// // MessageBubbleView.swift // Zyquo Cloud // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // One transcript turn: user messages right-aligned in accentSubtle bubbles, // assistant messages left-aligned on surface with the provider glyph avatar. // Hover reveals timestamp + tokens/cost and message actions. Reasoning models // get a collapsible "Thinking…" section; Perplexity citations render as // numbered chips. // import SwiftUI struct MessageBubbleView: View { let message: Message let fontSize: Double var onCopy: () -> Void = {} var onEditResend: ((String) -> Void)? var onRegenerate: (() -> Void)? var onDelete: () -> Void = {} var onQuote: ((String) -> Void)? @State private var hovering = false @State private var showThinking = false @State private var editing = false @State private var editText = "" @Environment(\.openURL) private var openURL var body: some View { HStack(alignment: .top, spacing: ZyquoSpacing.xs) { if message.role == .user { Spacer(minLength: 60) } if message.role == .assistant { avatar } VStack(alignment: message.role == .user ? .trailing : .leading, spacing: ZyquoSpacing.xxs) { bubble metadata .opacity(hovering ? 1 : 0) } if message.role == .assistant { Spacer(minLength: 60) } } .onHover { inside in withAnimation(ZyquoMotion.hover) { hovering = inside } } } // MARK: - Avatar private var avatar: some View { Image(systemName: message.provider?.symbolName ?? "sparkle") .font(.system(size: 12, weight: .medium)) .foregroundStyle(ZyquoColor.accent) .frame(width: 26, height: 26) .background(Circle().fill(ZyquoColor.accentSubtle)) .padding(.top, 2) } // MARK: - Bubble @ViewBuilder private var bubble: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { if !message.attachments.isEmpty { attachmentPreviews } if let reasoning = message.reasoning, !reasoning.isEmpty { thinkingSection(reasoning) } if editing { editorView } else if message.role == .assistant { MarkdownView(text: message.text, fontSize: fontSize) if message.isStreaming { StreamingCaret() } } else { Text(message.text) .font(ZyquoFont.body(size: fontSize)) .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor) .foregroundStyle(ZyquoColor.textPrimary) .textSelection(.enabled) } if !message.citations.isEmpty { citationChips } if let error = message.errorText { errorView(error) } } .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, ZyquoSpacing.xs + 2) .background( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .fill(message.role == .user ? ZyquoColor.accentSubtle : ZyquoColor.surface) .overlay( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .strokeBorder(ZyquoColor.border, lineWidth: message.role == .assistant ? ZyquoMetrics.hairline : 0) ) ) .contextMenu { actionButtons } } private var editorView: some View { VStack(alignment: .trailing, spacing: ZyquoSpacing.xs) { TextEditor(text: $editText) .font(ZyquoFont.body(size: fontSize)) .frame(minWidth: 320, minHeight: 60, maxHeight: 200) .scrollContentBackground(.hidden) HStack { Button("Cancel") { editing = false } Button("Resend") { editing = false onEditResend?(editText) } .buttonStyle(.borderedProminent) .disabled(editText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } } // MARK: - Thinking private func thinkingSection(_ reasoning: String) -> some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { Button { withAnimation(ZyquoMotion.picker) { showThinking.toggle() } } label: { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: "chevron.right") .font(.system(size: 8, weight: .semibold)) .rotationEffect(.degrees(showThinking ? 90 : 0)) Text(message.isStreaming && message.text.isEmpty ? "Thinking…" : "Thought process") .font(ZyquoFont.caption) } .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) if showThinking { Text(reasoning) .font(ZyquoFont.code(size: fontSize - 2)) .foregroundStyle(ZyquoColor.textSecondary) .lineSpacing(3) .textSelection(.enabled) .padding(ZyquoSpacing.xs) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surfaceSecondary) ) } } } // MARK: - Citations private var citationChips: some View { FlowLayoutCompat(spacing: ZyquoSpacing.xxs) { ForEach(message.citations) { citation in Button { openURL(citation.url) } label: { HStack(spacing: 3) { Text("\(citation.index)") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.white) .frame(width: 13, height: 13) .background(Circle().fill(ZyquoColor.accent)) Text(citation.title ?? citation.url.host() ?? citation.url.absoluteString) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) .lineLimit(1) } .padding(.horizontal, ZyquoSpacing.xs) .padding(.vertical, 3) .background(Capsule().fill(ZyquoColor.surfaceSecondary)) } .buttonStyle(.plain) .help(citation.url.absoluteString) } } } private func errorView(_ error: String) -> some View { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 11)) Text(error) .font(ZyquoFont.body(size: fontSize - 1)) .textSelection(.enabled) } .foregroundStyle(ZyquoColor.danger) .padding(ZyquoSpacing.xs) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.danger.opacity(0.08)) ) } private var attachmentPreviews: some View { HStack(spacing: ZyquoSpacing.xs) { ForEach(message.attachments) { attachment in if attachment.kind == .image, let image = NSImage(data: attachment.data) { Image(nsImage: image) .resizable() .aspectRatio(contentMode: .fill) .frame(width: 120, height: 90) .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)) } else { ZyquoBadge(text: attachment.fileName) } } } } // MARK: - Metadata & actions private var metadata: some View { HStack(spacing: ZyquoSpacing.xs) { if message.role == .assistant, hovering { actionIcons } Text(message.createdAt, format: .dateTime.hour().minute()) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) if let usage = message.usage { Text("\(usage.inputTokens)→\(usage.outputTokens) tok") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } if let cost = message.estimatedCost, cost > 0 { Text(String(format: "~$%.4f", cost)) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } if message.role == .user, hovering { actionIcons } } } private var actionIcons: some View { HStack(spacing: ZyquoSpacing.xxs) { iconButton("doc.on.doc", help: "Copy") { onCopy() } if message.role == .user, onEditResend != nil { iconButton("pencil", help: "Edit & resend") { editText = message.text editing = true } } if message.role == .assistant, let onRegenerate { iconButton("arrow.clockwise", help: "Regenerate") { onRegenerate() } } if let onQuote { iconButton("quote.opening", help: "Quote reply") { onQuote(message.text) } } iconButton("trash", help: "Delete") { onDelete() } } } @ViewBuilder private var actionButtons: some View { Button("Copy") { onCopy() } if message.role == .user, onEditResend != nil { Button("Edit & Resend") { editText = message.text editing = true } } if message.role == .assistant, let onRegenerate { Button("Regenerate") { onRegenerate() } } if let onQuote { Button("Quote Reply") { onQuote(message.text) } } Divider() Button("Delete", role: .destructive) { onDelete() } } private func iconButton(_ symbol: String, help: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: symbol) .font(.system(size: 10)) .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) .help(help) } } /// Blinking caret shown at the tail of a streaming message. struct StreamingCaret: View { @State private var visible = true var body: some View { RoundedRectangle(cornerRadius: 1) .fill(ZyquoColor.accent) .frame(width: 7, height: 15) .opacity(visible ? 1 : 0.15) .onAppear { withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) { visible = false } } } } /// Minimal flow layout for citation chips (macOS 13-compatible Layout). struct FlowLayoutCompat: Layout { var spacing: CGFloat = 4 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let width = proposal.width ?? 600 var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0 for subview in subviews { let size = subview.sizeThatFits(.unspecified) if x + size.width > width, x > 0 { x = 0 y += rowHeight + spacing rowHeight = 0 } x += size.width + spacing rowHeight = max(rowHeight, size.height) } return CGSize(width: width, height: y + rowHeight) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { var x = bounds.minX, y = bounds.minY, rowHeight: CGFloat = 0 for subview in subviews { let size = subview.sizeThatFits(.unspecified) if x + size.width > bounds.maxX, x > bounds.minX { x = bounds.minX y += rowHeight + spacing rowHeight = 0 } subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) x += size.width + spacing rowHeight = max(rowHeight, size.height) } } }