// // TaskDetailView.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The command-center detail for one task: the 52pt header (editable title, // centered model chip, safety-mode segmented control, workspace chip, // export, info popover), the conversation column + collapsible plan panel, // the collapsible Activity/Terminal drawer, and the floating input bar. // import SwiftUI import UniformTypeIdentifiers /// Thin wrapper resolving the task's RunController from the RunHub. struct TaskDetailView: View { let taskID: AgentTask.ID @EnvironmentObject private var hub: RunHub var body: some View { TaskDetailContent(controller: hub.controller(for: taskID)) .id(taskID) } } struct TaskDetailContent: View { @ObservedObject var controller: RunController @EnvironmentObject private var store: TaskStore @EnvironmentObject private var catalog: ModelCatalog @EnvironmentObject private var vault: KeyVaultStore @EnvironmentObject private var appearance: AppearanceStore @EnvironmentObject private var personas: PersonaStore @EnvironmentObject private var uiState: AppUIState @State private var draft = "" @State private var editingTitle = false @State private var titleDraft = "" @State private var showingInfo = false @State private var planVisible = true @State private var drawerVisible = false private var task: AgentTask? { store.task(id: controller.taskID) } private var currentModel: AIModel? { guard let task else { return nil } return catalog.model(id: task.modelID, provider: task.providerID) ?? catalog.defaultAgentModel } var body: some View { VStack(spacing: 0) { header ZyquoHairline() HStack(spacing: 0) { VStack(spacing: 0) { if let task, task.messages.isEmpty, controller.entries.isEmpty { emptyState(task) } else if let task { ConversationView(task: task, controller: controller) } if noKeyForModel { noKeyBanner } InputBarView( text: $draft, isRunning: controller.isRunning, disabledReason: inputDisabledReason, onRun: run, onStop: { controller.cancel() } ) } .frame(maxWidth: .infinity) if planVisible { Rectangle() .fill(ZyquoColor.border) .frame(width: ZyquoMetrics.hairline) PlanPanelView(controller: controller) .transition(.move(edge: .trailing).combined(with: .opacity)) } } if drawerVisible { ZyquoHairline() TerminalDrawerView(controller: controller) .transition(.move(edge: .bottom).combined(with: .opacity)) } } .background(ZyquoColor.background) .background( // ⌘. stops the current run. Button("") { controller.cancel() } .keyboardShortcut(".", modifiers: .command) .hidden() ) .onAppear { if let pending = store.pendingDraft { draft = pending store.pendingDraft = nil } } .onChange(of: controller.isRunning) { running in if running { drawerVisible = true } } // App-level command signals (⌘⇧A, palette actions). .onChange(of: uiState.auditLogRequest) { _ in withAnimation(ZyquoMotion.appear) { drawerVisible = true } } .onChange(of: uiState.drawerToggleRequest) { _ in withAnimation(ZyquoMotion.appear) { drawerVisible.toggle() } } .onChange(of: uiState.planToggleRequest) { _ in withAnimation(ZyquoMotion.appear) { planVisible.toggle() } } } // MARK: - Header (52pt) private var header: some View { ZStack { ModelChipView(model: currentModel, onSelect: select(model:)) HStack(spacing: ZyquoSpacing.xs) { titleView Spacer() headerControls } } .padding(.horizontal, ZyquoMetrics.contentInset) .frame(height: ZyquoMetrics.headerHeight) } @ViewBuilder private var titleView: some View { if editingTitle { TextField("Title", text: $titleDraft, onCommit: { store.rename(controller.taskID, to: titleDraft) editingTitle = false }) .textFieldStyle(.plain) .font(ZyquoFont.bodyEmphasis(size: 13)) .frame(maxWidth: 220) } else { Text(task?.title ?? "") .font(ZyquoFont.bodyEmphasis(size: 13)) .foregroundStyle(ZyquoColor.textPrimary) .lineLimit(1) .frame(maxWidth: 220, alignment: .leading) .onTapGesture(count: 2) { titleDraft = task?.title ?? "" editingTitle = true } .help("Double-click to rename") } } private var headerControls: some View { HStack(spacing: ZyquoSpacing.sm) { SafetyModePicker(mode: task?.safetyMode ?? .guarded) { mode in controller.setSafetyMode(mode) } if let root = controller.workspaceRoot { workspaceChip(root) } HStack(spacing: ZyquoSpacing.xs) { toggleButton( symbol: "sidebar.right", active: planVisible, help: "Toggle plan panel" ) { withAnimation(ZyquoMotion.appear) { planVisible.toggle() } } toggleButton( symbol: "terminal", active: drawerVisible, help: "Toggle activity drawer" ) { withAnimation(ZyquoMotion.appear) { drawerVisible.toggle() } } Menu { Button("Export as Markdown…") { if let task { TaskTranscriptExporter.presentSavePanel(for: task, format: .markdown) } } Button("Export as PDF…") { if let task { TaskTranscriptExporter.presentSavePanel(for: task, format: .pdf) } } } label: { Image(systemName: "square.and.arrow.up") .font(.system(size: 12)) .foregroundStyle(ZyquoColor.textSecondary) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .fixedSize() .help("Export task transcript (Markdown / PDF)") Button { showingInfo.toggle() } label: { Image(systemName: "info.circle") .font(.system(size: 12)) .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) .help("Task info") .popover(isPresented: $showingInfo, arrowEdge: .bottom) { infoPopover } } } } private func toggleButton(symbol: String, active: Bool, help: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: symbol) .font(.system(size: 12)) .foregroundStyle(active ? ZyquoColor.accent : ZyquoColor.textSecondary) } .buttonStyle(.plain) .help(help) } private func workspaceChip(_ root: URL) -> some View { Button { NSWorkspace.shared.activateFileViewerSelecting([root]) } label: { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: "folder") .font(.system(size: 10)) Text(root.lastPathComponent) .font(ZyquoFont.caption) .lineLimit(1) } .foregroundStyle(ZyquoColor.textSecondary) .padding(.horizontal, ZyquoSpacing.xs) .padding(.vertical, 3) .background(Capsule().fill(ZyquoColor.surfaceSecondary)) } .buttonStyle(.plain) .help("Reveal workspace in Finder\n\(root.path)") } // MARK: - Info popover private var infoPopover: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { Text("Task") .font(ZyquoFont.bodyEmphasis()) let config = controller.loopGuardConfiguration LabeledContent("Budgets") { Text("\(config.maxSteps) steps · \(config.tokenBudget / 1_000)K tokens · \(Int(config.wallClockBudget / 60)) min") } LabeledContent("Used this run") { Text("\(controller.stepsUsed) steps · \(controller.tokensUsed) tokens") } if let workspace = controller.workspaceRoot { LabeledContent("Workspace") { Text(workspace.path) .lineLimit(2) .truncationMode(.middle) } } Divider() Text("System prompt") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) ScrollView { Text(controller.systemPromptPreview ?? "The full system prompt is assembled when a run starts (role, tools, workspace, safety expectations, done-signal).") .font(ZyquoFont.code(size: 10.5)) .foregroundStyle(ZyquoColor.textSecondary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(ZyquoSpacing.xs) } .frame(width: 340, height: 160) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surfaceSecondary) ) } .font(ZyquoFont.body(size: 12.5)) .padding(ZyquoSpacing.md) .frame(width: 380) } // MARK: - Empty & no-key states private func emptyState(_ task: AgentTask) -> some View { AgentEmptyStateView( model: currentModel, safetyMode: task.safetyMode, personaName: personas.persona(id: task.personaID)?.name, onSelectModel: select(model:), onSelectSafetyMode: { controller.setSafetyMode($0) }, onSelectPersona: { persona in select(persona: persona) }, onBrowseTemplates: { uiState.showTemplateBrowser = true }, onSuggestion: { draft = $0 } ) } /// Adopts a persona for this task: stores the id and applies its /// preferred model / safety default when set. private func select(persona: Persona?) { guard var task else { return } task.personaID = persona?.id if let persona, let id = persona.modelID, let provider = persona.provider, let preferred = catalog.model(id: id, provider: provider) { task.modelID = preferred.id task.providerID = preferred.provider } store.update(task, touch: false) if let mode = persona?.safetyMode { controller.setSafetyMode(mode) } } private var noKeyForModel: Bool { guard let model = currentModel else { return false } return AgentCLI.resolveAPIKey(for: model.provider) == nil } private var noKeyBanner: some View { HStack(spacing: ZyquoSpacing.xs) { Image(systemName: "key") .font(.system(size: 11)) Text("No API key for \(currentModel?.provider.displayName ?? "this provider") yet.") .font(ZyquoFont.body(size: 12.5)) Button("Providers & Keys…") { SettingsOpener.open() } .font(ZyquoFont.body(size: 12.5)) Spacer(minLength: 0) } .foregroundStyle(ZyquoColor.warning) .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, ZyquoSpacing.xs) .background( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .fill(ZyquoColor.warning.opacity(0.08)) ) .padding(.horizontal, ZyquoSpacing.sm) } private var inputDisabledReason: String? { guard currentModel != nil else { return "Choose a model to run agent tasks." } if noKeyForModel { return "Add an API key for \(currentModel?.provider.displayName ?? "the provider") to run." } if let model = currentModel, !model.agentCapable { return "\(model.displayName) has limited tool use — agent runs may be unreliable." } return nil } // MARK: - Actions private func run() { guard let model = currentModel else { return } let prompt = draft withAnimation(ZyquoMotion.appear) { draft = "" } controller.start( prompt: prompt, model: model, persona: personas.persona(id: task?.personaID) ) } private func select(model: AIModel) { guard var task else { return } task.modelID = model.id task.providerID = model.provider store.update(task, touch: false) } }