Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit 30ef328

Browse files
authored
feat(web): reposition UI as viewer + light CRUD (#8)
* feat(web): replace 'Start Work' button with 'Copy command' The 'Start Work' button posted to /epics/:id/work which only updated task status in SQLite — it never spawned Claude Code workers. That was a fake promise. Replaced with a 'Copy /flow-code:work <id>' action plus explainer text making clear that agent execution belongs in the Claude Code terminal. fn-18-reposition-web-ui-as-viewer-light-crud.1 * docs: add Web UI positioning section to README Documents the divide of labor between Claude Code terminal (agent execution), web UI (browse + light CRUD), and flowctl CLI (scripting). Makes it explicit that the web UI is a read-only dashboard, not an orchestrator — you copy the work command from the UI and run it in your Claude Code session. fn-18-reposition-web-ui-as-viewer-light-crud.2 * feat(web): add epic search + status filter to Dashboard When epic count grows past ~10, scanning the grid becomes painful. Added a search input (matches id or title, case-insensitive) and a status dropdown derived from live epic statuses. Shows filtered/total count when any filter is active, with a clear-filters button. Domain filter deliberately skipped — domain is per-task, not per-epic, so filtering epics by domain would require pulling task lists into the /epics response. Not worth the cost now. fn-18-reposition-web-ui-as-viewer-light-crud.3 * feat(web): evidence viewer modal for done tasks Adds GET /api/v1/tasks/{id} endpoint returning task + evidence + runtime state (including duration_seconds). Frontend exposes a clickable title on done tasks that opens an EvidenceModal showing status, duration, diff stats, commits, tests, review iterations, and workspace_changes if recorded. Backend: new get_task_handler in handlers/task.rs merges TaskRepo, EvidenceRepo, and RuntimeRepo into a single response payload. Frontend: EvidenceModal component with Escape/backdrop close, loading/error states, and conditional rendering per evidence field. fn-18-reposition-web-ui-as-viewer-light-crud.4
1 parent c1172ed commit 30ef328

8 files changed

Lines changed: 487 additions & 44 deletions

File tree

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,39 @@ You can always run interview again after planning to catch anything missed. Inte
299299

300300
---
301301

302+
## Web UI
303+
304+
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.
305+
306+
### Divide of labor
307+
308+
| Surface | Responsibility |
309+
|---|---|
310+
| **Claude Code terminal** | Run agents: `/flow-code:plan`, `/flow-code:work`, `/flow-code:brainstorm`, reviews, worker spawning, permission prompts |
311+
| **Web UI** | Browse epics/tasks/specs/DAG/memory/evidence; light CRUD (create epic, edit deps, add gaps, archive epics) |
312+
| **`flowctl` CLI** | Scripting, CI, automation, anything headless |
313+
314+
### Launch
315+
316+
```bash
317+
# Build the frontend once
318+
cd frontend && bun install && bun run build
319+
320+
# Start the daemon with TCP port (serves API + static web UI on same port)
321+
flowctl serve --port 3737
322+
323+
# Open in browser
324+
open http://localhost:3737
325+
```
326+
327+
Without `--port`, `flowctl serve` binds a Unix socket only (CLI/MCP clients, no browser).
328+
329+
### What the Web UI does not do
330+
331+
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.
332+
333+
---
334+
302335
## Agent Readiness Assessment
303336

304337
> Inspired by [Factory.ai's Agent Readiness framework](https://factory.ai/news/agent-readiness)

flowctl/crates/flowctl-daemon/src/handlers/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ pub use dag::{
1818
pub use epic::{create_epic_handler, set_epic_plan_handler, start_epic_work_handler};
1919
pub use task::{
2020
block_task_handler, block_task_rest_handler, create_task_handler, done_task_handler,
21-
done_task_rest_handler, restart_task_handler, restart_task_rest_handler,
21+
done_task_rest_handler, get_task_handler, restart_task_handler, restart_task_rest_handler,
2222
skip_task_handler, skip_task_rest_handler, start_task_handler, start_task_rest_handler,
2323
};
2424
pub use ws::events_ws_handler;

flowctl/crates/flowctl-daemon/src/handlers/task.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,52 @@ pub async fn restart_task_handler(
468468
}
469469
}
470470

471+
/// GET /api/v1/tasks/:id -- fetch full task details + evidence + runtime state.
472+
pub async fn get_task_handler(
473+
State(state): State<AppState>,
474+
axum::extract::Path(task_id): axum::extract::Path<String>,
475+
) -> Result<Json<serde_json::Value>, AppError> {
476+
let conn = state.db_lock()?;
477+
let task_repo = flowctl_db::TaskRepo::new(&conn);
478+
let (task, body) = task_repo
479+
.get_with_body(&task_id)
480+
.map_err(|_| AppError::InvalidInput(format!("task not found: {task_id}")))?;
481+
482+
let evidence_repo = flowctl_db::EvidenceRepo::new(&conn);
483+
let evidence = evidence_repo
484+
.get(&task_id)
485+
.map_err(|e| AppError::Internal(format!("evidence fetch error: {e}")))?;
486+
487+
let runtime_repo = flowctl_db::RuntimeRepo::new(&conn);
488+
let runtime = runtime_repo
489+
.get(&task_id)
490+
.map_err(|e| AppError::Internal(format!("runtime fetch error: {e}")))?;
491+
492+
let mut value = serde_json::to_value(&task)
493+
.map_err(|e| AppError::Internal(format!("serialization error: {e}")))?;
494+
if let Some(obj) = value.as_object_mut() {
495+
obj.insert("body".to_string(), serde_json::Value::String(body));
496+
obj.insert(
497+
"evidence".to_string(),
498+
serde_json::to_value(&evidence)
499+
.map_err(|e| AppError::Internal(format!("evidence serialization error: {e}")))?,
500+
);
501+
obj.insert(
502+
"runtime".to_string(),
503+
serde_json::to_value(&runtime)
504+
.map_err(|e| AppError::Internal(format!("runtime serialization error: {e}")))?,
505+
);
506+
obj.insert(
507+
"duration_seconds".to_string(),
508+
match runtime.as_ref().and_then(|r| r.duration_secs) {
509+
Some(d) => serde_json::Value::Number(d.into()),
510+
None => serde_json::Value::Null,
511+
},
512+
);
513+
}
514+
Ok(Json(value))
515+
}
516+
471517
// ── Request types ─────────────────────────────────────────────
472518

473519
#[derive(Debug, serde::Deserialize)]

flowctl/crates/flowctl-daemon/src/server.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ pub fn build_router(state: AppState) -> axum::Router {
8989
// ── New RESTful endpoints ──────────────────────────────
9090
.route("/api/v1/epics/{id}/plan", post(handlers::set_epic_plan_handler))
9191
.route("/api/v1/epics/{id}/work", post(handlers::start_epic_work_handler))
92+
.route("/api/v1/tasks/{id}", get(handlers::get_task_handler))
9293
.route("/api/v1/tasks/{id}/start", post(handlers::start_task_rest_handler))
9394
.route("/api/v1/tasks/{id}/done", post(handlers::done_task_rest_handler))
9495
.route("/api/v1/tasks/{id}/block", post(handlers::block_task_rest_handler))
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { Search, X } from "lucide-react";
2+
3+
export interface EpicFiltersValue {
4+
search: string;
5+
status: string;
6+
}
7+
8+
export const DEFAULT_EPIC_FILTERS: EpicFiltersValue = {
9+
search: "",
10+
status: "all",
11+
};
12+
13+
interface EpicFiltersProps {
14+
value: EpicFiltersValue;
15+
onChange: (next: EpicFiltersValue) => void;
16+
statusOptions: string[];
17+
filteredCount: number;
18+
totalCount: number;
19+
}
20+
21+
export default function EpicFilters({
22+
value,
23+
onChange,
24+
statusOptions,
25+
filteredCount,
26+
totalCount,
27+
}: EpicFiltersProps) {
28+
const active = value.search.trim() !== "" || value.status !== "all";
29+
30+
return (
31+
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-3">
32+
<div className="relative flex-1 min-w-0">
33+
<Search
34+
size={14}
35+
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
36+
/>
37+
<input
38+
type="text"
39+
value={value.search}
40+
onChange={(e) => onChange({ ...value, search: e.target.value })}
41+
placeholder="Search epics by id or title…"
42+
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"
43+
/>
44+
{value.search && (
45+
<button
46+
onClick={() => onChange({ ...value, search: "" })}
47+
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary transition-colors"
48+
aria-label="Clear search"
49+
>
50+
<X size={14} />
51+
</button>
52+
)}
53+
</div>
54+
55+
<select
56+
value={value.status}
57+
onChange={(e) => onChange({ ...value, status: e.target.value })}
58+
className="px-3 py-2 rounded-md text-sm bg-bg-secondary border border-border text-text-primary focus:outline-none focus:border-accent transition-colors"
59+
>
60+
<option value="all">All statuses</option>
61+
{statusOptions.map((s) => (
62+
<option key={s} value={s}>
63+
{s.replace("_", " ")}
64+
</option>
65+
))}
66+
</select>
67+
68+
{active && (
69+
<div className="flex items-center gap-2 text-xs text-text-muted">
70+
<span className="font-mono whitespace-nowrap">
71+
{filteredCount} / {totalCount}
72+
</span>
73+
<button
74+
onClick={() => onChange(DEFAULT_EPIC_FILTERS)}
75+
className="text-accent hover:text-accent-hover transition-colors whitespace-nowrap"
76+
>
77+
Clear filters
78+
</button>
79+
</div>
80+
)}
81+
</div>
82+
);
83+
}

0 commit comments

Comments
 (0)