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// ChatView.swift3// Zyquo Cloud4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// The chat area: 52pt header (editable title, centered model chip, info),9// transcript with 760pt centered column and smooth auto-scroll + jump-to-10// bottom pill, and the floating input bar.11//1213import SwiftUI1415struct ChatView: View {16 let conversationID: Conversation.ID1718 @EnvironmentObject private var store: ConversationStore19 @EnvironmentObject private var catalog: ModelCatalog20 @EnvironmentObject private var appearance: AppearanceStore2122 @State private var draft = ""23 @State private var draftAttachments: [Attachment] = []24 @State private var titleDraft = ""25 @State private var editingTitle = false26 @State private var showingInfo = false27 @State private var showingCompare = false28 @State private var showingPalette = false29 @State private var pinnedToBottom = true3031 private var conversation: Conversation? {32 store.conversations.first { $0.id == conversationID }33 }3435 private var currentModel: AIModel? {36 guard let conversation else { return nil }37 return catalog.model(id: conversation.modelID, provider: conversation.provider)38 }3940 var body: some View {41 VStack(spacing: 0) {42 header43 ZyquoHairline()44 if let conversation, conversation.messages.isEmpty {45 EmptyStateView(46 model: currentModel,47 onSelectModel: select(model:),48 onSuggestion: { draft = $0 }49 )50 } else {51 transcript52 }53 InputBarView(54 text: $draft,55 attachments: $draftAttachments,56 isStreaming: store.isStreaming(conversationID),57 supportsVision: currentModel?.capabilities.vision ?? false,58 onSend: send,59 onStop: { store.stopStreaming(conversationID) },60 parametersContent: { AnyView(parametersPopover) }61 )62 }63 .background(ZyquoColor.background)64 .sheet(isPresented: $showingCompare) {65 CompareView()66 }67 .sheet(isPresented: $showingPalette) {68 CommandPaletteView(69 currentModel: currentModel,70 onSelectModel: select(model:),71 onInsertTemplate: { template in72 draft = PromptLibraryStore.apply(template, input: draft)73 },74 onApplyPersona: applyPersona75 )76 }77 .background(78 // Invisible ⌘K target for the command palette.79 Button("") { showingPalette = true }80 .keyboardShortcut("k", modifiers: .command)81 .hidden()82 )83 }8485 // MARK: - Header8687 private var header: some View {88 ZStack {89 // Centered model chip.90 ModelChipView(model: currentModel, onSelect: select(model:))91 HStack(spacing: ZyquoSpacing.xs) {92 if editingTitle {93 TextField("Title", text: $titleDraft, onCommit: {94 store.rename(conversationID, to: titleDraft)95 editingTitle = false96 })97 .textFieldStyle(.plain)98 .font(ZyquoFont.bodyEmphasis(size: 13))99 .frame(maxWidth: 220)100 } else {101 Text(conversation?.title ?? "")102 .font(ZyquoFont.bodyEmphasis(size: 13))103 .foregroundStyle(ZyquoColor.textPrimary)104 .lineLimit(1)105 .frame(maxWidth: 220, alignment: .leading)106 .onTapGesture(count: 2) {107 titleDraft = conversation?.title ?? ""108 editingTitle = true109 }110 }111 Spacer()112 headerButtons113 }114 }115 .padding(.horizontal, ZyquoMetrics.contentInset)116 .frame(height: ZyquoMetrics.chatHeaderHeight)117 }118119 private var headerButtons: some View {120 HStack(spacing: ZyquoSpacing.xs) {121 Button {122 showingCompare = true123 } label: {124 Image(systemName: "rectangle.split.2x1")125 .font(.system(size: 12))126 .foregroundStyle(ZyquoColor.textSecondary)127 }128 .buttonStyle(.plain)129 .help("Compare models")130131 Button {132 exportMarkdown()133 } label: {134 Image(systemName: "square.and.arrow.up")135 .font(.system(size: 12))136 .foregroundStyle(ZyquoColor.textSecondary)137 }138 .buttonStyle(.plain)139 .keyboardShortcut("e", modifiers: [.command, .shift])140 .help("Export conversation (⌘⇧E)")141142 Button {143 showingInfo.toggle()144 } label: {145 Image(systemName: "info.circle")146 .font(.system(size: 12))147 .foregroundStyle(ZyquoColor.textSecondary)148 }149 .buttonStyle(.plain)150 .help("Conversation info")151 .popover(isPresented: $showingInfo, arrowEdge: .bottom) {152 infoPopover153 }154 }155 }156157 // MARK: - Transcript158159 private var transcript: some View {160 ScrollViewReader { proxy in161 ZStack(alignment: .bottom) {162 ScrollView {163 LazyVStack(spacing: ZyquoMetrics.verticalTurnRhythm) {164 ForEach(conversation?.messages ?? []) { message in165 MessageBubbleView(166 message: message,167 fontSize: appearance.chatFontSize,168 onCopy: { copyToPasteboard(message.text) },169 onEditResend: message.role == .user170 ? { newText in store.editAndResend(messageID: message.id, newText: newText, in: conversationID) }171 : nil,172 onRegenerate: message.role == .assistant173 ? { store.regenerate(in: conversationID) }174 : nil,175 onDelete: { store.deleteMessage(message.id, in: conversationID) },176 onQuote: { text in177 draft = text.split(separator: "\n").map { "> \($0)" }.joined(separator: "\n") + "\n\n" + draft178 }179 )180 .id(message.id)181 }182 Color.clear.frame(height: 1).id("bottom")183 }184 .padding(.horizontal, ZyquoMetrics.contentInset)185 .padding(.vertical, ZyquoMetrics.contentInset)186 .frame(maxWidth: ZyquoMetrics.maxMessageColumnWidth)187 .frame(maxWidth: .infinity)188 }189 .onChange(of: lastMessageFingerprint) { _ in190 if pinnedToBottom {191 proxy.scrollTo("bottom", anchor: .bottom)192 }193 }194 if !pinnedToBottom, store.isStreaming(conversationID) {195 jumpToBottomPill(proxy: proxy)196 }197 }198 }199 }200201 /// Changes when content grows so auto-scroll can follow the stream.202 private var lastMessageFingerprint: Int {203 guard let last = conversation?.messages.last else { return 0 }204 return last.text.count &+ (last.reasoning?.count ?? 0) &* 31 &+ (conversation?.messages.count ?? 0) &* 7205 }206207 private func jumpToBottomPill(proxy: ScrollViewProxy) -> some View {208 Button {209 pinnedToBottom = true210 withAnimation(ZyquoMotion.send) { proxy.scrollTo("bottom", anchor: .bottom) }211 } label: {212 HStack(spacing: ZyquoSpacing.xxs) {213 Image(systemName: "arrow.down")214 .font(.system(size: 10, weight: .semibold))215 Text("Jump to latest")216 .font(ZyquoFont.caption)217 }218 .foregroundStyle(ZyquoColor.textPrimary)219 .padding(.horizontal, ZyquoSpacing.sm)220 .padding(.vertical, 5)221 .background(Capsule().fill(ZyquoColor.surface))222 .overlay(Capsule().strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline))223 .zyquoSoftShadow()224 }225 .buttonStyle(PressableButtonStyle())226 .padding(.bottom, ZyquoSpacing.xs)227 }228229 // MARK: - Popovers230231 private var infoPopover: some View {232 VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {233 Text("Conversation")234 .font(ZyquoFont.bodyEmphasis())235 if let conversation {236 LabeledContent {237 Text("\(conversation.totalUsage.inputTokens) in · \(conversation.totalUsage.outputTokens) out")238 } label: { Text("Tokens") }239 LabeledContent {240 Text(String(format: "~$%.4f", conversation.totalCost))241 } label: { Text("Est. cost") }242 Divider()243 Text("System prompt")244 .font(ZyquoFont.caption)245 .foregroundStyle(ZyquoColor.textSecondary)246 TextEditor(text: Binding(247 get: { conversation.systemPrompt ?? "" },248 set: { newValue in249 var updated = conversation250 updated.systemPrompt = newValue.isEmpty ? nil : newValue251 store.update(updated)252 }253 ))254 .font(ZyquoFont.body(size: 12))255 .frame(width: 300, height: 90)256 .scrollContentBackground(.hidden)257 .background(258 RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)259 .fill(ZyquoColor.surfaceSecondary)260 )261 }262 }263 .font(ZyquoFont.body(size: 12.5))264 .padding(ZyquoSpacing.md)265 .frame(width: 340)266 }267268 private var parametersPopover: some View {269 ParametersEditorView(270 parameters: Binding(271 get: { conversation?.parameters ?? ChatParameters() },272 set: { newValue in273 guard var updated = conversation else { return }274 updated.parameters = newValue275 store.update(updated)276 }277 ),278 support: currentModel?.parameterSupport ?? ParameterSupport()279 )280 }281282 // MARK: - Actions283284 private func send() {285 let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)286 guard !text.isEmpty || !draftAttachments.isEmpty else { return }287 let attachments = draftAttachments288 withAnimation(ZyquoMotion.send) {289 draft = ""290 draftAttachments = []291 }292 pinnedToBottom = true293 store.send(text: text, attachments: attachments, in: conversationID)294 }295296 private func select(model: AIModel) {297 guard var conversation else { return }298 conversation.modelID = model.id299 conversation.provider = model.provider300 store.update(conversation)301 }302303 private func applyPersona(_ persona: Persona) {304 guard var conversation else { return }305 conversation.systemPrompt = persona.systemPrompt306 conversation.personaID = persona.id307 conversation.parameters = persona.parameters308 if let modelID = persona.modelID, let provider = persona.provider {309 conversation.modelID = modelID310 conversation.provider = provider311 }312 store.update(conversation)313 }314315 private func copyToPasteboard(_ text: String) {316 NSPasteboard.general.clearContents()317 NSPasteboard.general.setString(text, forType: .string)318 }319320 private func exportMarkdown() {321 guard let conversation else { return }322 ConversationExporter.presentSavePanel(for: conversation)323 }324}325