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// 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