Replace Alpine frontend with Next.js Claude Activity Dashboard (#7)

Rebuild the bundled web UI as a Next.js (React + TypeScript) static export
implementing the Claude Activity Dashboard design: a left-nav "Control Center"
hub over Runs, Repositories, Agents, Approvals, Git Servers, Activity, and
Shared, styled with the Leeworks design-system tokens (flat, dark, border-led).

The dashboard is a pure client of the existing API (same contract as curl):
the browser prompts for the token once, stores it in localStorage, attaches it
to every call, and renders all API values as React text so agent-authored
strings stay inert. Control actions enqueue a command and poll it to a terminal
state, matching the worker model.

The build output is committed to src/handler/api/static/ so the wheel ships it
and FastAPI serves it same-origin. app.py now mounts the export at "/" after the
API routers (a non-shadowing fallback: unmatched paths 404, no SPA rewrite).
UI-serving tests updated for the export; frontend source lives in frontend/.


Claude-Session: https://claude.ai/code/session_01ATgVWRjFzG8nHEnwgZpJWD

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Wyatt
2026-07-10 14:34:43 -04:00
committed by GitHub
parent 6668d14c34
commit 7399315185
45 changed files with 3763 additions and 1223 deletions
@@ -0,0 +1,306 @@
/* Runs — the inbox: a flat list of every agent across every project on the left, the
* selected agent's checkmark + log + answer/resume flow on the right. Maps the design's
* "Runs" pane to Handler's agent / checkmark / log model. */
"use client";
import { useMemo, useState } from "react";
import { useDashboard } from "@/components/store";
import { Badge, Button, Callout, Stat, StatusBadge, Tabs, Textarea } from "@/components/ui";
import { fmtFull, shortSha, statusTone, timeAgo } from "@/lib/format";
import type { Agent } from "@/lib/api";
const FILTERS = [
{ value: "all", label: "All" },
{ value: "needs", label: "Needs Input" },
{ value: "working", label: "Working" },
{ value: "done", label: "Done" },
];
function matches(filter: string, status: string): boolean {
if (filter === "all") return true;
if (filter === "needs") return status === "paused_for_input";
if (filter === "working") return status === "working" || status === "running";
if (filter === "done") return status === "done" || status === "completed";
return true;
}
export function RunsSection() {
const s = useDashboard();
const [filter, setFilter] = useState("all");
const runs = useMemo(() => {
const list = s.agents.filter((a) => matches(filter, a.status));
return [...list].sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
}, [s.agents, filter]);
const selected = s.selectedRun;
const needs = s.agents.filter((a) => a.status === "paused_for_input").length;
const working = s.agents.filter((a) => a.status === "working" || a.status === "running").length;
return (
<div className="runs">
<div className="runs-stats">
<div className="stat-row">
<div className="stat-cell">
<Stat value={s.agents.length} label="Runs tracked" />
</div>
<div className="stat-cell">
<Stat value={needs} label="Needs input" accent />
</div>
<div className="stat-cell">
<Stat value={working} label="Working" />
</div>
<div className="stat-cell">
<Stat value={s.projects.length} label="Repositories" />
</div>
</div>
</div>
<div className="split">
<div className="split-list">
<div className="split-list-head">
<div className="section-title" style={{ fontSize: "var(--text-lg)" }}>
Runs
</div>
<Tabs tabs={FILTERS} value={filter} onChange={setFilter} />
</div>
<div className="split-list-scroll">
{runs.length === 0 && <Callout tone="info">No runs match this filter.</Callout>}
{runs.map((a) => (
<RunRow
key={`${a.project_id}/${a.name}`}
agent={a}
selected={selected?.projectId === a.project_id && selected?.name === a.name}
onSelect={() => s.selectRun(a.project_id, a.name)}
/>
))}
</div>
</div>
<div className="split-detail">
{selected ? <RunDetail /> : <RunEmpty />}
</div>
</div>
</div>
);
}
function RunRow({
agent,
selected,
onSelect,
}: {
agent: Agent;
selected: boolean;
onSelect: () => void;
}) {
return (
<button className={`run-row${selected ? " selected" : ""}`} onClick={onSelect}>
<div className="run-row-top">
<span className="run-project">{agent.project_id}</span>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
{timeAgo(agent.created_at)}
</span>
</div>
<div className="truncate muted" style={{ fontSize: "var(--text-sm)" }}>
{agent.name}
{agent.role ? ` · ${agent.role}` : ""}
</div>
<div className="hstack" style={{ gap: 8 }}>
<StatusBadge status={agent.status} />
</div>
</button>
);
}
function RunEmpty() {
return (
<div style={{ padding: "60px 32px", color: "var(--text-muted)" }}>
Select a run to see its checkmark, log, and any open question.
</div>
);
}
function RunDetail() {
const s = useDashboard();
const run = s.selectedRun!;
const agent = s.agents.find((a) => a.project_id === run.projectId && a.name === run.name);
const cm = s.checkmark;
const [answer, setAnswer] = useState("");
const [busy, setBusy] = useState(false);
const isPaused = agent?.status === "paused_for_input";
const doAnswer = async (resume: boolean) => {
if (!answer.trim()) return;
setBusy(true);
const ok = await s.submitAnswer(answer.trim(), resume);
setBusy(false);
if (ok) setAnswer("");
};
return (
<>
<div
style={{
padding: "24px 28px",
borderBottom: "1px solid var(--border-default)",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div className="hstack">
<span style={{ color: "var(--accent)", fontWeight: "var(--fw-bold)", fontSize: "var(--text-xl)" }}>
{run.projectId}
</span>
<span className="faint">/</span>
<span style={{ color: "var(--text-heading)", fontWeight: "var(--fw-semibold)", fontSize: "var(--text-lg)" }}>
{run.name}
</span>
<StatusBadge status={agent?.status} />
{agent?.role && <Badge tone="info">{agent.role}</Badge>}
<span className="spacer" />
<Button size="sm" variant="secondary" onClick={() => s.killAgent(run.projectId, run.name)}>
Kill
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteAgent(run.projectId, run.name)}>
Delete row
</Button>
</div>
<div className="mono faint" style={{ fontSize: "var(--text-xs)" }}>
{agent?.working_dir ?? "—"} · created {fmtFull(agent?.created_at)}
</div>
</div>
<div style={{ padding: "20px 28px", display: "flex", flexDirection: "column", gap: 16 }}>
{/* Checkmark */}
<div>
<div className="eyebrow" style={{ marginBottom: 10 }}>
Checkmark
</div>
{s.checkmarkMissing && <Callout tone="info">No checkpoint recorded yet.</Callout>}
{cm && !s.checkmarkMissing && (
<dl className="kv">
<dt>Status</dt>
<dd>
<StatusBadge status={cm.status} />
</dd>
<dt>Where it stopped</dt>
<dd>{cm.where_it_stopped || "—"}</dd>
<dt>Open question</dt>
<dd>{cm.open_question || "—"}</dd>
<dt>Next steps</dt>
<dd>
{cm.next_steps && cm.next_steps.length > 0 ? (
<ul>
{cm.next_steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ul>
) : (
"—"
)}
</dd>
<dt>Tests</dt>
<dd className="hstack">
<Badge tone={statusTone(cm.tests_status)}>{cm.tests_status}</Badge>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
{cm.tested_at ? fmtFull(cm.tested_at) : ""}
</span>
</dd>
<dt>Build</dt>
<dd className="hstack">
<Badge tone={statusTone(cm.build_status)}>{cm.build_status}</Badge>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
{cm.built_at ? fmtFull(cm.built_at) : ""}
</span>
</dd>
<dt>Checkpoint at</dt>
<dd className="faint">{fmtFull(cm.checkpoint_at)}</dd>
</dl>
)}
</div>
{/* Answer / resume */}
{isPaused && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div className="eyebrow">Answer this question</div>
<Callout tone="danger">{cm?.open_question || "(no question text on the checkmark)"}</Callout>
<Textarea value={answer} onChange={setAnswer} rows={3} placeholder="Your answer…" />
<div className="hstack">
<Button variant="secondary" disabled={busy || !answer.trim()} onClick={() => doAnswer(false)}>
Answer
</Button>
<Button variant="primary" disabled={busy || !answer.trim()} onClick={() => doAnswer(true)}>
Answer &amp; Resume
</Button>
</div>
</div>
)}
{/* Log */}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div className="eyebrow">Log · newest first</div>
{s.log.length === 0 ? (
<div className="empty">No log entries.</div>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>When</th>
<th>Status</th>
<th>Summary</th>
<th>Q / A</th>
<th>Push</th>
<th>CI</th>
</tr>
</thead>
<tbody>
{s.log.map((e) => (
<tr key={e.id}>
<td className="faint nowrap">{fmtFull(e.created_at)}</td>
<td>
<StatusBadge status={e.status} />
</td>
<td>{e.summary || "—"}</td>
<td>
{e.question && (
<div>
<strong>Q:</strong> {e.question}
</div>
)}
{e.answer && (
<div>
<strong>A:</strong> {e.answer}
</div>
)}
{!e.question && !e.answer && "—"}
</td>
<td className="mono">{shortSha(e.push_sha)}</td>
<td>
<Badge tone={statusTone(e.ci_status)}>{e.ci_status}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="pager">
<Button size="sm" variant="ghost" disabled={s.logOffset === 0} onClick={() => s.pageLog(-1)}>
Newer
</Button>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
offset {s.logOffset}
</span>
<Button size="sm" variant="ghost" disabled={s.log.length < 100} onClick={() => s.pageLog(1)}>
Older
</Button>
</div>
</div>
</div>
</>
);
}