SPB Git

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%
12.2 KB · 342 lines swift
Raw Blame History
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