// // SidebarView.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Sidebar per the Phase 4 spec: "Zyquo Agent" wordmark, search, prominent // New Task button, task rows grouped Pinned/Today/Yesterday/Previous 7 Days/ // Older — each with title, animated status pill, model badge, relative time, // and a subtle activity indicator while running. Footer: settings gear + // safety-mode chip + active model chip. // import SwiftUI struct SidebarView: View { @EnvironmentObject private var store: TaskStore @EnvironmentObject private var hub: RunHub @EnvironmentObject private var catalog: ModelCatalog @EnvironmentObject private var settings: AgentSettingsStore @FocusState private var searchFocused: Bool var body: some View { VStack(spacing: 0) { wordmark searchField newTaskButton taskList ZyquoHairline() footer } .frame(minWidth: ZyquoMetrics.sidebarWidth) .background( // ⌘F focuses task search. Button("") { searchFocused = true } .keyboardShortcut("f", modifiers: .command) .hidden() ) } // MARK: - Sections private var wordmark: some View { HStack(spacing: ZyquoSpacing.xs) { AgentZGlyph(size: 22) Text("Zyquo Agent") .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 tasks", 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 newTaskButton: some View { Button { store.newTask( model: settings.defaultAgentModel(in: catalog), safetyMode: settings.settings.defaultSafetyMode ) } label: { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: "plus.circle.fill") .font(.system(size: 12, weight: .semibold)) Text("New Task") .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()) .help("New Task (⌘N)") .padding(.horizontal, ZyquoSpacing.sm) .padding(.bottom, ZyquoSpacing.xs) } private var taskList: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 2) { ForEach(store.sidebarGroups) { group in Text(group.title) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) .padding(.horizontal, ZyquoMetrics.contentInset) .padding(.top, ZyquoSpacing.sm) .padding(.bottom, 2) ForEach(group.tasks) { task in TaskRow(task: task) } } } .padding(.horizontal, ZyquoSpacing.xs) .padding(.bottom, ZyquoSpacing.sm) } } private var footer: some View { HStack(spacing: ZyquoSpacing.xs) { Button { SettingsOpener.open() } label: { Image(systemName: "gearshape") .font(.system(size: 13)) .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) .help("Settings (⌘,)") ZyquoBadge(text: footerSafetyMode.displayName, color: ZyquoColor.textSecondary) Spacer(minLength: 0) if let model = footerModel { ZyquoBadge(text: model.displayName, color: ZyquoColor.textSecondary) .help("Active model") } } .padding(.horizontal, ZyquoMetrics.contentInset) .padding(.vertical, ZyquoSpacing.xs) } /// Safety mode of the selected task (or the app default). private var footerSafetyMode: SafetyMode { store.selectedID.flatMap { store.task(id: $0)?.safetyMode } ?? settings.settings.defaultSafetyMode } /// Model of the selected task (or the default agent model). private var footerModel: AIModel? { if let id = store.selectedID, let task = store.task(id: id), let model = catalog.model(id: task.modelID, provider: task.providerID) { return model } return settings.defaultAgentModel(in: catalog) } } // MARK: - Status pill /// Small colored capsule showing a task's lifecycle state; transitions animate. struct StatusPill: View { let status: AgentTaskStatus var body: some View { Text(status.displayName) .font(ZyquoFont.caption) .foregroundStyle(color) .padding(.horizontal, ZyquoSpacing.xxs + 2) .padding(.vertical, 1) .background(Capsule().fill(color.opacity(0.12))) .animation(ZyquoMotion.appear, value: status) .contentTransition(.opacity) } private var color: Color { switch status { case .idle: return ZyquoColor.textTertiary case .planning, .running: return ZyquoColor.accent case .awaitingApproval, .awaitingInput: return ZyquoColor.warning case .done: return ZyquoColor.success case .failed: return ZyquoColor.danger } } } /// Subtle pulsing dot shown while a task's run is live. struct ActivityIndicatorDot: View { @State private var dimmed = false var body: some View { Circle() .fill(ZyquoColor.accent) .frame(width: 6, height: 6) .opacity(dimmed ? 0.25 : 1) .onAppear { withAnimation(ZyquoMotion.pulse) { dimmed = true } } } } // MARK: - Row private struct TaskRow: View { let task: AgentTask @EnvironmentObject private var store: TaskStore @EnvironmentObject private var hub: RunHub @State private var hovering = false private var isSelected: Bool { store.selectedID == task.id } var body: some View { Button { store.selectedID = task.id } label: { HStack(spacing: ZyquoSpacing.xs) { VStack(alignment: .leading, spacing: 2) { HStack(spacing: ZyquoSpacing.xxs) { if task.status.isActive { ActivityIndicatorDot() } Text(task.title) .font(ZyquoFont.body(size: 13)) .foregroundStyle(ZyquoColor.textPrimary) .lineLimit(1) } HStack(spacing: ZyquoSpacing.xxs) { StatusPill(status: task.status) Text(task.modelID.isEmpty ? "no model" : shortModelName) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) .lineLimit(1) Text(task.updatedAt, format: .relative(presentation: .named)) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) .lineLimit(1) } } Spacer(minLength: 0) if hovering { rowActions } else if task.pinned { 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(task.pinned ? "Unpin" : "Pin") { store.togglePin(task.id) } Button("Rename…") { promptForRename() } Button("Delete…", role: .destructive) { confirmDelete() } } } /// Model badge text without the id's path prefix ("deepseek-ai/X" → "X"). private var shortModelName: String { task.modelID.split(separator: "/").last.map(String.init) ?? task.modelID } private var rowActions: some View { HStack(spacing: ZyquoSpacing.xxs) { Button { store.togglePin(task.id) } label: { Image(systemName: task.pinned ? "pin.slash" : "pin") .font(.system(size: 10)) .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) .help(task.pinned ? "Unpin" : "Pin") Button { confirmDelete() } label: { Image(systemName: "trash") .font(.system(size: 10)) .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) .help("Delete") } } private func promptForRename() { let alert = NSAlert() alert.messageText = "Rename Task" let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 22)) field.stringValue = task.title alert.accessoryView = field alert.addButton(withTitle: "Rename") alert.addButton(withTitle: "Cancel") guard alert.runModal() == .alertFirstButtonReturn else { return } store.rename(task.id, to: field.stringValue) } /// Deleting is destructive: confirm, and ask separately about the /// workspace folder (which may contain the agent's produced files). private func confirmDelete() { let alert = NSAlert() alert.alertStyle = .warning alert.messageText = "Delete “\(task.title)”?" if task.workspacePath != nil { alert.informativeText = "This task has a workspace folder with the files the agent created. You can keep the folder or delete it too." alert.addButton(withTitle: "Delete Task & Workspace") alert.addButton(withTitle: "Delete Task Only") alert.addButton(withTitle: "Cancel") switch alert.runModal() { case .alertFirstButtonReturn: hub.remove(taskID: task.id) store.delete(task.id, deleteWorkspace: true) case .alertSecondButtonReturn: hub.remove(taskID: task.id) store.delete(task.id, deleteWorkspace: false) default: break } } else { alert.informativeText = "This cannot be undone." alert.addButton(withTitle: "Delete") alert.addButton(withTitle: "Cancel") if alert.runModal() == .alertFirstButtonReturn { hub.remove(taskID: task.id) store.delete(task.id, deleteWorkspace: false) } } } }