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%
11.3 KB · 307 lines swift
Raw Blame History
1//2//  SidebarView.swift3//  Zyquo Cloud4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Sidebar per the Phase 4 spec: wordmark, search field, prominent New Chat,9//  conversation list grouped Pinned/Today/Yesterday/Previous 7 Days/Older,10//  hover-revealed pin/delete, footer with settings + usage summary.11//1213import SwiftUI1415struct SidebarView: View {16    @EnvironmentObject private var store: ConversationStore17    @EnvironmentObject private var catalog: ModelCatalog18    @FocusState private var searchFocused: Bool19    @State private var activeTag: String?2021    var body: some View {22        VStack(spacing: 0) {23            wordmark24            searchField25            newChatButton26            conversationList27            if !allTags.isEmpty {28                ZyquoHairline()29                tagsSection30            }31            ZyquoHairline()32            footer33        }34        .frame(minWidth: ZyquoMetrics.sidebarWidth)35        .background(36            // ⌘F focuses conversation search.37            Button("") { searchFocused = true }38                .keyboardShortcut("f", modifiers: .command)39                .hidden()40        )41    }4243    // MARK: - Tags4445    private var allTags: [String] {46        Array(Set(store.conversations.flatMap(\.tags))).sorted()47    }4849    private var tagsSection: some View {50        ScrollView(.horizontal, showsIndicators: false) {51            HStack(spacing: ZyquoSpacing.xxs) {52                Image(systemName: "tag")53                    .font(.system(size: 9))54                    .foregroundStyle(ZyquoColor.textTertiary)55                ForEach(allTags, id: \.self) { tag in56                    Button {57                        activeTag = activeTag == tag ? nil : tag58                    } label: {59                        Text(tag)60                            .font(ZyquoFont.caption)61                            .foregroundStyle(activeTag == tag ? .white : ZyquoColor.textSecondary)62                            .padding(.horizontal, ZyquoSpacing.xs)63                            .padding(.vertical, 2)64                            .background(65                                Capsule().fill(activeTag == tag ? ZyquoColor.accent : ZyquoColor.surfaceSecondary)66                            )67                    }68                    .buttonStyle(.plain)69                }70            }71            .padding(.horizontal, ZyquoSpacing.sm)72            .padding(.vertical, ZyquoSpacing.xxs)73        }74    }7576    // MARK: - Sections7778    private var wordmark: some View {79        HStack(spacing: ZyquoSpacing.xs) {80            CloudZGlyph(size: 22)81            Text("Zyquo Cloud")82                .font(ZyquoFont.bodyEmphasis(size: 14))83                .foregroundStyle(ZyquoColor.textPrimary)84            Spacer()85        }86        .padding(.horizontal, ZyquoMetrics.contentInset)87        .padding(.top, ZyquoSpacing.sm)88        .padding(.bottom, ZyquoSpacing.xs)89    }9091    private var searchField: some View {92        HStack(spacing: ZyquoSpacing.xxs) {93            Image(systemName: "magnifyingglass")94                .font(.system(size: 11))95                .foregroundStyle(ZyquoColor.textTertiary)96            TextField("Search", text: $store.searchText)97                .textFieldStyle(.plain)98                .font(ZyquoFont.body(size: 12.5))99                .focused($searchFocused)100        }101        .padding(.horizontal, ZyquoSpacing.xs)102        .padding(.vertical, 5)103        .background(104            RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)105                .fill(ZyquoColor.surfaceSecondary.opacity(0.7))106        )107        .padding(.horizontal, ZyquoSpacing.sm)108        .padding(.bottom, ZyquoSpacing.xs)109    }110111    private var newChatButton: some View {112        Button {113            store.newConversation()114        } label: {115            HStack(spacing: ZyquoSpacing.xxs) {116                Image(systemName: "plus.bubble.fill")117                    .font(.system(size: 12, weight: .semibold))118                Text("New Chat")119                    .font(ZyquoFont.bodyEmphasis(size: 13))120            }121            .foregroundStyle(.white)122            .frame(maxWidth: .infinity)123            .padding(.vertical, 7)124            .background(125                RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)126                    .fill(ZyquoColor.accent)127            )128        }129        .buttonStyle(PressableButtonStyle())130        .keyboardShortcut("n", modifiers: .command)131        .padding(.horizontal, ZyquoSpacing.sm)132        .padding(.bottom, ZyquoSpacing.xs)133    }134135    /// Sidebar groups, additionally narrowed by the active tag chip.136    private var filteredGroups: [ConversationStore.SidebarGroup] {137        guard let tag = activeTag else { return store.sidebarGroups }138        return store.sidebarGroups.compactMap { group in139            let matching = group.conversations.filter { $0.tags.contains(tag) }140            return matching.isEmpty141                ? nil142                : ConversationStore.SidebarGroup(id: group.id, title: group.title, conversations: matching)143        }144    }145146    private var conversationList: some View {147        ScrollView {148            LazyVStack(alignment: .leading, spacing: 2, pinnedViews: []) {149                ForEach(filteredGroups) { group in150                    Text(group.title)151                        .font(ZyquoFont.caption)152                        .foregroundStyle(ZyquoColor.textTertiary)153                        .padding(.horizontal, ZyquoSpacing.md)154                        .padding(.top, ZyquoSpacing.sm)155                        .padding(.bottom, 2)156                    ForEach(group.conversations) { conversation in157                        ConversationRow(conversation: conversation)158                    }159                }160            }161            .padding(.horizontal, ZyquoSpacing.xs)162            .padding(.bottom, ZyquoSpacing.sm)163        }164    }165166    private var footer: some View {167        HStack {168            Button {169                SettingsOpener.open()170            } label: {171                Image(systemName: "gearshape")172                    .font(.system(size: 13))173                    .foregroundStyle(ZyquoColor.textSecondary)174            }175            .buttonStyle(.plain)176            .help("Settings")177            Spacer()178            Text(usageSummary)179                .font(ZyquoFont.caption)180                .foregroundStyle(ZyquoColor.textTertiary)181        }182        .padding(.horizontal, ZyquoMetrics.contentInset)183        .padding(.vertical, ZyquoSpacing.xs)184    }185186    private var usageSummary: String {187        let tokens = store.conversations.map { $0.totalUsage.totalTokens }.reduce(0, +)188        let cost = store.conversations.map(\.totalCost).reduce(0, +)189        guard tokens > 0 else { return "" }190        let tokenText = tokens >= 1_000_000191            ? String(format: "%.1fM tok", Double(tokens) / 1_000_000)192            : String(format: "%.1fK tok", Double(tokens) / 1_000)193        return cost > 0 ? String(format: "%@ · ~$%.2f", tokenText, cost) : tokenText194    }195}196197// MARK: - Row198199private struct ConversationRow: View {200    let conversation: Conversation201    @EnvironmentObject private var store: ConversationStore202    @State private var hovering = false203204    private var isSelected: Bool { store.selectedID == conversation.id }205206    var body: some View {207        Button {208            store.selectedID = conversation.id209        } label: {210            HStack(spacing: ZyquoSpacing.xs) {211                VStack(alignment: .leading, spacing: 1) {212                    Text(conversation.title)213                        .font(ZyquoFont.body(size: 13))214                        .foregroundStyle(ZyquoColor.textPrimary)215                        .lineLimit(1)216                    HStack(spacing: ZyquoSpacing.xxs) {217                        Text(conversation.modelID)218                            .font(ZyquoFont.caption)219                            .foregroundStyle(ZyquoColor.textTertiary)220                            .lineLimit(1)221                        Text(conversation.updatedAt, format: .relative(presentation: .named))222                            .font(ZyquoFont.caption)223                            .foregroundStyle(ZyquoColor.textTertiary)224                    }225                }226                Spacer(minLength: 0)227                if hovering {228                    rowActions229                } else if conversation.isPinned {230                    Image(systemName: "pin.fill")231                        .font(.system(size: 9))232                        .foregroundStyle(ZyquoColor.textTertiary)233                }234            }235            .padding(.horizontal, ZyquoSpacing.xs)236            .padding(.vertical, 5)237            .background(238                RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)239                    .fill(isSelected ? ZyquoColor.accentSubtle : (hovering ? ZyquoColor.surfaceSecondary.opacity(0.6) : .clear))240            )241            .contentShape(Rectangle())242        }243        .buttonStyle(.plain)244        .onHover { inside in245            withAnimation(ZyquoMotion.hover) { hovering = inside }246        }247        .contextMenu {248            Button(conversation.isPinned ? "Unpin" : "Pin") { store.togglePin(conversation.id) }249            Button("Add Tag…") { promptForTag() }250            if !conversation.tags.isEmpty {251                Menu("Remove Tag") {252                    ForEach(conversation.tags, id: \.self) { tag in253                        Button(tag) {254                            guard var updated = store.conversations.first(where: { $0.id == conversation.id }) else { return }255                            updated.tags.removeAll { $0 == tag }256                            store.update(updated)257                        }258                    }259                }260            }261            Button("Delete", role: .destructive) { store.delete(conversation.id) }262        }263    }264265    private func promptForTag() {266        let alert = NSAlert()267        alert.messageText = "Add Tag"268        alert.informativeText = "Tags group conversations in the sidebar."269        let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 220, height: 22))270        field.placeholderString = "Tag name"271        alert.accessoryView = field272        alert.addButton(withTitle: "Add")273        alert.addButton(withTitle: "Cancel")274        guard alert.runModal() == .alertFirstButtonReturn else { return }275        let tag = field.stringValue.trimmingCharacters(in: .whitespaces)276        guard !tag.isEmpty,277              var updated = store.conversations.first(where: { $0.id == conversation.id }),278              !updated.tags.contains(tag)279        else { return }280        updated.tags.append(tag)281        store.update(updated)282    }283284    private var rowActions: some View {285        HStack(spacing: ZyquoSpacing.xxs) {286            Button {287                store.togglePin(conversation.id)288            } label: {289                Image(systemName: conversation.isPinned ? "pin.slash" : "pin")290                    .font(.system(size: 10))291                    .foregroundStyle(ZyquoColor.textSecondary)292            }293            .buttonStyle(.plain)294            .help(conversation.isPinned ? "Unpin" : "Pin")295            Button {296                store.delete(conversation.id)297            } label: {298                Image(systemName: "trash")299                    .font(.system(size: 10))300                    .foregroundStyle(ZyquoColor.textSecondary)301            }302            .buttonStyle(.plain)303            .help("Delete")304        }305    }306}307