mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-05 14:46:25 +00:00
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:
@@ -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 & 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user