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%
13.6 KB · 370 lines swift
Raw Blame History
1//2//  TaskDetailView.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The command-center detail for one task: the 52pt header (editable title,9//  centered model chip, safety-mode segmented control, workspace chip,10//  export, info popover), the conversation column + collapsible plan panel,11//  the collapsible Activity/Terminal drawer, and the floating input bar.12//1314import SwiftUI15import UniformTypeIdentifiers1617/// Thin wrapper resolving the task's RunController from the RunHub.18struct TaskDetailView: View {19    let taskID: AgentTask.ID20    @EnvironmentObject private var hub: RunHub2122    var body: some View {23        TaskDetailContent(controller: hub.controller(for: taskID))24            .id(taskID)25    }26}2728struct TaskDetailContent: View {29    @ObservedObject var controller: RunController3031    @EnvironmentObject private var store: TaskStore32    @EnvironmentObject private var catalog: ModelCatalog33    @EnvironmentObject private var vault: KeyVaultStore34    @EnvironmentObject private var appearance: AppearanceStore35    @EnvironmentObject private var personas: PersonaStore36    @EnvironmentObject private var uiState: AppUIState3738    @State private var draft = ""39    @State private var editingTitle = false40    @State private var titleDraft = ""41    @State private var showingInfo = false42    @State private var planVisible = true43    @State private var drawerVisible = false4445    private var task: AgentTask? { store.task(id: controller.taskID) }4647    private var currentModel: AIModel? {48        guard let task else { return nil }49        return catalog.model(id: task.modelID, provider: task.providerID) ?? catalog.defaultAgentModel50    }5152    var body: some View {53        VStack(spacing: 0) {54            header55            ZyquoHairline()56            HStack(spacing: 0) {57                VStack(spacing: 0) {58                    if let task, task.messages.isEmpty, controller.entries.isEmpty {59                        emptyState(task)60                    } else if let task {61                        ConversationView(task: task, controller: controller)62                    }63                    if noKeyForModel {64                        noKeyBanner65                    }66                    InputBarView(67                        text: $draft,68                        isRunning: controller.isRunning,69                        disabledReason: inputDisabledReason,70                        onRun: run,71                        onStop: { controller.cancel() }72                    )73                }74                .frame(maxWidth: .infinity)75                if planVisible {76                    Rectangle()77                        .fill(ZyquoColor.border)78                        .frame(width: ZyquoMetrics.hairline)79                    PlanPanelView(controller: controller)80                        .transition(.move(edge: .trailing).combined(with: .opacity))81                }82            }83            if drawerVisible {84                ZyquoHairline()85                TerminalDrawerView(controller: controller)86                    .transition(.move(edge: .bottom).combined(with: .opacity))87            }88        }89        .background(ZyquoColor.background)90        .background(91            // ⌘. stops the current run.92            Button("") { controller.cancel() }93                .keyboardShortcut(".", modifiers: .command)94                .hidden()95        )96        .onAppear {97            if let pending = store.pendingDraft {98                draft = pending99                store.pendingDraft = nil100            }101        }102        .onChange(of: controller.isRunning) { running in103            if running { drawerVisible = true }104        }105        // App-level command signals (⌘⇧A, palette actions).106        .onChange(of: uiState.auditLogRequest) { _ in107            withAnimation(ZyquoMotion.appear) { drawerVisible = true }108        }109        .onChange(of: uiState.drawerToggleRequest) { _ in110            withAnimation(ZyquoMotion.appear) { drawerVisible.toggle() }111        }112        .onChange(of: uiState.planToggleRequest) { _ in113            withAnimation(ZyquoMotion.appear) { planVisible.toggle() }114        }115    }116117    // MARK: - Header (52pt)118119    private var header: some View {120        ZStack {121            ModelChipView(model: currentModel, onSelect: select(model:))122            HStack(spacing: ZyquoSpacing.xs) {123                titleView124                Spacer()125                headerControls126            }127        }128        .padding(.horizontal, ZyquoMetrics.contentInset)129        .frame(height: ZyquoMetrics.headerHeight)130    }131132    @ViewBuilder133    private var titleView: some View {134        if editingTitle {135            TextField("Title", text: $titleDraft, onCommit: {136                store.rename(controller.taskID, to: titleDraft)137                editingTitle = false138            })139            .textFieldStyle(.plain)140            .font(ZyquoFont.bodyEmphasis(size: 13))141            .frame(maxWidth: 220)142        } else {143            Text(task?.title ?? "")144                .font(ZyquoFont.bodyEmphasis(size: 13))145                .foregroundStyle(ZyquoColor.textPrimary)146                .lineLimit(1)147                .frame(maxWidth: 220, alignment: .leading)148                .onTapGesture(count: 2) {149                    titleDraft = task?.title ?? ""150                    editingTitle = true151                }152                .help("Double-click to rename")153        }154    }155156    private var headerControls: some View {157        HStack(spacing: ZyquoSpacing.sm) {158            SafetyModePicker(mode: task?.safetyMode ?? .guarded) { mode in159                controller.setSafetyMode(mode)160            }161            if let root = controller.workspaceRoot {162                workspaceChip(root)163            }164            HStack(spacing: ZyquoSpacing.xs) {165                toggleButton(166                    symbol: "sidebar.right",167                    active: planVisible,168                    help: "Toggle plan panel"169                ) { withAnimation(ZyquoMotion.appear) { planVisible.toggle() } }170                toggleButton(171                    symbol: "terminal",172                    active: drawerVisible,173                    help: "Toggle activity drawer"174                ) { withAnimation(ZyquoMotion.appear) { drawerVisible.toggle() } }175                Menu {176                    Button("Export as Markdown…") {177                        if let task { TaskTranscriptExporter.presentSavePanel(for: task, format: .markdown) }178                    }179                    Button("Export as PDF…") {180                        if let task { TaskTranscriptExporter.presentSavePanel(for: task, format: .pdf) }181                    }182                } label: {183                    Image(systemName: "square.and.arrow.up")184                        .font(.system(size: 12))185                        .foregroundStyle(ZyquoColor.textSecondary)186                }187                .menuStyle(.borderlessButton)188                .menuIndicator(.hidden)189                .fixedSize()190                .help("Export task transcript (Markdown / PDF)")191                Button {192                    showingInfo.toggle()193                } label: {194                    Image(systemName: "info.circle")195                        .font(.system(size: 12))196                        .foregroundStyle(ZyquoColor.textSecondary)197                }198                .buttonStyle(.plain)199                .help("Task info")200                .popover(isPresented: $showingInfo, arrowEdge: .bottom) {201                    infoPopover202                }203            }204        }205    }206207    private func toggleButton(symbol: String, active: Bool, help: String, action: @escaping () -> Void) -> some View {208        Button(action: action) {209            Image(systemName: symbol)210                .font(.system(size: 12))211                .foregroundStyle(active ? ZyquoColor.accent : ZyquoColor.textSecondary)212        }213        .buttonStyle(.plain)214        .help(help)215    }216217    private func workspaceChip(_ root: URL) -> some View {218        Button {219            NSWorkspace.shared.activateFileViewerSelecting([root])220        } label: {221            HStack(spacing: ZyquoSpacing.xxs) {222                Image(systemName: "folder")223                    .font(.system(size: 10))224                Text(root.lastPathComponent)225                    .font(ZyquoFont.caption)226                    .lineLimit(1)227            }228            .foregroundStyle(ZyquoColor.textSecondary)229            .padding(.horizontal, ZyquoSpacing.xs)230            .padding(.vertical, 3)231            .background(Capsule().fill(ZyquoColor.surfaceSecondary))232        }233        .buttonStyle(.plain)234        .help("Reveal workspace in Finder\n\(root.path)")235    }236237    // MARK: - Info popover238239    private var infoPopover: some View {240        VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {241            Text("Task")242                .font(ZyquoFont.bodyEmphasis())243            let config = controller.loopGuardConfiguration244            LabeledContent("Budgets") {245                Text("\(config.maxSteps) steps · \(config.tokenBudget / 1_000)K tokens · \(Int(config.wallClockBudget / 60)) min")246            }247            LabeledContent("Used this run") {248                Text("\(controller.stepsUsed) steps · \(controller.tokensUsed) tokens")249            }250            if let workspace = controller.workspaceRoot {251                LabeledContent("Workspace") {252                    Text(workspace.path)253                        .lineLimit(2)254                        .truncationMode(.middle)255                }256            }257            Divider()258            Text("System prompt")259                .font(ZyquoFont.caption)260                .foregroundStyle(ZyquoColor.textSecondary)261            ScrollView {262                Text(controller.systemPromptPreview ?? "The full system prompt is assembled when a run starts (role, tools, workspace, safety expectations, done-signal).")263                    .font(ZyquoFont.code(size: 10.5))264                    .foregroundStyle(ZyquoColor.textSecondary)265                    .textSelection(.enabled)266                    .frame(maxWidth: .infinity, alignment: .leading)267                    .padding(ZyquoSpacing.xs)268            }269            .frame(width: 340, height: 160)270            .background(271                RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)272                    .fill(ZyquoColor.surfaceSecondary)273            )274        }275        .font(ZyquoFont.body(size: 12.5))276        .padding(ZyquoSpacing.md)277        .frame(width: 380)278    }279280    // MARK: - Empty & no-key states281282    private func emptyState(_ task: AgentTask) -> some View {283        AgentEmptyStateView(284            model: currentModel,285            safetyMode: task.safetyMode,286            personaName: personas.persona(id: task.personaID)?.name,287            onSelectModel: select(model:),288            onSelectSafetyMode: { controller.setSafetyMode($0) },289            onSelectPersona: { persona in select(persona: persona) },290            onBrowseTemplates: { uiState.showTemplateBrowser = true },291            onSuggestion: { draft = $0 }292        )293    }294295    /// Adopts a persona for this task: stores the id and applies its296    /// preferred model / safety default when set.297    private func select(persona: Persona?) {298        guard var task else { return }299        task.personaID = persona?.id300        if let persona, let id = persona.modelID, let provider = persona.provider,301           let preferred = catalog.model(id: id, provider: provider) {302            task.modelID = preferred.id303            task.providerID = preferred.provider304        }305        store.update(task, touch: false)306        if let mode = persona?.safetyMode {307            controller.setSafetyMode(mode)308        }309    }310311    private var noKeyForModel: Bool {312        guard let model = currentModel else { return false }313        return AgentCLI.resolveAPIKey(for: model.provider) == nil314    }315316    private var noKeyBanner: some View {317        HStack(spacing: ZyquoSpacing.xs) {318            Image(systemName: "key")319                .font(.system(size: 11))320            Text("No API key for \(currentModel?.provider.displayName ?? "this provider") yet.")321                .font(ZyquoFont.body(size: 12.5))322            Button("Providers & Keys…") {323                SettingsOpener.open()324            }325            .font(ZyquoFont.body(size: 12.5))326            Spacer(minLength: 0)327        }328        .foregroundStyle(ZyquoColor.warning)329        .padding(.horizontal, ZyquoSpacing.sm)330        .padding(.vertical, ZyquoSpacing.xs)331        .background(332            RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)333                .fill(ZyquoColor.warning.opacity(0.08))334        )335        .padding(.horizontal, ZyquoSpacing.sm)336    }337338    private var inputDisabledReason: String? {339        guard currentModel != nil else { return "Choose a model to run agent tasks." }340        if noKeyForModel {341            return "Add an API key for \(currentModel?.provider.displayName ?? "the provider") to run."342        }343        if let model = currentModel, !model.agentCapable {344            return "\(model.displayName) has limited tool use — agent runs may be unreliable."345        }346        return nil347    }348349    // MARK: - Actions350351    private func run() {352        guard let model = currentModel else { return }353        let prompt = draft354        withAnimation(ZyquoMotion.appear) { draft = "" }355        controller.start(356            prompt: prompt,357            model: model,358            persona: personas.persona(id: task?.personaID)359        )360    }361362    private func select(model: AIModel) {363        guard var task else { return }364        task.modelID = model.id365        task.providerID = model.provider366        store.update(task, touch: false)367    }368369}370