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