// // SidebarView.swift // Zyquo Cloud // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Sidebar per the Phase 4 spec: wordmark, search field, prominent New Chat, // conversation list grouped Pinned/Today/Yesterday/Previous 7 Days/Older, // hover-revealed pin/delete, footer with settings + usage summary. // import SwiftUI struct SidebarView: View { @EnvironmentObject private var store: ConversationStore @EnvironmentObject private var catalog: ModelCatalog @FocusState private var searchFocused: Bool @State private var activeTag: String? var body: some View { VStack(spacing: 0) { wordmark searchField newChatButton conversationList if !allTags.isEmpty { ZyquoHairline() tagsSection } ZyquoHairline() footer } .frame(minWidth: ZyquoMetrics.sidebarWidth) .background( // ⌘F focuses conversation search. Button("") { searchFocused = true } .keyboardShortcut("f", modifiers: .command) .hidden() ) } // MARK: - Tags private var allTags: [String] { Array(Set(store.conversations.flatMap(\.tags))).sorted() } private var tagsSection: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: "tag") .font(.system(size: 9)) .foregroundStyle(ZyquoColor.textTertiary) ForEach(allTags, id: \.self) { tag in Button { activeTag = activeTag == tag ? nil : tag } label: { Text(tag) .font(ZyquoFont.caption) .foregroundStyle(activeTag == tag ? .white : ZyquoColor.textSecondary) .padding(.horizontal, ZyquoSpacing.xs) .padding(.vertical, 2) .background( Capsule().fill(activeTag == tag ? ZyquoColor.accent : ZyquoColor.surfaceSecondary) ) } .buttonStyle(.plain) } } .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, ZyquoSpacing.xxs) } } // MARK: - Sections private var wordmark: some View { HStack(spacing: ZyquoSpacing.xs) { CloudZGlyph(size: 22) Text("Zyquo Cloud") .font(ZyquoFont.bodyEmphasis(size: 14)) .foregroundStyle(ZyquoColor.textPrimary) Spacer() } .padding(.horizontal, ZyquoMetrics.contentInset) .padding(.top, ZyquoSpacing.sm) .padding(.bottom, ZyquoSpacing.xs) } private var searchField: some View { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: "magnifyingglass") .font(.system(size: 11)) .foregroundStyle(ZyquoColor.textTertiary) TextField("Search", text: $store.searchText) .textFieldStyle(.plain) .font(ZyquoFont.body(size: 12.5)) .focused($searchFocused) } .padding(.horizontal, ZyquoSpacing.xs) .padding(.vertical, 5) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surfaceSecondary.opacity(0.7)) ) .padding(.horizontal, ZyquoSpacing.sm) .padding(.bottom, ZyquoSpacing.xs) } private var newChatButton: some View { Button { store.newConversation() } label: { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: "plus.bubble.fill") .font(.system(size: 12, weight: .semibold)) Text("New Chat") .font(ZyquoFont.bodyEmphasis(size: 13)) } .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding(.vertical, 7) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.accent) ) } .buttonStyle(PressableButtonStyle()) .keyboardShortcut("n", modifiers: .command) .padding(.horizontal, ZyquoSpacing.sm) .padding(.bottom, ZyquoSpacing.xs) } /// Sidebar groups, additionally narrowed by the active tag chip. private var filteredGroups: [ConversationStore.SidebarGroup] { guard let tag = activeTag else { return store.sidebarGroups } return store.sidebarGroups.compactMap { group in let matching = group.conversations.filter { $0.tags.contains(tag) } return matching.isEmpty ? nil : ConversationStore.SidebarGroup(id: group.id, title: group.title, conversations: matching) } } private var conversationList: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 2, pinnedViews: []) { ForEach(filteredGroups) { group in Text(group.title) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) .padding(.horizontal, ZyquoSpacing.md) .padding(.top, ZyquoSpacing.sm) .padding(.bottom, 2) ForEach(group.conversations) { conversation in ConversationRow(conversation: conversation) } } } .padding(.horizontal, ZyquoSpacing.xs) .padding(.bottom, ZyquoSpacing.sm) } } private var footer: some View { HStack { Button { SettingsOpener.open() } label: { Image(systemName: "gearshape") .font(.system(size: 13)) .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) .help("Settings") Spacer() Text(usageSummary) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } .padding(.horizontal, ZyquoMetrics.contentInset) .padding(.vertical, ZyquoSpacing.xs) } private var usageSummary: String { let tokens = store.conversations.map { $0.totalUsage.totalTokens }.reduce(0, +) let cost = store.conversations.map(\.totalCost).reduce(0, +) guard tokens > 0 else { return "" } let tokenText = tokens >= 1_000_000 ? String(format: "%.1fM tok", Double(tokens) / 1_000_000) : String(format: "%.1fK tok", Double(tokens) / 1_000) return cost > 0 ? String(format: "%@ · ~$%.2f", tokenText, cost) : tokenText } } // MARK: - Row private struct ConversationRow: View { let conversation: Conversation @EnvironmentObject private var store: ConversationStore @State private var hovering = false private var isSelected: Bool { store.selectedID == conversation.id } var body: some View { Button { store.selectedID = conversation.id } label: { HStack(spacing: ZyquoSpacing.xs) { VStack(alignment: .leading, spacing: 1) { Text(conversation.title) .font(ZyquoFont.body(size: 13)) .foregroundStyle(ZyquoColor.textPrimary) .lineLimit(1) HStack(spacing: ZyquoSpacing.xxs) { Text(conversation.modelID) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) .lineLimit(1) Text(conversation.updatedAt, format: .relative(presentation: .named)) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } } Spacer(minLength: 0) if hovering { rowActions } else if conversation.isPinned { Image(systemName: "pin.fill") .font(.system(size: 9)) .foregroundStyle(ZyquoColor.textTertiary) } } .padding(.horizontal, ZyquoSpacing.xs) .padding(.vertical, 5) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(isSelected ? ZyquoColor.accentSubtle : (hovering ? ZyquoColor.surfaceSecondary.opacity(0.6) : .clear)) ) .contentShape(Rectangle()) } .buttonStyle(.plain) .onHover { inside in withAnimation(ZyquoMotion.hover) { hovering = inside } } .contextMenu { Button(conversation.isPinned ? "Unpin" : "Pin") { store.togglePin(conversation.id) } Button("Add Tag…") { promptForTag() } if !conversation.tags.isEmpty { Menu("Remove Tag") { ForEach(conversation.tags, id: \.self) { tag in Button(tag) { guard var updated = store.conversations.first(where: { $0.id == conversation.id }) else { return } updated.tags.removeAll { $0 == tag } store.update(updated) } } } } Button("Delete", role: .destructive) { store.delete(conversation.id) } } } private func promptForTag() { let alert = NSAlert() alert.messageText = "Add Tag" alert.informativeText = "Tags group conversations in the sidebar." let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 220, height: 22)) field.placeholderString = "Tag name" alert.accessoryView = field alert.addButton(withTitle: "Add") alert.addButton(withTitle: "Cancel") guard alert.runModal() == .alertFirstButtonReturn else { return } let tag = field.stringValue.trimmingCharacters(in: .whitespaces) guard !tag.isEmpty, var updated = store.conversations.first(where: { $0.id == conversation.id }), !updated.tags.contains(tag) else { return } updated.tags.append(tag) store.update(updated) } private var rowActions: some View { HStack(spacing: ZyquoSpacing.xxs) { Button { store.togglePin(conversation.id) } label: { Image(systemName: conversation.isPinned ? "pin.slash" : "pin") .font(.system(size: 10)) .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) .help(conversation.isPinned ? "Unpin" : "Pin") Button { store.delete(conversation.id) } label: { Image(systemName: "trash") .font(.system(size: 10)) .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) .help("Delete") } } }