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%

phase6: sidebar tags + ⌘F focus; checkpoint complete

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 12 days ago (Jul 30, 2026) parent 4295d60

Showing 2 changed files with +117 and −6

modified Sources/ZyquoCloud/Views/Sidebar/SidebarView.swift +89 −1
@@ -15,6 +15,8 @@ import SwiftUI
15 15 struct SidebarView: View {
16 16 @EnvironmentObject private var store: ConversationStore
17 17 @EnvironmentObject private var catalog: ModelCatalog
18 + @FocusState private var searchFocused: Bool
19 + @State private var activeTag: String?
18 20
19 21 var body: some View {
20 22 VStack(spacing: 0) {
@@ -22,10 +24,53 @@ struct SidebarView: View {
22 24 searchField
23 25 newChatButton
24 26 conversationList
27 + if !allTags.isEmpty {
28 + ZyquoHairline()
29 + tagsSection
30 + }
25 31 ZyquoHairline()
26 32 footer
27 33 }
28 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 + }
42 +
43 + // MARK: - Tags
44 +
45 + private var allTags: [String] {
46 + Array(Set(store.conversations.flatMap(\.tags))).sorted()
47 + }
48 +
49 + 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 in
56 + Button {
57 + activeTag = activeTag == tag ? nil : tag
58 + } 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 + }
29 74 }
30 75
31 76 // MARK: - Sections
@@ -51,6 +96,7 @@ struct SidebarView: View {
51 96 TextField("Search", text: $store.searchText)
52 97 .textFieldStyle(.plain)
53 98 .font(ZyquoFont.body(size: 12.5))
99 + .focused($searchFocused)
54 100 }
55 101 .padding(.horizontal, ZyquoSpacing.xs)
56 102 .padding(.vertical, 5)
@@ -86,10 +132,21 @@ struct SidebarView: View {
86 132 .padding(.bottom, ZyquoSpacing.xs)
87 133 }
88 134
135 + /// 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 in
139 + let matching = group.conversations.filter { $0.tags.contains(tag) }
140 + return matching.isEmpty
141 + ? nil
142 + : ConversationStore.SidebarGroup(id: group.id, title: group.title, conversations: matching)
143 + }
144 + }
145 +
89 146 private var conversationList: some View {
90 147 ScrollView {
91 148 LazyVStack(alignment: .leading, spacing: 2, pinnedViews: []) {
92 ForEach(store.sidebarGroups) { group in
149 + ForEach(filteredGroups) { group in
93 150 Text(group.title)
94 151 .font(ZyquoFont.caption)
95 152 .foregroundStyle(ZyquoColor.textTertiary)
@@ -189,10 +246,41 @@ private struct ConversationRow: View {
189 246 }
190 247 .contextMenu {
191 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 in
253 + 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 + }
192 261 Button("Delete", role: .destructive) { store.delete(conversation.id) }
193 262 }
194 263 }
195 264
265 + 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 = field
272 + 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 + }
283 +
196 284 private var rowActions: some View {
197 285 HStack(spacing: ZyquoSpacing.xxs) {
198 286 Button {
modified docs/PLAN.md +28 −5
@@ -88,11 +88,34 @@ plugin required by SDK 27's macro-based @State.
88 88 **Phase 5 checkpoint summary:** Icon is crisp and on-identity at every size (verified visually at
89 89 1024/512/256/128/64/32/16). SVG sources are the single source of truth in `assets/icon/`.
90 90
91 ## Phase 6 — Features (in progress)
92
93 Build order (compile after each): stores → chat engine → main window (sidebar/transcript/input) →
94 markdown+code rendering → settings → model picker/compare/quick chat → productivity (templates,
95 personas, export, search, titles) → menu bar extra + shortcuts.
91 +## Phase 6 — Features ✅ (completed 2026-07-30; design gate re-checked before Done)
92 +
93 +- [x] Core chat: sidebar (search ⌘F, pin, rename, delete, tags), streaming with stop (⌘.), model picker
94 + per conversation + per message, message actions (copy/edit&resend/regenerate/delete/quote),
95 + reasoning display (collapsible), Perplexity citations chips, per-message + per-conversation
96 + tokens & est. cost, system prompt per conversation + global default, gated parameters editor
97 +- [x] Vision & attachments: image + text-file drag/drop + picker, 56pt thumbnails, dashed drop highlight
98 +- [x] Productivity: 56-template prompt library + user templates ({{input}}), 8 personas + user personas,
99 + Quick Chat (⌥Space floating panel + "Continue in app"), compare mode (2–4 columns), export
100 + Markdown/PDF, full-text search, auto-titles via cheapest same-provider model
101 +- [x] macOS polish: ⌘N/⌘K palette/⌘F/⌘↩/⌘⇧E/⌥Space/⌘., menu bar extra (template icon, toggleable),
102 + empty state per spec
103 +- [x] Model management: 195-model built-in catalog, dynamic /models refresh + catalog diff in Settings,
104 + custom OpenAI-compatible models (ID + base URL), favorites
105 +- [x] Markdown: full rendering (tables, quotes, lists, links, headings) + code blocks with copy button,
106 + language label, 11-language syntax highlighter; streaming-safe, memoized
107 +- [x] Checkpoint: builds clean 0 warnings, 26 tests green, app launched + UI visually verified (dark)
108 +
109 +**Phase 6 checkpoint summary:** All Phase 6 features implemented and wired. UI verified running;
110 +light/dark render from the same tokens. End-to-end chat with live keys is exercised in Phase 7.
111 +
112 +## Phase 7 — API Verification (in progress)
113 +
114 +- [ ] Verify harness: app binary gains `--verify` mode reusing the exact production provider clients;
115 + `zyquo-verify` target execs it (keys via env from .env.keys, never committed)
116 +- [ ] /models diff vs catalog per provider; "Reply with exactly: OK" on every catalog chat model;
117 + streaming test ≥1 model/provider; vision test on 1 vision model/provider
118 +- [ ] Results table in docs/VERIFICATION.md; fix every failure (update PROVIDERS.md + catalog together)
96 119 ## Phase 2 — Architecture (pending)
97 120 ## Phase 3 — SecureKeyStore (pending)
98 121 ## Phase 4 — Design System (pending)
99 122