diff --git a/README.md b/README.md index a10a88d6..1cfe2d54 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,39 @@ You can always run interview again after planning to catch anything missed. Inte --- +## Web UI + +flow-code ships a React web UI for browsing `.flow/` state. **It is a read-only dashboard plus light CRUD, not an orchestrator.** All agent execution happens in your Claude Code terminal session. + +### Divide of labor + +| Surface | Responsibility | +|---|---| +| **Claude Code terminal** | Run agents: `/flow-code:plan`, `/flow-code:work`, `/flow-code:brainstorm`, reviews, worker spawning, permission prompts | +| **Web UI** | Browse epics/tasks/specs/DAG/memory/evidence; light CRUD (create epic, edit deps, add gaps, archive epics) | +| **`flowctl` CLI** | Scripting, CI, automation, anything headless | + +### Launch + +```bash +# Build the frontend once +cd frontend && bun install && bun run build + +# Start the daemon with TCP port (serves API + static web UI on same port) +flowctl serve --port 3737 + +# Open in browser +open http://localhost:3737 +``` + +Without `--port`, `flowctl serve` binds a Unix socket only (CLI/MCP clients, no browser). + +### What the Web UI does not do + +The web UI never starts, stops, or manages Claude Code / Codex agents. When you want to execute an epic, the web UI shows you the exact command to paste into your Claude Code terminal (e.g., `/flow-code:work fn-3-add-oauth`). This is deliberate — agent execution, permission prompts, and live worker output belong in the terminal where Claude Code runs. + +--- + ## Agent Readiness Assessment > Inspired by [Factory.ai's Agent Readiness framework](https://factory.ai/news/agent-readiness) diff --git a/flowctl/crates/flowctl-daemon/src/handlers/mod.rs b/flowctl/crates/flowctl-daemon/src/handlers/mod.rs index d30a238d..56832848 100644 --- a/flowctl/crates/flowctl-daemon/src/handlers/mod.rs +++ b/flowctl/crates/flowctl-daemon/src/handlers/mod.rs @@ -18,7 +18,7 @@ pub use dag::{ pub use epic::{create_epic_handler, set_epic_plan_handler, start_epic_work_handler}; pub use task::{ block_task_handler, block_task_rest_handler, create_task_handler, done_task_handler, - done_task_rest_handler, restart_task_handler, restart_task_rest_handler, + done_task_rest_handler, get_task_handler, restart_task_handler, restart_task_rest_handler, skip_task_handler, skip_task_rest_handler, start_task_handler, start_task_rest_handler, }; pub use ws::events_ws_handler; diff --git a/flowctl/crates/flowctl-daemon/src/handlers/task.rs b/flowctl/crates/flowctl-daemon/src/handlers/task.rs index 91880b6f..b4ef8e44 100644 --- a/flowctl/crates/flowctl-daemon/src/handlers/task.rs +++ b/flowctl/crates/flowctl-daemon/src/handlers/task.rs @@ -468,6 +468,52 @@ pub async fn restart_task_handler( } } +/// GET /api/v1/tasks/:id -- fetch full task details + evidence + runtime state. +pub async fn get_task_handler( + State(state): State, + axum::extract::Path(task_id): axum::extract::Path, +) -> Result, AppError> { + let conn = state.db_lock()?; + let task_repo = flowctl_db::TaskRepo::new(&conn); + let (task, body) = task_repo + .get_with_body(&task_id) + .map_err(|_| AppError::InvalidInput(format!("task not found: {task_id}")))?; + + let evidence_repo = flowctl_db::EvidenceRepo::new(&conn); + let evidence = evidence_repo + .get(&task_id) + .map_err(|e| AppError::Internal(format!("evidence fetch error: {e}")))?; + + let runtime_repo = flowctl_db::RuntimeRepo::new(&conn); + let runtime = runtime_repo + .get(&task_id) + .map_err(|e| AppError::Internal(format!("runtime fetch error: {e}")))?; + + let mut value = serde_json::to_value(&task) + .map_err(|e| AppError::Internal(format!("serialization error: {e}")))?; + if let Some(obj) = value.as_object_mut() { + obj.insert("body".to_string(), serde_json::Value::String(body)); + obj.insert( + "evidence".to_string(), + serde_json::to_value(&evidence) + .map_err(|e| AppError::Internal(format!("evidence serialization error: {e}")))?, + ); + obj.insert( + "runtime".to_string(), + serde_json::to_value(&runtime) + .map_err(|e| AppError::Internal(format!("runtime serialization error: {e}")))?, + ); + obj.insert( + "duration_seconds".to_string(), + match runtime.as_ref().and_then(|r| r.duration_secs) { + Some(d) => serde_json::Value::Number(d.into()), + None => serde_json::Value::Null, + }, + ); + } + Ok(Json(value)) +} + // ── Request types ───────────────────────────────────────────── #[derive(Debug, serde::Deserialize)] diff --git a/flowctl/crates/flowctl-daemon/src/server.rs b/flowctl/crates/flowctl-daemon/src/server.rs index cb874867..7622a3c2 100644 --- a/flowctl/crates/flowctl-daemon/src/server.rs +++ b/flowctl/crates/flowctl-daemon/src/server.rs @@ -89,6 +89,7 @@ pub fn build_router(state: AppState) -> axum::Router { // ── New RESTful endpoints ────────────────────────────── .route("/api/v1/epics/{id}/plan", post(handlers::set_epic_plan_handler)) .route("/api/v1/epics/{id}/work", post(handlers::start_epic_work_handler)) + .route("/api/v1/tasks/{id}", get(handlers::get_task_handler)) .route("/api/v1/tasks/{id}/start", post(handlers::start_task_rest_handler)) .route("/api/v1/tasks/{id}/done", post(handlers::done_task_rest_handler)) .route("/api/v1/tasks/{id}/block", post(handlers::block_task_rest_handler)) diff --git a/frontend/src/components/EpicFilters.tsx b/frontend/src/components/EpicFilters.tsx new file mode 100644 index 00000000..fc0cfa2e --- /dev/null +++ b/frontend/src/components/EpicFilters.tsx @@ -0,0 +1,83 @@ +import { Search, X } from "lucide-react"; + +export interface EpicFiltersValue { + search: string; + status: string; +} + +export const DEFAULT_EPIC_FILTERS: EpicFiltersValue = { + search: "", + status: "all", +}; + +interface EpicFiltersProps { + value: EpicFiltersValue; + onChange: (next: EpicFiltersValue) => void; + statusOptions: string[]; + filteredCount: number; + totalCount: number; +} + +export default function EpicFilters({ + value, + onChange, + statusOptions, + filteredCount, + totalCount, +}: EpicFiltersProps) { + const active = value.search.trim() !== "" || value.status !== "all"; + + return ( +
+
+ + onChange({ ...value, search: e.target.value })} + placeholder="Search epics by id or title…" + className="w-full pl-8 pr-8 py-2 rounded-md text-sm bg-bg-secondary border border-border text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent transition-colors" + /> + {value.search && ( + + )} +
+ + + + {active && ( +
+ + {filteredCount} / {totalCount} + + +
+ )} +
+ ); +} diff --git a/frontend/src/components/EvidenceModal.tsx b/frontend/src/components/EvidenceModal.tsx new file mode 100644 index 00000000..281b860e --- /dev/null +++ b/frontend/src/components/EvidenceModal.tsx @@ -0,0 +1,215 @@ +import { useEffect } from "react"; +import useSWR from "swr"; +import { X } from "lucide-react"; +import { swrFetcher } from "../lib/api"; + +interface Evidence { + commits?: string[]; + tests?: string[]; + prs?: string[]; + files_changed?: number; + insertions?: number; + deletions?: number; + review_iterations?: number; + workspace_changes?: unknown; +} + +interface TaskDetail { + id: string; + title: string; + status: string; + domain?: string; + duration_seconds?: number | null; + evidence?: Evidence | null; +} + +interface EvidenceModalProps { + taskId: string | null; + open: boolean; + onClose: () => void; +} + +function formatDuration(seconds?: number | null): string { + if (!seconds && seconds !== 0) return "--"; + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return `${h}h ${m}m`; +} + +export default function EvidenceModal({ taskId, open, onClose }: EvidenceModalProps) { + const { data, error, isLoading } = useSWR( + open && taskId ? `/tasks/${taskId}` : null, + swrFetcher, + ); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + if (!open) return null; + + const evidence = data?.evidence ?? null; + const hasEvidence = + evidence && + ((evidence.commits?.length ?? 0) > 0 || + (evidence.tests?.length ?? 0) > 0 || + (evidence.files_changed ?? 0) > 0 || + evidence.workspace_changes); + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+

+ {data?.title ?? "Task details"} +

+

+ {taskId} +

+
+ +
+ + {/* Body */} +
+ {isLoading && ( +
Loading…
+ )} + {error && ( +
+ Failed to load task details. +
+ )} + {data && !isLoading && !error && ( +
+ {/* Summary row */} +
+
+

Status

+

+ {data.status.replace("_", " ")} +

+
+
+

Duration

+

+ {formatDuration(data.duration_seconds)} +

+
+ {data.domain && ( +
+

Domain

+

{data.domain}

+
+ )} +
+ + {/* Evidence details */} + {!hasEvidence ? ( +
+ No evidence recorded for this task yet. +
+ ) : ( +
+ {(evidence?.files_changed != null || + evidence?.insertions != null || + evidence?.deletions != null) && ( +
+

+ Diff stats +

+
+ {evidence?.files_changed != null && ( + + {evidence.files_changed} files + + )} + {evidence?.insertions != null && ( + +{evidence.insertions} + )} + {evidence?.deletions != null && ( + -{evidence.deletions} + )} +
+
+ )} + + {evidence?.commits && evidence.commits.length > 0 && ( +
+

+ Commits +

+
    + {evidence.commits.map((c) => ( +
  • + {c} +
  • + ))} +
+
+ )} + + {evidence?.tests && evidence.tests.length > 0 && ( +
+

+ Tests run +

+
    + {evidence.tests.map((t) => ( +
  • + {t} +
  • + ))} +
+
+ )} + + {evidence?.review_iterations != null && ( +
+

+ Review iterations +

+

{evidence.review_iterations}

+
+ )} + + {evidence?.workspace_changes != null && ( +
+

+ Workspace changes +

+
+                        {JSON.stringify(evidence.workspace_changes, null, 2)}
+                      
+
+ )} +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b393206d..bccbddbd 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -8,6 +8,10 @@ import { LayoutDashboard, CheckCircle, Loader, Coins, Plus } from "lucide-react" import StatsCard from "../components/StatsCard"; import Skeleton from "../components/ui/Skeleton"; import Badge from "../components/ui/Badge"; +import EpicFilters, { + DEFAULT_EPIC_FILTERS, + type EpicFiltersValue, +} from "../components/EpicFilters"; interface Stats { total_epics: number; @@ -140,6 +144,7 @@ export default function Dashboard() { ); const [creating, setCreating] = useState(false); + const [filters, setFilters] = useState(DEFAULT_EPIC_FILTERS); const [events, setEvents] = useState([]); const eventIdRef = useRef(0); @@ -238,27 +243,59 @@ export default function Dashboard() { {/* Epic cards grid */} {epics && epics.length > 0 ? ( -
-

Epics

-
- {epics.map((epic) => ( - -
-

{epic.title}

- + (() => { + const statusOptions = Array.from(new Set(epics.map((e) => e.status))).sort(); + const q = filters.search.trim().toLowerCase(); + const filtered = epics.filter((e) => { + if (filters.status !== "all" && e.status !== filters.status) return false; + if (q && !e.id.toLowerCase().includes(q) && !e.title.toLowerCase().includes(q)) { + return false; + } + return true; + }); + return ( +
+

Epics

+ + {filtered.length === 0 ? ( +
+

No epics match your filters.

+
- -

- {epic.id} -

- - ))} -
-
+ ) : ( +
+ {filtered.map((epic) => ( + +
+

{epic.title}

+ +
+ +

+ {epic.id} +

+ + ))} +
+ )} +
+ ); + })() ) : (
diff --git a/frontend/src/pages/EpicDetail.tsx b/frontend/src/pages/EpicDetail.tsx index 8b68f509..fa1f1d95 100644 --- a/frontend/src/pages/EpicDetail.tsx +++ b/frontend/src/pages/EpicDetail.tsx @@ -1,9 +1,11 @@ import { useState } from "react"; import { useParams, Link } from "react-router-dom"; import useSWR from "swr"; -import { Plus, GitBranch, Play } from "lucide-react"; -import { swrFetcher, apiPost } from "../lib/api"; +import { Plus, GitBranch, Copy } from "lucide-react"; +import { toast } from "sonner"; +import { swrFetcher } from "../lib/api"; import CreateTaskForm from "../components/CreateTaskForm"; +import EvidenceModal from "../components/EvidenceModal"; import TaskActions from "../components/TaskActions"; import Badge from "../components/ui/Badge"; import Table from "../components/ui/Table"; @@ -88,7 +90,7 @@ function EpicDetailSkeleton() { export default function EpicDetail() { const { id } = useParams<{ id: string }>(); const [formOpen, setFormOpen] = useState(false); - const [starting, setStarting] = useState(false); + const [evidenceTaskId, setEvidenceTaskId] = useState(null); const { data, isLoading, mutate } = useSWR( id ? `/tasks?epic_id=${id}` : null, @@ -109,7 +111,7 @@ export default function EpicDetail() { const inProgressCount = tasks.filter((t) => t.status === "in_progress").length; const progress = total > 0 ? Math.round((doneCount / total) * 100) : 0; - const canStartWork = total > 0; + const workCommand = `/flow-code:work ${id ?? ""}`; const stats = [ { label: "Total", value: total, color: "text-text-primary" }, @@ -118,16 +120,15 @@ export default function EpicDetail() { { label: "Blocked", value: blockedCount, color: "text-error" }, ]; - async function handleStartWork() { + async function handleCopyCommand() { if (!id) return; - setStarting(true); try { - await apiPost(`/epics/${id}/work`); - mutate(); - } catch (err) { - console.error("Failed to start work:", err); - } finally { - setStarting(false); + await navigator.clipboard.writeText(workCommand); + toast.success("Command copied", { + description: "Run it in your Claude Code terminal to execute this epic.", + }); + } catch { + toast.error("Copy failed — select and copy manually"); } } @@ -137,12 +138,25 @@ export default function EpicDetail() { header: "Title", sortable: true, sortValue: (t: Task) => t.title, - render: (t: Task) => ( -
- {t.title} - {t.id} -
- ), + render: (t: Task) => { + const clickable = t.status === "done"; + return ( +
+ {clickable ? ( + + ) : ( + {t.title} + )} + {t.id} +
+ ); + }, }, { key: "status", @@ -206,12 +220,14 @@ export default function EpicDetail() {
+ {/* Execution explainer */} +
+ Agent execution happens in your Claude Code terminal. This web UI is for browsing and data management. +
+ {/* Stats row */}
{stats.map((s) => ( @@ -262,6 +283,13 @@ export default function EpicDetail() { onCreated={() => mutate()} /> )} + + {/* Evidence modal */} + setEvidenceTaskId(null)} + />
); }