spb/zyquo-agent Public MIT
The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.
Swift 94.7%
Shell 4.1%
Python 0.7%
Makefile 0.5%
1//2// SidebarView.swift3// Zyquo Agent4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// Sidebar per the Phase 4 spec: "Zyquo Agent" wordmark, search, prominent9// New Task button, task rows grouped Pinned/Today/Yesterday/Previous 7 Days/10// Older — each with title, animated status pill, model badge, relative time,11// and a subtle activity indicator while running. Footer: settings gear +12// safety-mode chip + active model chip.13//1415import SwiftUI1617struct SidebarView: View {18 @EnvironmentObject private var store: TaskStore19 @EnvironmentObject private var hub: RunHub20 @EnvironmentObject private var catalog: ModelCatalog21 @EnvironmentObject private var settings: AgentSettingsStore22 @FocusState private var searchFocused: Bool2324 var body: some View {25 VStack(spacing: 0) {26 wordmark27 searchField28 newTaskButton29 taskList30 ZyquoHairline()31 footer32 }33 .frame(minWidth: ZyquoMetrics.sidebarWidth)34 .background(35 // ⌘F focuses task search.36 Button("") { searchFocused = true }37 .keyboardShortcut("f", modifiers: .command)38 .hidden()39 )40 }4142 // MARK: - Sections4344 private var wordmark: some View {45 HStack(spacing: ZyquoSpacing.xs) {46 AgentZGlyph(size: 22)47 Text("Zyquo Agent")48 .font(ZyquoFont.bodyEmphasis(size: 14))49 .foregroundStyle(ZyquoColor.textPrimary)50 Spacer()51 }52 .padding(.horizontal, ZyquoMetrics.contentInset)53 .padding(.top, ZyquoSpacing.sm)54 .padding(.bottom, ZyquoSpacing.xs)55 }5657 private var searchField: some View {58 HStack(spacing: ZyquoSpacing.xxs) {59 Image(systemName: "magnifyingglass")60 .font(.system(size: 11))61 .foregroundStyle(ZyquoColor.textTertiary)62 TextField("Search tasks", text: $store.searchText)63 .textFieldStyle(.plain)64 .font(ZyquoFont.body(size: 12.5))65 .focused($searchFocused)66 }67 .padding(.horizontal, ZyquoSpacing.xs)68 .padding(.vertical, 5)69 .background(70 RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)71 .fill(ZyquoColor.surfaceSecondary.opacity(0.7))72 )73 .padding(.horizontal, ZyquoSpacing.sm)74 .padding(.bottom, ZyquoSpacing.xs)75 }7677 private var newTaskButton: some View {78 Button {79 store.newTask(80 model: settings.defaultAgentModel(in: catalog),81 safetyMode: settings.settings.defaultSafetyMode82 )83 } label: {84 HStack(spacing: ZyquoSpacing.xxs) {85 Image(systemName: "plus.circle.fill")86 .font(.system(size: 12, weight: .semibold))87 Text("New Task")88 .font(ZyquoFont.bodyEmphasis(size: 13))89 }90 .foregroundStyle(.white)91 .frame(maxWidth: .infinity)92 .padding(.vertical, 7)93 .background(94 RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)95 .fill(ZyquoColor.accent)96 )97 }98 .buttonStyle(PressableButtonStyle())99 .help("New Task (⌘N)")100 .padding(.horizontal, ZyquoSpacing.sm)101 .padding(.bottom, ZyquoSpacing.xs)102 }103104 private var taskList: some View {105 ScrollView {106 LazyVStack(alignment: .leading, spacing: 2) {107 ForEach(store.sidebarGroups) { group in108 Text(group.title)109 .font(ZyquoFont.caption)110 .foregroundStyle(ZyquoColor.textTertiary)111 .padding(.horizontal, ZyquoMetrics.contentInset)112 .padding(.top, ZyquoSpacing.sm)113 .padding(.bottom, 2)114 ForEach(group.tasks) { task in115 TaskRow(task: task)116 }117 }118 }119 .padding(.horizontal, ZyquoSpacing.xs)120 .padding(.bottom, ZyquoSpacing.sm)121 }122 }123124 private var footer: some View {125 HStack(spacing: ZyquoSpacing.xs) {126 Button {127 SettingsOpener.open()128 } label: {129 Image(systemName: "gearshape")130 .font(.system(size: 13))131 .foregroundStyle(ZyquoColor.textSecondary)132 }133 .buttonStyle(.plain)134 .help("Settings (⌘,)")135 ZyquoBadge(text: footerSafetyMode.displayName, color: ZyquoColor.textSecondary)136 Spacer(minLength: 0)137 if let model = footerModel {138 ZyquoBadge(text: model.displayName, color: ZyquoColor.textSecondary)139 .help("Active model")140 }141 }142 .padding(.horizontal, ZyquoMetrics.contentInset)143 .padding(.vertical, ZyquoSpacing.xs)144 }145146 /// Safety mode of the selected task (or the app default).147 private var footerSafetyMode: SafetyMode {148 store.selectedID.flatMap { store.task(id: $0)?.safetyMode }149 ?? settings.settings.defaultSafetyMode150 }151152 /// Model of the selected task (or the default agent model).153 private var footerModel: AIModel? {154 if let id = store.selectedID, let task = store.task(id: id),155 let model = catalog.model(id: task.modelID, provider: task.providerID) {156 return model157 }158 return settings.defaultAgentModel(in: catalog)159 }160}161162// MARK: - Status pill163164/// Small colored capsule showing a task's lifecycle state; transitions animate.165struct StatusPill: View {166 let status: AgentTaskStatus167168 var body: some View {169 Text(status.displayName)170 .font(ZyquoFont.caption)171 .foregroundStyle(color)172 .padding(.horizontal, ZyquoSpacing.xxs + 2)173 .padding(.vertical, 1)174 .background(Capsule().fill(color.opacity(0.12)))175 .animation(ZyquoMotion.appear, value: status)176 .contentTransition(.opacity)177 }178179 private var color: Color {180 switch status {181 case .idle: return ZyquoColor.textTertiary182 case .planning, .running: return ZyquoColor.accent183 case .awaitingApproval, .awaitingInput: return ZyquoColor.warning184 case .done: return ZyquoColor.success185 case .failed: return ZyquoColor.danger186 }187 }188}189190/// Subtle pulsing dot shown while a task's run is live.191struct ActivityIndicatorDot: View {192 @State private var dimmed = false193194 var body: some View {195 Circle()196 .fill(ZyquoColor.accent)197 .frame(width: 6, height: 6)198 .opacity(dimmed ? 0.25 : 1)199 .onAppear {200 withAnimation(ZyquoMotion.pulse) { dimmed = true }201 }202 }203}204205// MARK: - Row206207private struct TaskRow: View {208 let task: AgentTask209 @EnvironmentObject private var store: TaskStore210 @EnvironmentObject private var hub: RunHub211 @State private var hovering = false212213 private var isSelected: Bool { store.selectedID == task.id }214215 var body: some View {216 Button {217 store.selectedID = task.id218 } label: {219 HStack(spacing: ZyquoSpacing.xs) {220 VStack(alignment: .leading, spacing: 2) {221 HStack(spacing: ZyquoSpacing.xxs) {222 if task.status.isActive {223 ActivityIndicatorDot()224 }225 Text(task.title)226 .font(ZyquoFont.body(size: 13))227 .foregroundStyle(ZyquoColor.textPrimary)228 .lineLimit(1)229 }230 HStack(spacing: ZyquoSpacing.xxs) {231 StatusPill(status: task.status)232 Text(task.modelID.isEmpty ? "no model" : shortModelName)233 .font(ZyquoFont.caption)234 .foregroundStyle(ZyquoColor.textTertiary)235 .lineLimit(1)236 Text(task.updatedAt, format: .relative(presentation: .named))237 .font(ZyquoFont.caption)238 .foregroundStyle(ZyquoColor.textTertiary)239 .lineLimit(1)240 }241 }242 Spacer(minLength: 0)243 if hovering {244 rowActions245 } else if task.pinned {246 Image(systemName: "pin.fill")247 .font(.system(size: 9))248 .foregroundStyle(ZyquoColor.textTertiary)249 }250 }251 .padding(.horizontal, ZyquoSpacing.xs)252 .padding(.vertical, 5)253 .background(254 RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)255 .fill(isSelected ? ZyquoColor.accentSubtle : (hovering ? ZyquoColor.surfaceSecondary.opacity(0.6) : .clear))256 )257 .contentShape(Rectangle())258 }259 .buttonStyle(.plain)260 .onHover { inside in261 withAnimation(ZyquoMotion.hover) { hovering = inside }262 }263 .contextMenu {264 Button(task.pinned ? "Unpin" : "Pin") { store.togglePin(task.id) }265 Button("Rename…") { promptForRename() }266 Button("Delete…", role: .destructive) { confirmDelete() }267 }268 }269270 /// Model badge text without the id's path prefix ("deepseek-ai/X" → "X").271 private var shortModelName: String {272 task.modelID.split(separator: "/").last.map(String.init) ?? task.modelID273 }274275 private var rowActions: some View {276 HStack(spacing: ZyquoSpacing.xxs) {277 Button {278 store.togglePin(task.id)279 } label: {280 Image(systemName: task.pinned ? "pin.slash" : "pin")281 .font(.system(size: 10))282 .foregroundStyle(ZyquoColor.textSecondary)283 }284 .buttonStyle(.plain)285 .help(task.pinned ? "Unpin" : "Pin")286 Button {287 confirmDelete()288 } label: {289 Image(systemName: "trash")290 .font(.system(size: 10))291 .foregroundStyle(ZyquoColor.textSecondary)292 }293 .buttonStyle(.plain)294 .help("Delete")295 }296 }297298 private func promptForRename() {299 let alert = NSAlert()300 alert.messageText = "Rename Task"301 let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 22))302 field.stringValue = task.title303 alert.accessoryView = field304 alert.addButton(withTitle: "Rename")305 alert.addButton(withTitle: "Cancel")306 guard alert.runModal() == .alertFirstButtonReturn else { return }307 store.rename(task.id, to: field.stringValue)308 }309310 /// Deleting is destructive: confirm, and ask separately about the311 /// workspace folder (which may contain the agent's produced files).312 private func confirmDelete() {313 let alert = NSAlert()314 alert.alertStyle = .warning315 alert.messageText = "Delete “\(task.title)”?"316 if task.workspacePath != nil {317 alert.informativeText = "This task has a workspace folder with the files the agent created. You can keep the folder or delete it too."318 alert.addButton(withTitle: "Delete Task & Workspace")319 alert.addButton(withTitle: "Delete Task Only")320 alert.addButton(withTitle: "Cancel")321 switch alert.runModal() {322 case .alertFirstButtonReturn:323 hub.remove(taskID: task.id)324 store.delete(task.id, deleteWorkspace: true)325 case .alertSecondButtonReturn:326 hub.remove(taskID: task.id)327 store.delete(task.id, deleteWorkspace: false)328 default:329 break330 }331 } else {332 alert.informativeText = "This cannot be undone."333 alert.addButton(withTitle: "Delete")334 alert.addButton(withTitle: "Cancel")335 if alert.runModal() == .alertFirstButtonReturn {336 hub.remove(taskID: task.id)337 store.delete(task.id, deleteWorkspace: false)338 }339 }340 }341}342