SPB Git

spb/zyquo-cloud Public MIT

Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.

Swift 97.4% Shell 1.7% Makefile 1%
12.5 KB · 343 lines swift
Raw Blame History
1//2//  MessageBubbleView.swift3//  Zyquo Cloud4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  One transcript turn: user messages right-aligned in accentSubtle bubbles,9//  assistant messages left-aligned on surface with the provider glyph avatar.10//  Hover reveals timestamp + tokens/cost and message actions. Reasoning models11//  get a collapsible "Thinking…" section; Perplexity citations render as12//  numbered chips.13//1415import SwiftUI1617struct MessageBubbleView: View {18    let message: Message19    let fontSize: Double20    var onCopy: () -> Void = {}21    var onEditResend: ((String) -> Void)?22    var onRegenerate: (() -> Void)?23    var onDelete: () -> Void = {}24    var onQuote: ((String) -> Void)?2526    @State private var hovering = false27    @State private var showThinking = false28    @State private var editing = false29    @State private var editText = ""30    @Environment(\.openURL) private var openURL3132    var body: some View {33        HStack(alignment: .top, spacing: ZyquoSpacing.xs) {34            if message.role == .user { Spacer(minLength: 60) }35            if message.role == .assistant { avatar }36            VStack(alignment: message.role == .user ? .trailing : .leading, spacing: ZyquoSpacing.xxs) {37                bubble38                metadata39                    .opacity(hovering ? 1 : 0)40            }41            if message.role == .assistant { Spacer(minLength: 60) }42        }43        .onHover { inside in44            withAnimation(ZyquoMotion.hover) { hovering = inside }45        }46    }4748    // MARK: - Avatar4950    private var avatar: some View {51        Image(systemName: message.provider?.symbolName ?? "sparkle")52            .font(.system(size: 12, weight: .medium))53            .foregroundStyle(ZyquoColor.accent)54            .frame(width: 26, height: 26)55            .background(Circle().fill(ZyquoColor.accentSubtle))56            .padding(.top, 2)57    }5859    // MARK: - Bubble6061    @ViewBuilder62    private var bubble: some View {63        VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {64            if !message.attachments.isEmpty {65                attachmentPreviews66            }67            if let reasoning = message.reasoning, !reasoning.isEmpty {68                thinkingSection(reasoning)69            }70            if editing {71                editorView72            } else if message.role == .assistant {73                MarkdownView(text: message.text, fontSize: fontSize)74                if message.isStreaming {75                    StreamingCaret()76                }77            } else {78                Text(message.text)79                    .font(ZyquoFont.body(size: fontSize))80                    .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor)81                    .foregroundStyle(ZyquoColor.textPrimary)82                    .textSelection(.enabled)83            }84            if !message.citations.isEmpty {85                citationChips86            }87            if let error = message.errorText {88                errorView(error)89            }90        }91        .padding(.horizontal, ZyquoSpacing.sm)92        .padding(.vertical, ZyquoSpacing.xs + 2)93        .background(94            RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)95                .fill(message.role == .user ? ZyquoColor.accentSubtle : ZyquoColor.surface)96                .overlay(97                    RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)98                        .strokeBorder(ZyquoColor.border, lineWidth: message.role == .assistant ? ZyquoMetrics.hairline : 0)99                )100        )101        .contextMenu { actionButtons }102    }103104    private var editorView: some View {105        VStack(alignment: .trailing, spacing: ZyquoSpacing.xs) {106            TextEditor(text: $editText)107                .font(ZyquoFont.body(size: fontSize))108                .frame(minWidth: 320, minHeight: 60, maxHeight: 200)109                .scrollContentBackground(.hidden)110            HStack {111                Button("Cancel") { editing = false }112                Button("Resend") {113                    editing = false114                    onEditResend?(editText)115                }116                .buttonStyle(.borderedProminent)117                .disabled(editText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)118            }119        }120    }121122    // MARK: - Thinking123124    private func thinkingSection(_ reasoning: String) -> some View {125        VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {126            Button {127                withAnimation(ZyquoMotion.picker) { showThinking.toggle() }128            } label: {129                HStack(spacing: ZyquoSpacing.xxs) {130                    Image(systemName: "chevron.right")131                        .font(.system(size: 8, weight: .semibold))132                        .rotationEffect(.degrees(showThinking ? 90 : 0))133                    Text(message.isStreaming && message.text.isEmpty ? "Thinking…" : "Thought process")134                        .font(ZyquoFont.caption)135                }136                .foregroundStyle(ZyquoColor.textSecondary)137            }138            .buttonStyle(.plain)139            if showThinking {140                Text(reasoning)141                    .font(ZyquoFont.code(size: fontSize - 2))142                    .foregroundStyle(ZyquoColor.textSecondary)143                    .lineSpacing(3)144                    .textSelection(.enabled)145                    .padding(ZyquoSpacing.xs)146                    .frame(maxWidth: .infinity, alignment: .leading)147                    .background(148                        RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)149                            .fill(ZyquoColor.surfaceSecondary)150                    )151            }152        }153    }154155    // MARK: - Citations156157    private var citationChips: some View {158        FlowLayoutCompat(spacing: ZyquoSpacing.xxs) {159            ForEach(message.citations) { citation in160                Button {161                    openURL(citation.url)162                } label: {163                    HStack(spacing: 3) {164                        Text("\(citation.index)")165                            .font(.system(size: 9, weight: .semibold))166                            .foregroundStyle(.white)167                            .frame(width: 13, height: 13)168                            .background(Circle().fill(ZyquoColor.accent))169                        Text(citation.title ?? citation.url.host() ?? citation.url.absoluteString)170                            .font(ZyquoFont.caption)171                            .foregroundStyle(ZyquoColor.textSecondary)172                            .lineLimit(1)173                    }174                    .padding(.horizontal, ZyquoSpacing.xs)175                    .padding(.vertical, 3)176                    .background(Capsule().fill(ZyquoColor.surfaceSecondary))177                }178                .buttonStyle(.plain)179                .help(citation.url.absoluteString)180            }181        }182    }183184    private func errorView(_ error: String) -> some View {185        HStack(spacing: ZyquoSpacing.xxs) {186            Image(systemName: "exclamationmark.triangle.fill")187                .font(.system(size: 11))188            Text(error)189                .font(ZyquoFont.body(size: fontSize - 1))190                .textSelection(.enabled)191        }192        .foregroundStyle(ZyquoColor.danger)193        .padding(ZyquoSpacing.xs)194        .background(195            RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)196                .fill(ZyquoColor.danger.opacity(0.08))197        )198    }199200    private var attachmentPreviews: some View {201        HStack(spacing: ZyquoSpacing.xs) {202            ForEach(message.attachments) { attachment in203                if attachment.kind == .image, let image = NSImage(data: attachment.data) {204                    Image(nsImage: image)205                        .resizable()206                        .aspectRatio(contentMode: .fill)207                        .frame(width: 120, height: 90)208                        .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous))209                } else {210                    ZyquoBadge(text: attachment.fileName)211                }212            }213        }214    }215216    // MARK: - Metadata & actions217218    private var metadata: some View {219        HStack(spacing: ZyquoSpacing.xs) {220            if message.role == .assistant, hovering {221                actionIcons222            }223            Text(message.createdAt, format: .dateTime.hour().minute())224                .font(ZyquoFont.caption)225                .foregroundStyle(ZyquoColor.textTertiary)226            if let usage = message.usage {227                Text("\(usage.inputTokens)\(usage.outputTokens) tok")228                    .font(ZyquoFont.caption)229                    .foregroundStyle(ZyquoColor.textTertiary)230            }231            if let cost = message.estimatedCost, cost > 0 {232                Text(String(format: "~$%.4f", cost))233                    .font(ZyquoFont.caption)234                    .foregroundStyle(ZyquoColor.textTertiary)235            }236            if message.role == .user, hovering {237                actionIcons238            }239        }240    }241242    private var actionIcons: some View {243        HStack(spacing: ZyquoSpacing.xxs) {244            iconButton("doc.on.doc", help: "Copy") { onCopy() }245            if message.role == .user, onEditResend != nil {246                iconButton("pencil", help: "Edit & resend") {247                    editText = message.text248                    editing = true249                }250            }251            if message.role == .assistant, let onRegenerate {252                iconButton("arrow.clockwise", help: "Regenerate") { onRegenerate() }253            }254            if let onQuote {255                iconButton("quote.opening", help: "Quote reply") { onQuote(message.text) }256            }257            iconButton("trash", help: "Delete") { onDelete() }258        }259    }260261    @ViewBuilder262    private var actionButtons: some View {263        Button("Copy") { onCopy() }264        if message.role == .user, onEditResend != nil {265            Button("Edit & Resend") {266                editText = message.text267                editing = true268            }269        }270        if message.role == .assistant, let onRegenerate {271            Button("Regenerate") { onRegenerate() }272        }273        if let onQuote {274            Button("Quote Reply") { onQuote(message.text) }275        }276        Divider()277        Button("Delete", role: .destructive) { onDelete() }278    }279280    private func iconButton(_ symbol: String, help: String, action: @escaping () -> Void) -> some View {281        Button(action: action) {282            Image(systemName: symbol)283                .font(.system(size: 10))284                .foregroundStyle(ZyquoColor.textSecondary)285        }286        .buttonStyle(.plain)287        .help(help)288    }289}290291/// Blinking caret shown at the tail of a streaming message.292struct StreamingCaret: View {293    @State private var visible = true294295    var body: some View {296        RoundedRectangle(cornerRadius: 1)297            .fill(ZyquoColor.accent)298            .frame(width: 7, height: 15)299            .opacity(visible ? 1 : 0.15)300            .onAppear {301                withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) {302                    visible = false303                }304            }305    }306}307308/// Minimal flow layout for citation chips (macOS 13-compatible Layout).309struct FlowLayoutCompat: Layout {310    var spacing: CGFloat = 4311312    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {313        let width = proposal.width ?? 600314        var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0315        for subview in subviews {316            let size = subview.sizeThatFits(.unspecified)317            if x + size.width > width, x > 0 {318                x = 0319                y += rowHeight + spacing320                rowHeight = 0321            }322            x += size.width + spacing323            rowHeight = max(rowHeight, size.height)324        }325        return CGSize(width: width, height: y + rowHeight)326    }327328    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {329        var x = bounds.minX, y = bounds.minY, rowHeight: CGFloat = 0330        for subview in subviews {331            let size = subview.sizeThatFits(.unspecified)332            if x + size.width > bounds.maxX, x > bounds.minX {333                x = bounds.minX334                y += rowHeight + spacing335                rowHeight = 0336            }337            subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))338            x += size.width + spacing339            rowHeight = max(rowHeight, size.height)340        }341    }342}343