mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 10:06:24 +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,63 @@
|
||||
/* Activity — the control-command queue: every enqueued action and its status
|
||||
* (queued → running → done/failed). The audit log of what the dashboard triggered. */
|
||||
"use client";
|
||||
|
||||
import { useDashboard } from "@/components/store";
|
||||
import { Button, StatusBadge } from "@/components/ui";
|
||||
import { fmtFull } from "@/lib/format";
|
||||
|
||||
export function ActivitySection() {
|
||||
const s = useDashboard();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-head">
|
||||
<div className="hstack" style={{ justifyContent: "space-between" }}>
|
||||
<div>
|
||||
<div className="section-title">Activity</div>
|
||||
<div className="section-desc">Control commands the worker drains from the queue.</div>
|
||||
</div>
|
||||
<Button variant="secondary" disabled={s.cmd.busy} onClick={() => s.pollCi()}>
|
||||
Sweep CI now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-body">
|
||||
{s.commands.length === 0 ? (
|
||||
<div className="empty">No commands yet.</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Type</th>
|
||||
<th>Repository</th>
|
||||
<th>Agent</th>
|
||||
<th>Status</th>
|
||||
<th>Result / Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.commands.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td className="faint nowrap">{fmtFull(c.created_at)}</td>
|
||||
<td className="mono">{c.type}</td>
|
||||
<td className="mono">{c.project_id || "—"}</td>
|
||||
<td className="mono">{c.agent_name || "—"}</td>
|
||||
<td>
|
||||
<StatusBadge status={c.status} />
|
||||
</td>
|
||||
<td className="mono faint" style={{ fontSize: "var(--text-xs)" }}>
|
||||
{c.error || (c.result ? JSON.stringify(c.result) : "—")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/* Agents — spawn a new agent into a repository and manage the ones already running.
|
||||
* Spawning enqueues a control command that the worker turns into a tmux + claude process. */
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDashboard } from "@/components/store";
|
||||
import { Badge, Button, Card, Input, Select, StatusBadge, Textarea } from "@/components/ui";
|
||||
import { fmtFull } from "@/lib/format";
|
||||
|
||||
const ROLE_OPTS = [
|
||||
{ value: "", label: "Role — none" },
|
||||
{ value: "junior", label: "junior" },
|
||||
{ value: "senior", label: "senior" },
|
||||
{ value: "deploy", label: "deploy" },
|
||||
];
|
||||
const PLACEMENT_OPTS = [
|
||||
{ value: "worktree", label: "git worktree on branch" },
|
||||
{ value: "subdir", label: "subdir under root" },
|
||||
];
|
||||
|
||||
const emptySpawn = {
|
||||
name: "",
|
||||
role: "",
|
||||
placement: "worktree" as "worktree" | "subdir",
|
||||
worktree: "",
|
||||
subdir: "",
|
||||
task: "",
|
||||
};
|
||||
|
||||
export function AgentsSection() {
|
||||
const s = useDashboard();
|
||||
const [form, setForm] = useState(emptySpawn);
|
||||
|
||||
const projectOpts = useMemo(
|
||||
() => s.projects.map((p) => ({ value: p.id, label: p.id })),
|
||||
[s.projects],
|
||||
);
|
||||
const agents = useMemo(
|
||||
() => s.agents.filter((a) => a.project_id === s.selectedProjectId),
|
||||
[s.agents, s.selectedProjectId],
|
||||
);
|
||||
|
||||
const spawn = async () => {
|
||||
const ok = await s.spawnAgent(form);
|
||||
if (ok) setForm(emptySpawn);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-head">
|
||||
<div className="section-title">Agents</div>
|
||||
<div className="section-desc">Spawn agents into a repository and manage running sessions.</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)" }}>
|
||||
Spawn an agent
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<Input label="Name" value={form.name} onChange={(v) => setForm({ ...form, name: v })} placeholder="junior" />
|
||||
<Select label="Role" value={form.role} onChange={(v) => setForm({ ...form, role: v })} options={ROLE_OPTS} />
|
||||
<Select
|
||||
label="Placement"
|
||||
value={form.placement}
|
||||
onChange={(v) => setForm({ ...form, placement: v as "worktree" | "subdir" })}
|
||||
options={PLACEMENT_OPTS}
|
||||
/>
|
||||
{form.placement === "worktree" ? (
|
||||
<Input label="Branch" value={form.worktree} onChange={(v) => setForm({ ...form, worktree: v })} placeholder="feat/auth" />
|
||||
) : (
|
||||
<Input label="Subdir" value={form.subdir} onChange={(v) => setForm({ ...form, subdir: v })} placeholder="api" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt14">
|
||||
<Textarea
|
||||
label="Initial task"
|
||||
value={form.task}
|
||||
onChange={(v) => setForm({ ...form, task: v })}
|
||||
rows={2}
|
||||
placeholder="initial task / prompt (optional)"
|
||||
/>
|
||||
</div>
|
||||
<div className="hstack mt14">
|
||||
<Button variant="primary" disabled={s.cmd.busy || !form.name.trim()} onClick={spawn}>
|
||||
Spawn
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{agents.length === 0 ? (
|
||||
<div className="empty">No agents in this repository.</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Working dir</th>
|
||||
<th>Created</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agents.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td className="mono">{a.name}</td>
|
||||
<td>{a.role ? <Badge tone="info">{a.role}</Badge> : "—"}</td>
|
||||
<td>
|
||||
<StatusBadge status={a.status} />
|
||||
</td>
|
||||
<td className="mono faint">{a.working_dir}</td>
|
||||
<td className="faint nowrap">{fmtFull(a.created_at)}</td>
|
||||
<td className="nowrap">
|
||||
<div className="hstack">
|
||||
<Button size="sm" variant="ghost" onClick={() => s.selectRun(a.project_id, a.name)}>
|
||||
Open
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => s.killAgent(a.project_id, a.name)}>
|
||||
Kill
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => s.deleteAgent(a.project_id, a.name)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/* 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/* Git Servers — the forge host registry. Each row maps a host to the token env var to
|
||||
* inject at spawn (and the credential-helper scope). Holds no secrets, only the var name. */
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useDashboard } from "@/components/store";
|
||||
import { Badge, Button, Card, Input, Select } from "@/components/ui";
|
||||
import type { Host } from "@/lib/api";
|
||||
|
||||
const FORGE_OPTS = [
|
||||
{ value: "github", label: "github" },
|
||||
{ value: "gitlab", label: "gitlab" },
|
||||
{ value: "gitea", label: "gitea" },
|
||||
{ value: "forgejo", label: "forgejo" },
|
||||
{ value: "bitbucket", label: "bitbucket" },
|
||||
];
|
||||
|
||||
const empty = { hostname: "", forge_type: "github", token_env_var: "", base_url: "" };
|
||||
|
||||
export function GitServersSection() {
|
||||
const s = useDashboard();
|
||||
const [form, setForm] = useState(empty);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setForm(empty);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const ok = editing ? await s.updateHost(form.hostname, form) : await s.createHost(form);
|
||||
if (ok) reset();
|
||||
};
|
||||
|
||||
const edit = (h: Host) => {
|
||||
setForm({
|
||||
hostname: h.hostname,
|
||||
forge_type: h.forge_type,
|
||||
token_env_var: h.token_env_var ?? "",
|
||||
base_url: h.base_url ?? "",
|
||||
});
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-head">
|
||||
<div className="section-title">Git Servers</div>
|
||||
<div className="section-desc">
|
||||
Maps a git host to the token env var injected at spawn. The built-in host map is the
|
||||
fallback when no row matches.
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-body">
|
||||
<Card>
|
||||
<div className="card-head" style={{ marginBottom: 14 }}>
|
||||
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
|
||||
{editing ? `Edit server · ${form.hostname}` : "Add a git server"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<Input
|
||||
label="Hostname"
|
||||
value={form.hostname}
|
||||
onChange={(v) => setForm({ ...form, hostname: v })}
|
||||
placeholder="git.corp.internal"
|
||||
disabled={editing}
|
||||
/>
|
||||
<Select
|
||||
label="Type"
|
||||
value={form.forge_type}
|
||||
onChange={(v) => setForm({ ...form, forge_type: v })}
|
||||
options={FORGE_OPTS}
|
||||
/>
|
||||
<Input
|
||||
label="Token env var"
|
||||
value={form.token_env_var}
|
||||
onChange={(v) => setForm({ ...form, token_env_var: v })}
|
||||
placeholder="GITEA_TOKEN"
|
||||
/>
|
||||
<Input
|
||||
label="Base URL"
|
||||
value={form.base_url}
|
||||
onChange={(v) => setForm({ ...form, base_url: v })}
|
||||
placeholder="https://git.corp.internal (optional)"
|
||||
/>
|
||||
</div>
|
||||
<div className="hstack mt14">
|
||||
<Button variant="primary" disabled={s.cmd.busy || !form.hostname.trim()} onClick={save}>
|
||||
{editing ? "Save changes" : "Add server"}
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button variant="ghost" onClick={reset}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{s.hosts.length === 0 && (
|
||||
<div className="empty">No git servers registered (built-in host map still applies).</div>
|
||||
)}
|
||||
|
||||
{s.hosts.map((h) => (
|
||||
<Card key={h.hostname}>
|
||||
<div className="card-head">
|
||||
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
|
||||
{h.hostname}
|
||||
</span>
|
||||
<div className="hstack">
|
||||
<Badge tone="info">{h.forge_type}</Badge>
|
||||
<Button size="sm" variant="secondary" onClick={() => edit(h)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => s.deleteHost(h.hostname)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
|
||||
token env {h.token_env_var || "—"}
|
||||
{h.base_url ? ` · ${h.base_url}` : ""}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/* Repositories — register / edit / remove the projects (repos) Handler manages.
|
||||
* Maps the design's "Repositories" pane to Handler's project registry. */
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDashboard } from "@/components/store";
|
||||
import { Badge, Button, Card, Input } from "@/components/ui";
|
||||
import { fmtFull } from "@/lib/format";
|
||||
import type { Project } from "@/lib/api";
|
||||
|
||||
const CRED_HELP = "credential_ref is a pointer, never the token (env: / file: / db:). cmd: is CLI-only.";
|
||||
|
||||
const empty = { id: "", root_dir: "", git_remote: "", credential_ref: "" };
|
||||
|
||||
export function RepositoriesSection() {
|
||||
const s = useDashboard();
|
||||
const [form, setForm] = useState(empty);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const agentCount = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const a of s.agents) m.set(a.project_id, (m.get(a.project_id) ?? 0) + 1);
|
||||
return m;
|
||||
}, [s.agents]);
|
||||
|
||||
const reset = () => {
|
||||
setForm(empty);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const ok = editing
|
||||
? await s.updateProject(form.id, form)
|
||||
: await s.createProject(form);
|
||||
if (ok) reset();
|
||||
};
|
||||
|
||||
const edit = (p: Project) => {
|
||||
setForm({
|
||||
id: p.id,
|
||||
root_dir: p.root_dir,
|
||||
git_remote: p.git_remote ?? "",
|
||||
credential_ref: p.credential_ref ?? "",
|
||||
});
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-head">
|
||||
<div className="section-title">Repositories</div>
|
||||
<div className="section-desc">
|
||||
Repos Handler manages. Each carries its own agents, history, and credentials.
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-body">
|
||||
<Card>
|
||||
<div className="card-head" style={{ marginBottom: 14 }}>
|
||||
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
|
||||
{editing ? `Edit repository · ${form.id}` : "Register a repository"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<Input
|
||||
label="ID / slug"
|
||||
value={form.id}
|
||||
onChange={(v) => setForm({ ...form, id: v })}
|
||||
placeholder="leeworks-api"
|
||||
disabled={editing}
|
||||
/>
|
||||
<Input
|
||||
label="Root dir"
|
||||
value={form.root_dir}
|
||||
onChange={(v) => setForm({ ...form, root_dir: v })}
|
||||
placeholder="/var/lib/handler/projects/leeworks"
|
||||
/>
|
||||
<Input
|
||||
label="Git remote"
|
||||
value={form.git_remote}
|
||||
onChange={(v) => setForm({ ...form, git_remote: v })}
|
||||
placeholder="git@github.com:user/repo.git (optional)"
|
||||
/>
|
||||
<Input
|
||||
label="Credential ref"
|
||||
value={form.credential_ref}
|
||||
onChange={(v) => setForm({ ...form, credential_ref: v })}
|
||||
placeholder="env:VAR / file:/path / db:id"
|
||||
/>
|
||||
</div>
|
||||
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
|
||||
{CRED_HELP}
|
||||
</p>
|
||||
<div className="hstack mt14">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={s.cmd.busy || !form.id.trim() || !form.root_dir.trim()}
|
||||
onClick={save}
|
||||
>
|
||||
{editing ? "Save changes" : "Register"}
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button variant="ghost" onClick={reset}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{s.projects.length === 0 && <div className="empty">No repositories registered.</div>}
|
||||
|
||||
{s.projects.map((p) => (
|
||||
<Card key={p.id}>
|
||||
<div className="card-head">
|
||||
<span className="card-title">{p.id}</span>
|
||||
<Badge tone="info" pill>
|
||||
{agentCount.get(p.id) ?? 0} {(agentCount.get(p.id) ?? 0) === 1 ? "agent" : "agents"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 4 }}>
|
||||
{p.root_dir}
|
||||
{p.git_remote ? ` · ${p.git_remote}` : ""}
|
||||
</div>
|
||||
<div className="hstack" style={{ marginTop: 12, justifyContent: "space-between" }}>
|
||||
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
|
||||
cred {p.credential_ref || "—"} · added {fmtFull(p.created_at)}
|
||||
</span>
|
||||
<div className="hstack">
|
||||
<Button size="sm" variant="secondary" onClick={() => edit(p)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => s.deleteProject(p.id)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/* Shared — the cross-project global feed and the shared key/value context store.
|
||||
* Writing a context key needs the higher-trust shared-write token. */
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useDashboard } from "@/components/store";
|
||||
import { Button, Card, Input, StatusBadge } from "@/components/ui";
|
||||
import { fmtFull } from "@/lib/format";
|
||||
|
||||
export function SharedSection() {
|
||||
const s = useDashboard();
|
||||
const [key, setKey] = useState("");
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const set = async () => {
|
||||
if (!key.trim() || !value.trim()) return;
|
||||
const ok = await s.setSharedKey(key.trim(), value.trim());
|
||||
if (ok) {
|
||||
setKey("");
|
||||
setValue("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-head">
|
||||
<div className="section-title">Shared</div>
|
||||
<div className="section-desc">The cross-project global feed and shared facts.</div>
|
||||
</div>
|
||||
<div className="section-body">
|
||||
<div>
|
||||
<div className="eyebrow" style={{ marginBottom: 10 }}>
|
||||
Global feed
|
||||
</div>
|
||||
{s.shared.log.length === 0 ? (
|
||||
<div className="empty">No global log entries.</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Agent</th>
|
||||
<th>Status</th>
|
||||
<th>Summary</th>
|
||||
<th>CI</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.shared.log.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td className="faint nowrap">{fmtFull(e.created_at)}</td>
|
||||
<td className="mono">{e.agent_id}</td>
|
||||
<td>
|
||||
<StatusBadge status={e.status} />
|
||||
</td>
|
||||
<td>{e.summary || "—"}</td>
|
||||
<td>
|
||||
<StatusBadge status={e.ci_status} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="card-head" style={{ marginBottom: 14 }}>
|
||||
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
|
||||
Set a shared key
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<Input label="Key" value={key} onChange={setKey} placeholder="key" />
|
||||
<Input label="Value" value={value} onChange={setValue} placeholder="value" />
|
||||
</div>
|
||||
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
|
||||
Requires the shared-context write token (or admin/global if unset).
|
||||
</p>
|
||||
<div className="hstack mt14">
|
||||
<Button variant="primary" disabled={!key.trim() || !value.trim()} onClick={set}>
|
||||
Set
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{s.shared.context.length > 0 && (
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.shared.context.map((c) => (
|
||||
<tr key={c.key}>
|
||||
<td className="mono">{c.key}</td>
|
||||
<td>{c.value}</td>
|
||||
<td className="faint nowrap">{fmtFull(c.updated_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user