Files
Wyatt 7399315185 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>
2026-07-10 14:34:43 -04:00

116 lines
4.3 KiB
TypeScript

/* Approvals — record a per-branch verdict (the review gate). A verdict is enqueued as a
* control command so the worker can read the reviewed HEAD and pin the approval. */
"use client";
import { useMemo, useState } from "react";
import { useDashboard } from "@/components/store";
import { Badge, Button, Card, Input, Select, StatusBadge } from "@/components/ui";
import { fmtFull, shortSha } from "@/lib/format";
const STATUS_OPTS = [
{ value: "approved", label: "approve" },
{ value: "rejected", label: "reject" },
];
const empty = { branch: "", status: "approved", agent_name: "", sha: "", note: "" };
export function ApprovalsSection() {
const s = useDashboard();
const [form, setForm] = useState(empty);
const projectOpts = useMemo(
() => s.projects.map((p) => ({ value: p.id, label: p.id })),
[s.projects],
);
const submit = async () => {
await s.submitApproval(form);
setForm(empty);
};
return (
<>
<div className="section-head">
<div className="section-title">Approvals</div>
<div className="section-desc">
A merge is denied unless a standing approval exists made by a different agent, pinned to
the reviewed commit.
</div>
</div>
<div className="section-body">
{s.projects.length === 0 ? (
<div className="empty">Register a repository first.</div>
) : (
<>
<div className="row">
<div style={{ width: 260 }}>
<Select
label="Repository"
value={s.selectedProjectId}
onChange={s.selectProject}
options={projectOpts}
/>
</div>
</div>
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
Record a verdict
</span>
</div>
<div className="form-grid">
<Input label="Branch" value={form.branch} onChange={(v) => setForm({ ...form, branch: v })} placeholder="feat/auth" />
<Select label="Verdict" value={form.status} onChange={(v) => setForm({ ...form, status: v })} options={STATUS_OPTS} />
<Input label="Agent" value={form.agent_name} onChange={(v) => setForm({ ...form, agent_name: v })} placeholder="reads its HEAD (optional)" />
<Input label="SHA" value={form.sha} onChange={(v) => setForm({ ...form, sha: v })} placeholder="pins the approval (optional)" />
</div>
<div className="mt14">
<Input label="Note" value={form.note} onChange={(v) => setForm({ ...form, note: v })} placeholder="optional" />
</div>
<div className="hstack mt14">
<Button variant="primary" disabled={s.cmd.busy || !form.branch.trim()} onClick={submit}>
Enqueue verdict
</Button>
</div>
</Card>
{s.approvals.length === 0 ? (
<div className="empty">No approvals recorded.</div>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>When</th>
<th>Branch</th>
<th>Verdict</th>
<th>By</th>
<th>SHA</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{s.approvals.map((ap) => (
<tr key={ap.id}>
<td className="faint nowrap">{fmtFull(ap.created_at)}</td>
<td className="mono">{ap.branch}</td>
<td>
<StatusBadge status={ap.status} />
</td>
<td>{ap.approved_by_agent_id ? `agent ${ap.approved_by_agent_id}` : ap.actor || "—"}</td>
<td className="mono">{shortSha(ap.approved_sha)}</td>
<td>{ap.note || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</>
);
}