// // MessageRow.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI /// One transcript message: bubble, hover timestamp + actions, thinking /// disclosure and the stats caption line for assistant messages. struct MessageRow: View { let message: Message let conversation: Conversation @Environment(AppModel.self) private var app @State private var hovering = false @State private var editing = false @State private var editText = "" var body: some View { HStack(alignment: .top, spacing: 0) { if message.role == .user { Spacer(minLength: ZyquoTheme.Spacing.xxl) } VStack(alignment: message.role == .user ? .trailing : .leading, spacing: ZyquoTheme.Spacing.xxs) { bubble captionLine } .frame( maxWidth: ZyquoTheme.messageColumnMaxWidth * 0.86, alignment: message.role == .user ? .trailing : .leading ) if message.role == .assistant { Spacer(minLength: ZyquoTheme.Spacing.xxl) } } .onHover { hovering = $0 } .transition(.opacity.combined(with: .offset(y: 6))) } @ViewBuilder private var bubble: some View { VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { if let thinking = message.thinking, !thinking.isEmpty { ThinkingDisclosure(thinking: thinking, isLive: false) } if editing { editor } else { MarkdownText(message.content) .textSelection(.enabled) } } .padding(ZyquoTheme.Spacing.s) .background(bubbleBackground, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)) .overlay( RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m) .stroke(message.role == .assistant ? ZyquoTheme.border : .clear, lineWidth: ZyquoTheme.hairline) ) .contextMenu { actions } } private var bubbleBackground: Color { message.role == .user ? ZyquoTheme.accentSubtle : ZyquoTheme.surface } private var editor: some View { VStack(alignment: .trailing, spacing: ZyquoTheme.Spacing.xs) { TextEditor(text: $editText) .font(ZyquoTheme.chatBody) .frame(minHeight: 60, maxHeight: 160) .scrollContentBackground(.hidden) HStack { Button("Cancel") { editing = false } .buttonStyle(.bordered) Button("Resend") { editing = false app.chat.editAndResend(messageID: message.id, newText: editText, in: conversation) } .buttonStyle(.borderedProminent) .tint(ZyquoTheme.accent) .disabled(editText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } .frame(width: 420) } @ViewBuilder private var actions: some View { Button("Copy") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(message.content, forType: .string) } if message.role == .user { Button("Edit & Resend") { editText = message.content editing = true } } if message.role == .assistant { Button("Regenerate") { app.chat.regenerate(in: conversation) } .disabled(app.chat.isGenerating) } Button("Quote Reply") { let quoted = message.content .split(separator: "\n", omittingEmptySubsequences: false) .map { "> \($0)" } .joined(separator: "\n") NotificationCenter.default.post(name: .zyquoQuoteReply, object: quoted + "\n\n") } Divider() Button("Delete", role: .destructive) { var c = conversation c.messages.removeAll { $0.id == message.id } app.update(c, touch: false) } } @ViewBuilder private var captionLine: some View { HStack(spacing: ZyquoTheme.Spacing.xs) { if message.role == .assistant, let stats = message.stats { Text(String( format: "⚡ %.1f tok/s · %d tokens · %.1fs to first token", stats.tokensPerSecond, stats.generationTokenCount, stats.timeToFirstToken )) .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) } if hovering { Text(message.date, format: .dateTime.hour().minute()) .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) .transition(.opacity) } } .animation(.easeOut(duration: 0.08), value: hovering) } } /// Collapsible "Thinking…" section for reasoning models. struct ThinkingDisclosure: View { let thinking: String let isLive: Bool @State private var expanded = false var body: some View { VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xxs) { Button { withAnimation(.snappy(duration: 0.2)) { expanded.toggle() } } label: { HStack(spacing: ZyquoTheme.Spacing.xxs) { Image(systemName: "brain") .font(.system(size: 10)) Text(isLive ? "Thinking…" : "Thought process") .font(ZyquoTheme.caption.weight(.medium)) Image(systemName: "chevron.right") .font(.system(size: 8, weight: .semibold)) .rotationEffect(.degrees(expanded ? 90 : 0)) } .foregroundStyle(ZyquoTheme.textSecondary) } .buttonStyle(.plain) if expanded || isLive { Text(thinking) .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) .lineSpacing(3) .textSelection(.enabled) .padding(ZyquoTheme.Spacing.xs) .frame(maxWidth: .infinity, alignment: .leading) .background(ZyquoTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) .frame(maxHeight: isLive && !expanded ? 96 : .infinity) .clipped() } } } } extension Notification.Name { static let zyquoQuoteReply = Notification.Name("zyquoQuoteReply") }