SPB Git

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%
12.1 KB · 325 lines swift
Raw Blame History
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