// // SidebarView.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI /// Translucent 260 pt sidebar: wordmark, Chats (search + groups), Library /// entry with live download badge, footer with settings + loaded-model chip. struct SidebarView: View { @Environment(AppModel.self) private var app @Environment(\.openSettings) private var openSettings @State private var query = "" @State private var renamingID: UUID? @State private var renameText = "" @FocusState private var searchFocused: Bool var body: some View { VStack(spacing: 0) { wordmark .padding(.horizontal, ZyquoTheme.Spacing.m) .padding(.top, ZyquoTheme.Spacing.s) searchField .padding(.horizontal, ZyquoTheme.Spacing.s) .padding(.top, ZyquoTheme.Spacing.s) List(selection: selectionBinding) { Section { newChatButton libraryRow } ForEach(app.sidebarGroups(query: query)) { group in Section(group.title) { ForEach(group.conversations) { conversation in conversationRow(conversation) .tag(conversation.id) } } } } .scrollContentBackground(.hidden) .listStyle(.sidebar) footer } .background(.regularMaterial) .onReceive(NotificationCenter.default.publisher(for: .zyquoFocusSearch)) { _ in searchFocused = true } } private var selectionBinding: Binding { Binding( get: { app.route == .chat ? app.selectedConversationID : nil }, set: { id in if let id { app.selectedConversationID = id app.route = .chat } } ) } private var wordmark: some View { HStack(spacing: ZyquoTheme.Spacing.xs) { ZyquoGlyph(size: 18) Text("Zyquo Local") .font(ZyquoTheme.bodyEmphasis) .foregroundStyle(ZyquoTheme.textPrimary) Spacer() } } private var searchField: some View { HStack(spacing: ZyquoTheme.Spacing.xxs) { Image(systemName: "magnifyingglass") .font(.system(size: 11)) .foregroundStyle(ZyquoTheme.textTertiary) TextField("Search chats", text: $query) .textFieldStyle(.plain) .font(ZyquoTheme.body) .focused($searchFocused) } .padding(.horizontal, ZyquoTheme.Spacing.xs) .padding(.vertical, 5) .background(ZyquoTheme.surfaceSecondary.opacity(0.6), in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) .overlay( RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s) .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) ) } private var newChatButton: some View { Button { app.newConversation() } label: { Label("New Chat", systemImage: "square.and.pencil") .font(ZyquoTheme.bodyEmphasis) .foregroundStyle(ZyquoTheme.accent) } .buttonStyle(.plain) .keyboardShortcut("n", modifiers: .command) } private var libraryRow: some View { Button { app.route = .library } label: { HStack { Label("Library", systemImage: "square.stack.3d.up") .font(ZyquoTheme.body) Spacer() if app.downloads.activeCount > 0 { HStack(spacing: ZyquoTheme.Spacing.xxs) { ProgressView(value: app.downloads.overallFraction) .progressViewStyle(.circular) .controlSize(.mini) Text("\(app.downloads.activeCount)") .font(ZyquoTheme.caption) .foregroundStyle(.white) .padding(.horizontal, 6) .padding(.vertical, 1) .background(ZyquoTheme.accent, in: Capsule()) } } } .contentShape(Rectangle()) } .buttonStyle(.plain) .keyboardShortcut("l", modifiers: .command) } @ViewBuilder private func conversationRow(_ conversation: Conversation) -> some View { HStack(spacing: ZyquoTheme.Spacing.xs) { VStack(alignment: .leading, spacing: 2) { if renamingID == conversation.id { TextField( "Title", text: $renameText, onCommit: { var c = conversation c.title = renameText.isEmpty ? conversation.title : renameText app.update(c, touch: false) renamingID = nil } ) .textFieldStyle(.plain) .font(ZyquoTheme.body) } else { Text(conversation.title) .font(ZyquoTheme.body) .lineLimit(1) } HStack(spacing: ZyquoTheme.Spacing.xxs) { if let modelID = conversation.modelID { Text(shortModelName(modelID)) .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) .lineLimit(1) } Text(conversation.updatedAt, format: .relative(presentation: .named)) .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) } } Spacer(minLength: 0) if conversation.pinned { Image(systemName: "pin.fill") .font(.system(size: 9)) .foregroundStyle(ZyquoTheme.textTertiary) } } .contextMenu { Button(conversation.pinned ? "Unpin" : "Pin") { app.togglePin(conversationID: conversation.id) } Button("Rename") { renameText = conversation.title renamingID = conversation.id } Divider() Button("Export as Markdown…") { ExportService.exportMarkdown(conversation) } Button("Export as PDF…") { ExportService.exportPDF(conversation) } Divider() Button("Delete", role: .destructive) { app.delete(conversationID: conversation.id) } } } private var footer: some View { VStack(spacing: 0) { Rectangle() .fill(ZyquoTheme.border) .frame(height: ZyquoTheme.hairline) HStack(spacing: ZyquoTheme.Spacing.xs) { Button { openSettings() } label: { Image(systemName: "gearshape") .foregroundStyle(ZyquoTheme.textSecondary) } .buttonStyle(.plain) .help("Settings (⌘,)") Spacer() if let repoID = app.loadedModelID { HStack(spacing: ZyquoTheme.Spacing.xxs) { Circle() .fill(memoryDotColor) .frame(width: 7, height: 7) Text(shortModelName(repoID)) .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) .lineLimit(1) if app.liveMemoryBytes > 0 { Text(formatBytes(Int64(app.liveMemoryBytes))) .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) } } .padding(.horizontal, ZyquoTheme.Spacing.xs) .padding(.vertical, 3) .background(ZyquoTheme.surfaceSecondary.opacity(0.7), in: Capsule()) .help("Loaded model · active memory") } else { Text("No model loaded") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) } } .padding(.horizontal, ZyquoTheme.Spacing.m) .padding(.vertical, ZyquoTheme.Spacing.s) } } private var memoryDotColor: Color { let ram = Double(MemoryAdvisor.physicalMemoryBytes) let used = Double(app.liveMemoryBytes) if used < ram * 0.5 { return ZyquoTheme.success } if used < ram * 0.7 { return ZyquoTheme.warning } return ZyquoTheme.danger } } /// "Qwen3-8B-4bit" from "mlx-community/Qwen3-8B-4bit". func shortModelName(_ repoID: String) -> String { repoID.split(separator: "/").last.map(String.init) ?? repoID } extension Notification.Name { static let zyquoFocusSearch = Notification.Name("zyquoFocusSearch") static let zyquoOpenModelSwitcher = Notification.Name("zyquoOpenModelSwitcher") static let zyquoExportConversation = Notification.Name("zyquoExportConversation") }