// // ChatView.swift // Zyquo Cloud // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The chat area: 52pt header (editable title, centered model chip, info), // transcript with 760pt centered column and smooth auto-scroll + jump-to- // bottom pill, and the floating input bar. // import SwiftUI struct ChatView: View { let conversationID: Conversation.ID @EnvironmentObject private var store: ConversationStore @EnvironmentObject private var catalog: ModelCatalog @EnvironmentObject private var appearance: AppearanceStore @State private var draft = "" @State private var draftAttachments: [Attachment] = [] @State private var titleDraft = "" @State private var editingTitle = false @State private var showingInfo = false @State private var showingCompare = false @State private var showingPalette = false @State private var pinnedToBottom = true private var conversation: Conversation? { store.conversations.first { $0.id == conversationID } } private var currentModel: AIModel? { guard let conversation else { return nil } return catalog.model(id: conversation.modelID, provider: conversation.provider) } var body: some View { VStack(spacing: 0) { header ZyquoHairline() if let conversation, conversation.messages.isEmpty { EmptyStateView( model: currentModel, onSelectModel: select(model:), onSuggestion: { draft = $0 } ) } else { transcript } InputBarView( text: $draft, attachments: $draftAttachments, isStreaming: store.isStreaming(conversationID), supportsVision: currentModel?.capabilities.vision ?? false, onSend: send, onStop: { store.stopStreaming(conversationID) }, parametersContent: { AnyView(parametersPopover) } ) } .background(ZyquoColor.background) .sheet(isPresented: $showingCompare) { CompareView() } .sheet(isPresented: $showingPalette) { CommandPaletteView( currentModel: currentModel, onSelectModel: select(model:), onInsertTemplate: { template in draft = PromptLibraryStore.apply(template, input: draft) }, onApplyPersona: applyPersona ) } .background( // Invisible ⌘K target for the command palette. Button("") { showingPalette = true } .keyboardShortcut("k", modifiers: .command) .hidden() ) } // MARK: - Header private var header: some View { ZStack { // Centered model chip. ModelChipView(model: currentModel, onSelect: select(model:)) HStack(spacing: ZyquoSpacing.xs) { if editingTitle { TextField("Title", text: $titleDraft, onCommit: { store.rename(conversationID, to: titleDraft) editingTitle = false }) .textFieldStyle(.plain) .font(ZyquoFont.bodyEmphasis(size: 13)) .frame(maxWidth: 220) } else { Text(conversation?.title ?? "") .font(ZyquoFont.bodyEmphasis(size: 13)) .foregroundStyle(ZyquoColor.textPrimary) .lineLimit(1) .frame(maxWidth: 220, alignment: .leading) .onTapGesture(count: 2) { titleDraft = conversation?.title ?? "" editingTitle = true } } Spacer() headerButtons } } .padding(.horizontal, ZyquoMetrics.contentInset) .frame(height: ZyquoMetrics.chatHeaderHeight) } private var headerButtons: some View { HStack(spacing: ZyquoSpacing.xs) { Button { showingCompare = true } label: { Image(systemName: "rectangle.split.2x1") .font(.system(size: 12)) .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) .help("Compare models") Button { exportMarkdown() } label: { Image(systemName: "square.and.arrow.up") .font(.system(size: 12)) .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) .keyboardShortcut("e", modifiers: [.command, .shift]) .help("Export conversation (⌘⇧E)") Button { showingInfo.toggle() } label: { Image(systemName: "info.circle") .font(.system(size: 12)) .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) .help("Conversation info") .popover(isPresented: $showingInfo, arrowEdge: .bottom) { infoPopover } } } // MARK: - Transcript private var transcript: some View { ScrollViewReader { proxy in ZStack(alignment: .bottom) { ScrollView { LazyVStack(spacing: ZyquoMetrics.verticalTurnRhythm) { ForEach(conversation?.messages ?? []) { message in MessageBubbleView( message: message, fontSize: appearance.chatFontSize, onCopy: { copyToPasteboard(message.text) }, onEditResend: message.role == .user ? { newText in store.editAndResend(messageID: message.id, newText: newText, in: conversationID) } : nil, onRegenerate: message.role == .assistant ? { store.regenerate(in: conversationID) } : nil, onDelete: { store.deleteMessage(message.id, in: conversationID) }, onQuote: { text in draft = text.split(separator: "\n").map { "> \($0)" }.joined(separator: "\n") + "\n\n" + draft } ) .id(message.id) } Color.clear.frame(height: 1).id("bottom") } .padding(.horizontal, ZyquoMetrics.contentInset) .padding(.vertical, ZyquoMetrics.contentInset) .frame(maxWidth: ZyquoMetrics.maxMessageColumnWidth) .frame(maxWidth: .infinity) } .onChange(of: lastMessageFingerprint) { _ in if pinnedToBottom { proxy.scrollTo("bottom", anchor: .bottom) } } if !pinnedToBottom, store.isStreaming(conversationID) { jumpToBottomPill(proxy: proxy) } } } } /// Changes when content grows so auto-scroll can follow the stream. private var lastMessageFingerprint: Int { guard let last = conversation?.messages.last else { return 0 } return last.text.count &+ (last.reasoning?.count ?? 0) &* 31 &+ (conversation?.messages.count ?? 0) &* 7 } private func jumpToBottomPill(proxy: ScrollViewProxy) -> some View { Button { pinnedToBottom = true withAnimation(ZyquoMotion.send) { proxy.scrollTo("bottom", anchor: .bottom) } } label: { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: "arrow.down") .font(.system(size: 10, weight: .semibold)) Text("Jump to latest") .font(ZyquoFont.caption) } .foregroundStyle(ZyquoColor.textPrimary) .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, 5) .background(Capsule().fill(ZyquoColor.surface)) .overlay(Capsule().strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)) .zyquoSoftShadow() } .buttonStyle(PressableButtonStyle()) .padding(.bottom, ZyquoSpacing.xs) } // MARK: - Popovers private var infoPopover: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { Text("Conversation") .font(ZyquoFont.bodyEmphasis()) if let conversation { LabeledContent { Text("\(conversation.totalUsage.inputTokens) in · \(conversation.totalUsage.outputTokens) out") } label: { Text("Tokens") } LabeledContent { Text(String(format: "~$%.4f", conversation.totalCost)) } label: { Text("Est. cost") } Divider() Text("System prompt") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) TextEditor(text: Binding( get: { conversation.systemPrompt ?? "" }, set: { newValue in var updated = conversation updated.systemPrompt = newValue.isEmpty ? nil : newValue store.update(updated) } )) .font(ZyquoFont.body(size: 12)) .frame(width: 300, height: 90) .scrollContentBackground(.hidden) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surfaceSecondary) ) } } .font(ZyquoFont.body(size: 12.5)) .padding(ZyquoSpacing.md) .frame(width: 340) } private var parametersPopover: some View { ParametersEditorView( parameters: Binding( get: { conversation?.parameters ?? ChatParameters() }, set: { newValue in guard var updated = conversation else { return } updated.parameters = newValue store.update(updated) } ), support: currentModel?.parameterSupport ?? ParameterSupport() ) } // MARK: - Actions private func send() { let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty || !draftAttachments.isEmpty else { return } let attachments = draftAttachments withAnimation(ZyquoMotion.send) { draft = "" draftAttachments = [] } pinnedToBottom = true store.send(text: text, attachments: attachments, in: conversationID) } private func select(model: AIModel) { guard var conversation else { return } conversation.modelID = model.id conversation.provider = model.provider store.update(conversation) } private func applyPersona(_ persona: Persona) { guard var conversation else { return } conversation.systemPrompt = persona.systemPrompt conversation.personaID = persona.id conversation.parameters = persona.parameters if let modelID = persona.modelID, let provider = persona.provider { conversation.modelID = modelID conversation.provider = provider } store.update(conversation) } private func copyToPasteboard(_ text: String) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) } private func exportMarkdown() { guard let conversation else { return } ConversationExporter.presentSavePanel(for: conversation) } }