diff --git a/.gitignore b/.gitignore index 3f6f514..824ef47 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,9 @@ apps/cli/dist/ # Board server packages/board-server/dist/ +# Agent server +packages/agent-server/dist/ + # Auto Claude data directory .auto-claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index afb4d6b..732c3d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ - **Desktop: Comparator v4** — Rebuilt from terminal multiplexer to structured evaluation workbench with 4-phase workflow (Setup → Execution → Results → Judge), session history rail, SQLite persistence, and shared Comparator types - **Marketplace: Ratings and reviews** — install counts, trust tiers, and user ratings on plugin detail pages. - **CLI: `detect` and `init` commands** — `harness-kit detect` shows which AI coding platforms are active in the current directory; `harness-kit init` scaffolds a new `harness.yaml` interactively. +- **Desktop: Board agent execution** — per-card autonomous coding runs via a new `packages/agent-server` LangGraph StateGraph (spec → planning → coding → qa_review → qa_fixing). Live phase/progress streaming over WebSocket, structured log view with color-coded tool blocks, phase-grouped subtask list, steering input, handoff/pause/stop controls. SQLite checkpointing at `~/.harness/board/agent-checkpoints.sqlite` — agent state survives app restarts. Board-aware context via MCP at port 4800. +- **Desktop: Agent server service** — Tauri launchd service management for `packages/agent-server` (mirrors board-server). `Install`, `Start`, and `Restart` commands registered as Tauri IPC. +- **Security: Agent server auth** — shared-secret token at `~/.harness-kit/agent-server.token` (mode 0600). All HTTP endpoints require `Authorization: Bearer `; WebSocket upgrade requires `?token=`. Bash tool uses an explicit allowlist of known-safe dev commands (replaces denylist) with additional blocks for pipe-to-interpreter and absolute-path `rm` patterns. - **Desktop: Permission mode selector** — Security → Permissions now has a Task Execution Mode section with three selectable modes: **Skip All** (`--dangerously-skip-permissions`, default), **Auto** (`--permission-mode auto`, requires Claude team/enterprise/API), and **Allowed Tools** (`--allowedTools `, user-curated checklist). Mode and tool list persist in localStorage and apply globally or per-harness via an expandable overrides section. A one-time first-run modal explains the active mode before the first task runs and links to the settings page. A slide-in Tool Approval panel lets users manage the allowed tools list mid-session from within the terminals view. - **Desktop: Security → Permissions redesign** — Permission mode cards with icons, flag badges, and clear selected state. Chip remove buttons now use SVG icons. Improved section hierarchy with a clean divider between execution mode and Claude settings.json controls. diff --git a/CLAUDE.md b/CLAUDE.md index 066d14c..a3d0f2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ harness-kit/ │ ├── shared/ ← shared TypeScript types used across apps │ ├── ui/ ← shared React components │ ├── board-server/ ← WebSocket + HTTP server for the Kanban board and Roadmap/Competitor Analysis features +│ ├── agent-server/ ← LangGraph execution engine for per-card agent runs (port 4801) │ ├── chat-relay/ ← self-hosted WebSocket relay for team chat │ └── membrain/ ← git submodule (siracusa5/membrain) — graph-based memory, excluded from pnpm workspace ├── apps/ ← end-user applications diff --git a/README.md b/README.md index 7556742..54455e1 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ A Tauri desktop companion that brings the harness concept to a native UI. - **Observatory** — live session dashboard with stats and transcripts - **Comparator** -- structured evaluation workbench: configure harnesses, run side-by-side comparisons, review file diffs, and judge results across a 4-phase workflow - **Harness editor** — inline editing with custom profiles -- **Board** — kanban project board with real-time Claude-to-web sync +- **Board** — kanban project board with real-time Claude-to-web sync; per-card agent execution via LangGraph with live phase/progress streaming, subtask tracking, steering, pause/resume, and tool-level permission controls - **Roadmap** — AI-driven product roadmap with competitor analysis, generated via Claude - **Parity** — cross-platform feature parity tracking across AI coding tools - **Security** — permissions editor, secrets management, and audit logging diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 0e96fd1..f200ea2 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -101,6 +101,12 @@ fn main() { "board_server_install", "board_server_start", "board_server_restart", + // Agent server + "get_agent_server_token", + "agent_server_check_installed", + "agent_server_install", + "agent_server_start", + "agent_server_restart", // membrain "membrain_check_installed", "membrain_start", diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 021073f..ef16954 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -129,6 +129,11 @@ "allow-board-server-install", "allow-board-server-start", "allow-board-server-restart", + "allow-get-agent-server-token", + "allow-agent-server-check-installed", + "allow-agent-server-install", + "allow-agent-server-start", + "allow-agent-server-restart", "allow-membrain-check-installed", "allow-membrain-start", "allow-membrain-stop", diff --git a/apps/desktop/src-tauri/src/agent_server.rs b/apps/desktop/src-tauri/src/agent_server.rs new file mode 100644 index 0000000..899e843 --- /dev/null +++ b/apps/desktop/src-tauri/src/agent_server.rs @@ -0,0 +1,244 @@ +use std::net::TcpListener; +use std::process::Command; +use std::path::PathBuf; + +const PORT: u16 = 4801; +const PLIST_LABEL: &str = "com.harness-kit.agent-server"; +const MAX_TRAVERSAL_DEPTH: usize = 10; + +pub struct AgentServerState; + +impl AgentServerState { + pub fn new() -> Self { + Self + } + + /// Returns true if the agent server appears to be running. + pub fn check(&self) -> bool { + port_in_use(PORT) + } +} + +fn port_in_use(port: u16) -> bool { + TcpListener::bind(("127.0.0.1", port)).is_err() +} + +fn plist_path() -> PathBuf { + dirs::home_dir() + .expect("No home directory") + .join("Library/LaunchAgents/com.harness-kit.agent-server.plist") +} + +fn current_uid() -> String { + let output = Command::new("id").arg("-u").output().expect("failed to get uid"); + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +fn find_node() -> Result { + let output = Command::new("which") + .arg("node") + .output() + .map_err(|_| "Could not locate node — install Node.js first".to_string())?; + if !output.status.success() { + return Err("node not found — install Node.js first".to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn find_server_dir() -> Result { + // Traverse up from the executable to locate packages/agent-server (capped depth) + let exe = std::env::current_exe() + .map_err(|_| "Could not determine application location".to_string())?; + let mut dir = exe.parent().map(|p| p.to_path_buf()); + let mut depth = 0; + while let Some(d) = dir { + if depth >= MAX_TRAVERSAL_DEPTH { + break; + } + let candidate = d.join("packages/agent-server/dist/index.js"); + if candidate.exists() { + return Ok(d.join("packages/agent-server")); + } + dir = d.parent().map(|p| p.to_path_buf()); + depth += 1; + } + Err("Agent server not found — run `cd packages/agent-server && pnpm build` first".to_string()) +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn generate_plist(node_path: &str, server_dir: &str, log_dir: &str) -> String { + let node_path = xml_escape(node_path); + let server_dir = xml_escape(server_dir); + let log_dir = xml_escape(log_dir); + format!( + r#" + + + + Label + {PLIST_LABEL} + + ProgramArguments + + {node_path} + {server_dir}/dist/index.js + + + WorkingDirectory + {server_dir} + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + ThrottleInterval + 5 + + StandardOutPath + {log_dir}/agent-server.log + + StandardErrorPath + {log_dir}/agent-server.log + + EnvironmentVariables + + AGENT_SERVER_PORT + 4801 + + +"# + ) +} + +const TOKEN_FILE: &str = ".harness-kit/agent-server.token"; + +#[tauri::command] +pub fn get_agent_server_token() -> Result { + let path = dirs::home_dir() + .ok_or("No home directory")? + .join(TOKEN_FILE); + std::fs::read_to_string(&path) + .map(|s| s.trim().to_string()) + .map_err(|_| "Agent server token not found — start the agent server first".to_string()) +} + +#[tauri::command] +pub fn agent_server_check_installed() -> bool { + plist_path().exists() +} + +#[tauri::command] +pub fn agent_server_install() -> Result { + let node_path = find_node()?; + let server_dir = find_server_dir()?; + let server_dir_str = server_dir.to_string_lossy().to_string(); + + let log_dir = dirs::home_dir() + .expect("No home directory") + .join(".harness-kit/logs"); + std::fs::create_dir_all(&log_dir) + .map_err(|_| "Failed to create log directory".to_string())?; + let log_dir_str = log_dir.to_string_lossy().to_string(); + + let plist_content = generate_plist(&node_path, &server_dir_str, &log_dir_str); + let plist = plist_path(); + + std::fs::write(&plist, &plist_content) + .map_err(|_| "Failed to write service configuration".to_string())?; + + // Restrict plist to owner-only access + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&plist, std::fs::Permissions::from_mode(0o600)); + } + + let uid = current_uid(); + let domain = format!("gui/{uid}"); + + // Bootstrap the service (idempotent — ignores "already loaded" error) + let _ = Command::new("launchctl") + .args(["bootstrap", &domain, &plist.to_string_lossy()]) + .output(); + + // Enable the service + let _ = Command::new("launchctl") + .args(["enable", &format!("{domain}/{PLIST_LABEL}")]) + .output(); + + // Kickstart to run immediately + Command::new("launchctl") + .args(["kickstart", &format!("{domain}/{PLIST_LABEL}")]) + .output() + .map_err(|_| "Failed to start the agent server service".to_string())?; + + Ok("Agent server installed and started".to_string()) +} + +#[tauri::command] +pub fn agent_server_start() -> Result { + let uid = current_uid(); + let domain = format!("gui/{uid}"); + let plist = plist_path(); + + // Bootstrap (idempotent) + let _ = Command::new("launchctl") + .args(["bootstrap", &domain, &plist.to_string_lossy()]) + .output(); + + // Kickstart + Command::new("launchctl") + .args(["kickstart", &format!("{domain}/{PLIST_LABEL}")]) + .output() + .map_err(|_| "Failed to start the agent server service".to_string())?; + + Ok("Agent server started".to_string()) +} + +#[tauri::command] +pub fn agent_server_restart() -> Result { + let uid = current_uid(); + let domain = format!("gui/{uid}"); + + // Kickstart with -k flag to kill existing instance first + Command::new("launchctl") + .args(["kickstart", "-k", &format!("{domain}/{PLIST_LABEL}")]) + .output() + .map_err(|_| "Failed to restart the agent server service".to_string())?; + + Ok("Agent server restarted".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + #[test] + fn port_in_use_returns_false_for_free_port() { + // Bind to 0 to get an ephemeral port, then drop to free it + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + assert!(!port_in_use(port)); + } + + #[test] + fn port_in_use_returns_true_for_occupied_port() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + // listener still bound — port is occupied + assert!(port_in_use(port)); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6e08e17..7b40bb3 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod ai; +mod agent_server; mod commands; mod db; mod board_server; @@ -15,6 +16,7 @@ use tauri::{LogicalSize, Manager}; use ai::client::OllamaState; use commands::terminal::TerminalState; use board_server::BoardServerState; +use agent_server::AgentServerState; use membrain_commands::MembrainServerState; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -39,6 +41,7 @@ pub fn run() { .manage(TerminalState::default()) .manage(database) .manage(BoardServerState::new()) + .manage(AgentServerState::new()) .manage(commands::relay::LocalRelay(tokio::sync::Mutex::new(None))) .manage(MembrainServerState::new()) .manage(OllamaState::new("http://localhost:11434")) @@ -154,6 +157,12 @@ pub fn run() { board_server::board_server_install, board_server::board_server_start, board_server::board_server_restart, + // Agent server + agent_server::get_agent_server_token, + agent_server::agent_server_check_installed, + agent_server::agent_server_install, + agent_server::agent_server_start, + agent_server::agent_server_restart, // membrain membrain_commands::membrain_check_installed, membrain_commands::membrain_start, @@ -198,6 +207,12 @@ pub fn run() { } else { eprintln!("[board-server] not running — install with: pnpm board:install"); } + let agent_state = app.state::(); + if agent_state.check() { + eprintln!("[agent-server] running on :{}", 4801); + } else { + eprintln!("[agent-server] not running — install with: pnpm agent:install"); + } // membrain server starts on-demand when the user navigates to the // Memory section (via useMembrainServerReady hook), not at app launch. // This avoids opening a network listener until the Labs feature is enabled. diff --git a/apps/desktop/src/app.css b/apps/desktop/src/app.css index 4de452a..ada44f3 100644 --- a/apps/desktop/src/app.css +++ b/apps/desktop/src/app.css @@ -1023,6 +1023,16 @@ body { opacity: 1 !important; } +/* ── Agent execution animations (ported from docs/plans/agent-ui-mock.html) ── */ +@keyframes agent-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: .5; transform: scale(.8); } +} + +@keyframes agent-spin { + to { transform: rotate(360deg); } +} + /* ── Reduced motion ── */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { diff --git a/apps/desktop/src/components/agent/AgentExecutionBadge.tsx b/apps/desktop/src/components/agent/AgentExecutionBadge.tsx new file mode 100644 index 0000000..0428252 --- /dev/null +++ b/apps/desktop/src/components/agent/AgentExecutionBadge.tsx @@ -0,0 +1,109 @@ +// apps/desktop/src/components/agent/AgentExecutionBadge.tsx +// Ported from docs/plans/agent-ui-mock.html — .mini-badge, .phase-dot, .phase-label, +// .mini-progress, .mini-progress-fill + +import { useEffect, useState } from 'react'; +import { agentApi } from '../../lib/agent-api'; + +type Phase = 'spec' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'done'; + +const PHASE_COLORS: Record = { + spec: '#6B7FA0', + planning: '#FBBF24', + coding: '#4B9EFF', + qa_review: '#34D399', + qa_fixing: '#FB923C', + done: '#34D399', +}; + +const PHASE_LABELS: Record = { + spec: 'Spec', + planning: 'Planning', + coding: 'Coding', + qa_review: 'QA Review', + qa_fixing: 'QA Fix', + done: 'Done', +}; + +interface Props { + phase: string; + progress: number; + /** Optional: when provided, subscribes to WS for live progress updates */ + taskId?: number; +} + +export function AgentExecutionBadge({ phase: phaseProp, progress: progressProp, taskId }: Props) { + const [livePhase, setLivePhase] = useState(phaseProp); + const [liveProgress, setLiveProgress] = useState(progressProp); + + // Subscribe to WS for live progress if taskId is provided + useEffect(() => { + if (!taskId) return; + setLivePhase(phaseProp); + setLiveProgress(progressProp); + let cleanup: (() => void) | null = null; + let cancelled = false; + agentApi.subscribe(taskId, (event) => { + if (event.type === 'agent_phase') { + setLivePhase(event.phase); + setLiveProgress(event.progress); + } + }).then(unsub => { + if (cancelled) { unsub(); return; } + cleanup = unsub; + }); + return () => { + cancelled = true; + cleanup?.(); + }; + }, [taskId, phaseProp, progressProp]); + + const color = PHASE_COLORS[livePhase] ?? '#6B7FA0'; + const label = PHASE_LABELS[livePhase] ?? livePhase; + + return ( +
+ {/* Phase dot + label row — .mini-badge */} +
+ {/* .phase-dot.anim */} +
+ {/* .phase-label */} + + {label} + +
+ {/* .mini-progress */} +
+ {/* .mini-progress-fill */} +
+
+
+ ); +} diff --git a/apps/desktop/src/components/agent/DiffTab.tsx b/apps/desktop/src/components/agent/DiffTab.tsx new file mode 100644 index 0000000..ec55f4e --- /dev/null +++ b/apps/desktop/src/components/agent/DiffTab.tsx @@ -0,0 +1,79 @@ +// apps/desktop/src/components/agent/DiffTab.tsx +// Ported from docs/plans/agent-ui-mock.html: +// .diff-bar, .diff-code, .d-hunk, .d-add, .d-rem, .d-ctx, .d-ln, .d-text + +import React from 'react'; +import type { AgentEvent } from '../../lib/agent-api'; + +function lineStyle(line: string): React.CSSProperties { + if (line.startsWith('@@')) { + return { color: '#4B9EFF', background: 'rgba(75,158,255,.07)', display: 'flex' }; + } + if (line.startsWith('+')) { + return { color: '#34D399', background: 'rgba(52,211,153,.06)', display: 'flex' }; + } + if (line.startsWith('-')) { + return { color: '#F87171', background: 'rgba(248,113,113,.07)', display: 'flex' }; + } + return { color: '#6B7FA0', display: 'flex' }; +} + +export function DiffTab({ events }: { events: AgentEvent[] }) { + const edits = events.filter( + e => e.type === 'agent_tool' && + (e.action === 'editing' || e.action === 'writing') && + e.state === 'done' + ); + + if (edits.length === 0) { + return ( +
+ No edits yet. +
+ ); + } + + return ( + // .diff-code +
+ {edits.map((e, i) => { + if (e.type !== 'agent_tool') return null; + return ( +
+ {/* .diff-bar */} +
+ + {e.path} +
+ {(e.output ?? []).map((line, j) => ( +
+ {/* .d-ln */} + + {j + 1} + + {/* .d-text */} + {line} +
+ ))} +
+ ); + })} +
+ ); +} diff --git a/apps/desktop/src/components/agent/FilesTab.tsx b/apps/desktop/src/components/agent/FilesTab.tsx new file mode 100644 index 0000000..9b879e4 --- /dev/null +++ b/apps/desktop/src/components/agent/FilesTab.tsx @@ -0,0 +1,64 @@ +// apps/desktop/src/components/agent/FilesTab.tsx +// Ported from docs/plans/agent-ui-mock.html: +// .files-body, .file-row, .file-icon-col, .file-path, .file-path .dir, .file-path .name + +import type { AgentEvent } from '../../lib/agent-api'; + +export function FilesTab({ events }: { events: AgentEvent[] }) { + // Derive modified files from write/edit tool events + const fileMap = new Map(); + for (const e of events) { + if ( + e.type === 'agent_tool' && + (e.action === 'editing' || e.action === 'writing') && + e.state === 'done' + ) { + if (!fileMap.has(e.path)) fileMap.set(e.path, { writes: 0 }); + fileMap.get(e.path)!.writes += 1; + } + } + + if (fileMap.size === 0) { + return ( +
+ No files modified yet. +
+ ); + } + + return ( + // .files-body +
+ {[...fileMap.entries()].map(([path]) => { + const parts = path.split('/'); + const name = parts.pop()!; + const dir = parts.length > 0 ? parts.join('/') + '/' : ''; + return ( + // .file-row +
{ (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,.02)'; }} + onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = 'transparent'; }} + > + {/* .file-icon-col */} + + {/* .file-path */} + + {dir} + {name} + +
+ ); + })} +
+ ); +} diff --git a/apps/desktop/src/components/agent/LogsTab.tsx b/apps/desktop/src/components/agent/LogsTab.tsx new file mode 100644 index 0000000..9740f5d --- /dev/null +++ b/apps/desktop/src/components/agent/LogsTab.tsx @@ -0,0 +1,286 @@ +// apps/desktop/src/components/agent/LogsTab.tsx +// Ported from docs/plans/agent-ui-mock.html: +// .logs-body, .log-entry, .log-ts, .log-thought, .tool-block, .tool-block-inner, +// .tool-header, .tool-label, .tool-path, .tool-status-row, .toggle-btn, +// .tool-output, .tool-output-inner, .output-line, .output-ln, .output-text + +import React, { useState, useRef, useEffect } from 'react'; +import type { AgentEvent } from '../../lib/agent-api'; + +// ── Color mappings — ported from mock CSS variables ─────────────────────────── + +const ACTION_COLORS: Record = { + reading: '#4B9EFF', // --blue + listing: '#4B9EFF', // --blue + editing: '#A78BFA', // --purple + writing: '#A78BFA', // --purple + running: '#FB923C', // --orange + board: '#34D399', // --green +}; + +const LABEL_STYLES: Record = { + reading: { background: 'rgba(75,158,255,.12)', color: '#4B9EFF', border: '1px solid rgba(75,158,255,.25)' }, + listing: { background: 'rgba(75,158,255,.12)', color: '#4B9EFF', border: '1px solid rgba(75,158,255,.25)' }, + editing: { background: 'rgba(167,139,250,.12)', color: '#A78BFA', border: '1px solid rgba(167,139,250,.25)' }, + writing: { background: 'rgba(167,139,250,.12)', color: '#A78BFA', border: '1px solid rgba(167,139,250,.25)' }, + running: { background: 'rgba(251,146,60,.12)', color: '#FB923C', border: '1px solid rgba(251,146,60,.25)' }, + board: { background: 'rgba(52,211,153,.12)', color: '#34D399', border: '1px solid rgba(52,211,153,.25)' }, +}; + +// ── Style constants — ported verbatim from mock ─────────────────────────────── + +const S = { + // .logs-body + body: { + padding: '8px 0', + display: 'flex', + flexDirection: 'column' as const, + }, + // .log-entry + entry: { padding: '10px 24px 6px' }, + // .log-ts + ts: { + fontFamily: 'JetBrains Mono, monospace', + fontSize: 10, + color: '#455270', + marginBottom: 4, + letterSpacing: '.02em', + }, + // .log-thought + thought: { + color: '#B8C4D4', + lineHeight: 1.55, + fontSize: 13, + marginBottom: 8, + }, + // .tool-block + blockWrap: { margin: '0 0 6px', overflow: 'hidden' as const }, + // .tool-block-inner + colored left border + blockInner: (action: string): React.CSSProperties => ({ + background: '#141D2F', + borderTop: '1px solid #1F2D44', + borderBottom: '1px solid #1F2D44', + borderLeft: `2.5px solid ${ACTION_COLORS[action] ?? '#6B7FA0'}`, + borderRight: 'none', + }), + // .tool-header + header: { display: 'flex', alignItems: 'center', gap: 8, padding: '8px 16px' }, + // .tool-file-icon + fileIcon: { opacity: .5, fontSize: 12, flexShrink: 0 } as React.CSSProperties, + // .tool-label + labelPill: (action: string): React.CSSProperties => ({ + fontSize: 10, + fontWeight: 700, + padding: '2px 7px', + borderRadius: 3, + fontFamily: 'JetBrains Mono, monospace', + letterSpacing: '.05em', + flexShrink: 0, + ...(LABEL_STYLES[action] ?? LABEL_STYLES.reading), + }), + // .tool-path + path: { + fontFamily: 'JetBrains Mono, monospace', + fontSize: 12, + color: '#E8EDF5', + flex: 1, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' as const, + }, + // .tool-status-row + statusRow: { + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '5px 16px', + borderTop: '1px solid rgba(255,255,255,.04)', + }, + // .tool-check + check: { color: '#34D399', fontSize: 11 }, + // .tool-done + done: { fontSize: 11, color: '#455270' }, + // .toggle-btn + toggleBtn: { + fontSize: 10, + color: '#455270', + cursor: 'pointer', + padding: '2px 7px', + borderRadius: 3, + border: '1px solid #1F2D44', + background: 'transparent', + fontFamily: 'JetBrains Mono, monospace', + letterSpacing: '.02em', + } as React.CSSProperties, + // .tool-output + outputWrap: { background: '#0D1422', borderTop: '1px solid #1F2D44' }, + // .tool-output-inner + outputInner: { padding: '10px 0', overflowY: 'auto' as const, maxHeight: 180 }, + // .output-line + outputLine: (isErr: boolean): React.CSSProperties => ({ + display: 'flex', + fontFamily: 'JetBrains Mono, monospace', + fontSize: 11, + lineHeight: 1.65, + color: isErr ? '#F87171' : undefined, + }), + // .output-ln + lineNo: { + minWidth: 40, + padding: '0 12px', + color: '#455270', + textAlign: 'right' as const, + userSelect: 'none' as const, + flexShrink: 0, + borderRight: '1px solid #1F2D44', + }, + // .output-text + lineText: { + padding: '0 14px', + color: '#6B7FA0', + wordBreak: 'break-all' as const, + flex: 1, + }, +}; + +// ── Log entry model ─────────────────────────────────────────────────────────── + +interface LogEntry { + ts: string; + thought?: string; + tool?: { + action: string; + path: string; + output?: string[]; + error?: boolean; + }; +} + +function eventsToEntries(events: AgentEvent[]): LogEntry[] { + const entries: LogEntry[] = []; + for (const e of events) { + if (e.type === 'agent_thought') { + entries.push({ ts: e.timestamp, thought: e.text }); + } else if (e.type === 'agent_tool' && e.state === 'done') { + const toolEntry = { + action: e.action, + path: e.path, + output: e.output, + error: false, + }; + const last = entries[entries.length - 1]; + if (last && !last.tool) { + last.tool = toolEntry; + } else { + entries.push({ ts: new Date().toISOString(), tool: toolEntry }); + } + } else if (e.type === 'agent_tool' && e.state === 'error') { + entries.push({ + ts: new Date().toISOString(), + tool: { action: e.action, path: e.path, output: e.output, error: true }, + }); + } + } + return entries; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +interface Props { + taskId: number; + events: AgentEvent[]; +} + +export function LogsTab({ events }: Props) { + const [expanded, setExpanded] = useState>(new Set()); + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [events.length]); + + const entries = eventsToEntries(events); + + const toggle = (key: string) => + setExpanded(s => { + const n = new Set(s); + n.has(key) ? n.delete(key) : n.add(key); + return n; + }); + + if (entries.length === 0) { + return ( +
+ Waiting for agent events… +
+ ); + } + + return ( +
+ {entries.map((entry, i) => ( +
+ {entry.ts && ( +
{new Date(entry.ts).toLocaleString()}
+ )} + {entry.thought && ( +
{entry.thought}
+ )} + {entry.tool && (() => { + const { action, path, output, error } = entry.tool; + const key = `${i}`; + const isExp = expanded.has(key); + const label = action.charAt(0).toUpperCase() + action.slice(1); + return ( +
+
+ {/* .tool-header */} +
+ + {label} + {path} +
+ {/* .tool-status-row */} +
+ + {error ? 'Error' : 'Done'} + {output && output.length > 0 && ( + + )} +
+ {/* .tool-output */} + {isExp && output && ( +
+
+ {output.map((line, n) => ( +
+ {n + 1} + {line} +
+ ))} +
+
+ )} +
+
+ ); + })()} +
+ ))} +
+
+ ); +} diff --git a/apps/desktop/src/components/agent/OverviewTab.tsx b/apps/desktop/src/components/agent/OverviewTab.tsx new file mode 100644 index 0000000..9e7b3bf --- /dev/null +++ b/apps/desktop/src/components/agent/OverviewTab.tsx @@ -0,0 +1,310 @@ +// apps/desktop/src/components/agent/OverviewTab.tsx +// Ported from docs/plans/agent-ui-mock.html: +// .overview-body, .phase-timeline, .phase-step, .phase-circle, .phase-name, +// .current-subtask-box, .csb-spinner, .steering-box, .steering-input, .send-btn, +// .control-btns, .ctrl-btn + +import React, { useState } from 'react'; +import { agentApi } from '../../lib/agent-api'; +import { api } from '../../lib/board-api'; +import type { Task } from '../../lib/board-api'; + +const PHASES = ['spec', 'planning', 'coding', 'qa_review', 'done'] as const; + +const PHASE_LABELS: Record = { + spec: 'Spec', + planning: 'Plan', + coding: 'Code', + qa_review: 'QA', + done: 'Done', +}; + +// All possible phases in order (for index comparison) +const ALL_PHASES = ['spec', 'planning', 'coding', 'qa_review', 'qa_fixing']; + +interface Props { + task: Task; + projectSlug: string; + currentPhase: string | null; + onTaskUpdated?: () => void; +} + +export function OverviewTab({ task, projectSlug, currentPhase, onTaskUpdated }: Props) { + const [steering, setSteering] = useState(''); + const [sending, setSending] = useState(false); + const [steerError, setSteerError] = useState(null); + + const curIdx = ALL_PHASES.indexOf(currentPhase ?? ''); + const active = task.subtasks.find(s => s.status === 'in_progress'); + const isPaused = task.execution?.status === 'paused'; + + const serialTask = { + id: task.id, title: task.title, description: task.description, + subtasks: task.subtasks.map(s => ({ id: s.id, title: s.title, status: s.status, phase: s.phase })), + worktree_path: task.worktree_path, default_model: task.default_model, + }; + + const handleSteer = async () => { + if (!steering.trim() || sending) return; + setSending(true); + setSteerError(null); + try { + const res = await agentApi.steer(projectSlug, task.id, serialTask, steering); + if (!res.ok) throw new Error(`Server error ${res.status}`); + setSteering(''); + } catch (err) { + setSteerError(err instanceof Error ? err.message : 'Failed to send message'); + } finally { + setSending(false); + } + }; + + const handlePause = async () => { + await agentApi.pause(projectSlug, task.id); + await api.tasks.updateExecution(projectSlug, task.id, { status: 'paused' }).catch(console.error); + onTaskUpdated?.(); + }; + + const handleResume = async () => { + await agentApi.resume(projectSlug, serialTask); + await api.tasks.updateExecution(projectSlug, task.id, { + status: 'running', phase: currentPhase ?? 'coding', + }).catch(console.error); + onTaskUpdated?.(); + }; + + return ( + // .overview-body +
+ + {/* Phase timeline */} +
+
+ Phase Progress +
+ {/* .phase-timeline */} +
+ {PHASES.map((p, i) => { + const realIdx = ALL_PHASES.indexOf(p); + const isDone = realIdx < curIdx || (p === 'done' && currentPhase === 'done'); + const isActive = p === currentPhase || (realIdx === curIdx && p !== 'done'); + + // .phase-circle + const circleStyle: React.CSSProperties = { + width: 22, height: 22, borderRadius: '50%', + display: 'flex', alignItems: 'center', justifyContent: 'center', + fontSize: 9, zIndex: 1, position: 'relative', + fontFamily: 'JetBrains Mono, monospace', + transition: 'all .3s', + ...(isDone + ? { background: 'rgba(52,211,153,.2)', border: '1.5px solid #34D399', color: '#34D399' } + : isActive + ? { background: 'rgba(75,158,255,.15)', border: '1.5px solid #4B9EFF', color: '#4B9EFF' } + : { background: '#161E2E', border: '1.5px solid #253352', color: '#455270' }), + }; + + // .phase-name + const nameColor = isDone ? '#34D399' : isActive ? '#4B9EFF' : '#455270'; + + return ( + // .phase-step +
+
+ {isDone ? '✓' : isActive ? '◉' : String(i + 1)} +
+
+ {PHASE_LABELS[p]} +
+ {/* Connector line — pseudoelement replacement */} + {i < PHASES.length - 1 && ( +
+ )} +
+ ); + })} +
+
+ + {/* Current subtask box — .current-subtask-box */} +
+
+ Currently working on +
+ {/* .csb-content */} +
+ {/* .csb-spinner */} +
+ + {active?.title ?? 'Preparing next subtask…'} + +
+
+ + {/* Steering box — .steering-box */} +
+
+ Steer the agent +
+ {/* .steering-row */} +
+ {/* .steering-input */} +