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%
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