Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Comment on lines +308 to +313
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The markdown table in this section uses double leading pipes (||) on each row, which renders as an extra empty column (or breaks table rendering depending on the renderer). Use single leading/ending pipes (|) for standard GitHub-flavored markdown tables.

Copilot uses AI. Check for mistakes.
### 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)
Expand Down
2 changes: 1 addition & 1 deletion flowctl/crates/flowctl-daemon/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions flowctl/crates/flowctl-daemon/src/handlers/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>,
axum::extract::Path(task_id): axum::extract::Path<String>,
) -> Result<Json<serde_json::Value>, 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)]
Expand Down
1 change: 1 addition & 0 deletions flowctl/crates/flowctl-daemon/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Comment on lines 89 to 95
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new GET route for fetching task details was added, but there’s no corresponding handler/router test coverage alongside the existing server/router tests in this crate. Add a test that creates a task (and optionally evidence/runtime rows) and asserts GET /api/v1/tasks/{id} returns the expected shape (including nulls when rows don’t exist).

Copilot uses AI. Check for mistakes.
Expand Down
83 changes: 83 additions & 0 deletions frontend/src/components/EpicFilters.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-3">
<div className="relative flex-1 min-w-0">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
/>
<input
type="text"
value={value.search}
onChange={(e) => 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 && (
<button
onClick={() => onChange({ ...value, search: "" })}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary transition-colors"
aria-label="Clear search"
>
<X size={14} />
</button>
)}
</div>

<select
value={value.status}
onChange={(e) => onChange({ ...value, status: e.target.value })}
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"
>
<option value="all">All statuses</option>
{statusOptions.map((s) => (
<option key={s} value={s}>
{s.replace("_", " ")}
</option>
))}
</select>

{active && (
<div className="flex items-center gap-2 text-xs text-text-muted">
<span className="font-mono whitespace-nowrap">
{filteredCount} / {totalCount}
</span>
<button
onClick={() => onChange(DEFAULT_EPIC_FILTERS)}
className="text-accent hover:text-accent-hover transition-colors whitespace-nowrap"
>
Clear filters
</button>
</div>
)}
</div>
);
}
Loading
Loading