mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 11: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,102 @@
|
||||
/* The Control Center shell: a left nav (Runs / Repositories / Agents / Approvals / Git
|
||||
* Servers / Activity / Shared) and the active section on the right, matching the design's
|
||||
* hub layout. Command feedback and load errors surface as banners at the top of main. */
|
||||
"use client";
|
||||
|
||||
import { useDashboard, type Section } from "@/components/store";
|
||||
import { RunsSection } from "@/components/sections/RunsSection";
|
||||
import { RepositoriesSection } from "@/components/sections/RepositoriesSection";
|
||||
import { AgentsSection } from "@/components/sections/AgentsSection";
|
||||
import { ApprovalsSection } from "@/components/sections/ApprovalsSection";
|
||||
import { GitServersSection } from "@/components/sections/GitServersSection";
|
||||
import { ActivitySection } from "@/components/sections/ActivitySection";
|
||||
import { SharedSection } from "@/components/sections/SharedSection";
|
||||
|
||||
interface NavDef {
|
||||
key: Section;
|
||||
label: string;
|
||||
count: (s: ReturnType<typeof useDashboard>) => number;
|
||||
accent?: (s: ReturnType<typeof useDashboard>) => boolean;
|
||||
}
|
||||
|
||||
const NAV: NavDef[] = [
|
||||
{
|
||||
key: "runs",
|
||||
label: "Runs",
|
||||
count: (s) => s.agents.length,
|
||||
accent: (s) => s.agents.some((a) => a.status === "paused_for_input"),
|
||||
},
|
||||
{ key: "repositories", label: "Repositories", count: (s) => s.projects.length },
|
||||
{ key: "agents", label: "Agents", count: (s) => s.agents.length },
|
||||
{ key: "approvals", label: "Approvals", count: (s) => s.approvals.length },
|
||||
{ key: "servers", label: "Git Servers", count: (s) => s.hosts.length },
|
||||
{ key: "activity", label: "Activity", count: (s) => s.commands.length },
|
||||
{ key: "shared", label: "Shared", count: (s) => s.shared.context.length },
|
||||
];
|
||||
|
||||
export function Dashboard({ onSignOut }: { onSignOut: () => void }) {
|
||||
const s = useDashboard();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span className="logo" />
|
||||
Claude Monitor
|
||||
</div>
|
||||
{NAV.map((n) => {
|
||||
const c = n.count(s);
|
||||
const isAccent = n.accent?.(s) ?? false;
|
||||
return (
|
||||
<button
|
||||
key={n.key}
|
||||
className={`nav-item${s.section === n.key ? " active" : ""}`}
|
||||
onClick={() => s.setSection(n.key)}
|
||||
>
|
||||
<span>{n.label}</span>
|
||||
<span className="count" style={isAccent ? { color: "var(--lw-warning-fg)" } : undefined}>
|
||||
{c || ""}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="sidebar-spacer" />
|
||||
<div className="sidebar-foot">
|
||||
<button className="nav-item" onClick={s.refresh} title="Refresh now">
|
||||
<span>Refresh</span>
|
||||
<span className="count">↻</span>
|
||||
</button>
|
||||
<button className="nav-item" onClick={onSignOut} title="Sign out / change token">
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
{s.cmd.text && (
|
||||
<p className={`banner ${s.cmd.error ? "err" : "ok"}`} style={{ marginTop: 16 }}>
|
||||
{s.cmd.text}
|
||||
</p>
|
||||
)}
|
||||
{s.lastError && (
|
||||
<p className="banner err" style={{ marginTop: 12 }}>
|
||||
{s.lastError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{s.section === "runs" ? (
|
||||
<RunsSection />
|
||||
) : (
|
||||
<div className="main-scroll">
|
||||
{s.section === "repositories" && <RepositoriesSection />}
|
||||
{s.section === "agents" && <AgentsSection />}
|
||||
{s.section === "approvals" && <ApprovalsSection />}
|
||||
{s.section === "servers" && <GitServersSection />}
|
||||
{s.section === "activity" && <ActivitySection />}
|
||||
{s.section === "shared" && <SharedSection />}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/* Token gate: shown until an API token is supplied. Holds no data. Management actions
|
||||
* (spawn, approve, edit repos/servers) need the admin token; read-only views need the
|
||||
* plain auth token. The token lives only in localStorage on this device. */
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function TokenGate({ error, onSubmit }: { error?: string; onSubmit: (token: string) => void }) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const t = value.trim();
|
||||
if (t) onSubmit(t);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gate">
|
||||
<form className="gate-card" onSubmit={submit}>
|
||||
<div className="gate-brand">
|
||||
<span className="logo" style={{ width: 26, height: 26, borderRadius: 7 }} />
|
||||
Claude Monitor
|
||||
</div>
|
||||
<p className="muted" style={{ fontSize: "var(--text-sm)", margin: 0 }}>
|
||||
Paste your API token to continue. Management actions require the admin token; read-only
|
||||
views work with the plain auth token.
|
||||
</p>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="API token"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="callout callout-danger" style={{ margin: 0 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button className="btn btn-primary" type="submit">
|
||||
Continue
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
/* Dashboard state: one store owns the API client, the loaded data for every section,
|
||||
* the 5s polling loop, and all mutating actions. Control actions enqueue a command and
|
||||
* poll it to done/failed, surfacing the outcome in the command banner (`cmd`). */
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
AuthError,
|
||||
createClient,
|
||||
type Agent,
|
||||
type ApiError,
|
||||
type Approval,
|
||||
type Checkmark,
|
||||
type Command,
|
||||
type Host,
|
||||
type LogEntry,
|
||||
type Project,
|
||||
type SharedContext,
|
||||
} from "@/lib/api";
|
||||
|
||||
export type Section =
|
||||
| "runs"
|
||||
| "repositories"
|
||||
| "agents"
|
||||
| "approvals"
|
||||
| "servers"
|
||||
| "activity"
|
||||
| "shared";
|
||||
|
||||
export interface RunAgent extends Agent {}
|
||||
|
||||
export interface CmdState {
|
||||
text: string;
|
||||
error: boolean;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
const LOG_LIMIT = 100;
|
||||
const POLL_MS = 5000;
|
||||
|
||||
interface StoreValue {
|
||||
section: Section;
|
||||
setSection: (s: Section) => void;
|
||||
|
||||
projects: Project[];
|
||||
agents: RunAgent[]; // every agent across every project
|
||||
selectedProjectId: string;
|
||||
selectProject: (id: string) => void;
|
||||
|
||||
// Runs inbox
|
||||
selectedRun: { projectId: string; name: string } | null;
|
||||
selectRun: (projectId: string, name: string) => void;
|
||||
checkmark: Checkmark | null;
|
||||
checkmarkMissing: boolean;
|
||||
log: LogEntry[];
|
||||
logOffset: number;
|
||||
pageLog: (dir: 1 | -1) => void;
|
||||
|
||||
approvals: Approval[];
|
||||
hosts: Host[];
|
||||
commands: Command[];
|
||||
shared: { log: LogEntry[]; context: SharedContext[] };
|
||||
|
||||
cmd: CmdState;
|
||||
lastError: string;
|
||||
loading: boolean;
|
||||
|
||||
refresh: () => void;
|
||||
|
||||
// actions
|
||||
spawnAgent: (body: SpawnBody) => Promise<boolean>;
|
||||
killAgent: (projectId: string, name: string) => Promise<void>;
|
||||
deleteAgent: (projectId: string, name: string) => Promise<void>;
|
||||
submitAnswer: (answer: string, resume: boolean) => Promise<boolean>;
|
||||
createProject: (b: ProjectBody) => Promise<boolean>;
|
||||
updateProject: (id: string, b: Omit<ProjectBody, "id">) => Promise<boolean>;
|
||||
deleteProject: (id: string) => Promise<void>;
|
||||
submitApproval: (b: ApprovalBody) => Promise<void>;
|
||||
createHost: (b: HostBody) => Promise<boolean>;
|
||||
updateHost: (hostname: string, b: Omit<HostBody, "hostname">) => Promise<boolean>;
|
||||
deleteHost: (hostname: string) => Promise<void>;
|
||||
pollCi: () => Promise<void>;
|
||||
setSharedKey: (key: string, value: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface SpawnBody {
|
||||
name: string;
|
||||
role: string;
|
||||
placement: "worktree" | "subdir";
|
||||
worktree: string;
|
||||
subdir: string;
|
||||
task: string;
|
||||
}
|
||||
export interface ProjectBody {
|
||||
id: string;
|
||||
root_dir: string;
|
||||
git_remote: string;
|
||||
credential_ref: string;
|
||||
}
|
||||
export interface ApprovalBody {
|
||||
branch: string;
|
||||
status: string;
|
||||
agent_name: string;
|
||||
sha: string;
|
||||
note: string;
|
||||
}
|
||||
export interface HostBody {
|
||||
hostname: string;
|
||||
forge_type: string;
|
||||
token_env_var: string;
|
||||
base_url: string;
|
||||
}
|
||||
|
||||
const Ctx = createContext<StoreValue | null>(null);
|
||||
|
||||
export function useDashboard(): StoreValue {
|
||||
const v = useContext(Ctx);
|
||||
if (!v) throw new Error("useDashboard outside provider");
|
||||
return v;
|
||||
}
|
||||
|
||||
export function DashboardProvider({
|
||||
token,
|
||||
onUnauthorized,
|
||||
children,
|
||||
}: {
|
||||
token: string;
|
||||
onUnauthorized: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const client = useMemo(() => createClient(token, onUnauthorized), [token, onUnauthorized]);
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const [section, setSectionRaw] = useState<Section>("runs");
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [agents, setAgents] = useState<RunAgent[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
||||
const [selectedRun, setSelectedRun] = useState<{ projectId: string; name: string } | null>(null);
|
||||
const [checkmark, setCheckmark] = useState<Checkmark | null>(null);
|
||||
const [checkmarkMissing, setCheckmarkMissing] = useState(false);
|
||||
const [log, setLog] = useState<LogEntry[]>([]);
|
||||
const [logOffset, setLogOffset] = useState(0);
|
||||
const [approvals, setApprovals] = useState<Approval[]>([]);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [commands, setCommands] = useState<Command[]>([]);
|
||||
const [shared, setShared] = useState<{ log: LogEntry[]; context: SharedContext[] }>({
|
||||
log: [],
|
||||
context: [],
|
||||
});
|
||||
const [cmd, setCmd] = useState<CmdState>({ text: "", error: false, busy: false });
|
||||
const [lastError, setLastError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Keep polling loop reading fresh values without re-subscribing every render.
|
||||
const sectionRef = useRef(section);
|
||||
sectionRef.current = section;
|
||||
const selectedProjectRef = useRef(selectedProjectId);
|
||||
selectedProjectRef.current = selectedProjectId;
|
||||
const selectedRunRef = useRef(selectedRun);
|
||||
selectedRunRef.current = selectedRun;
|
||||
const logOffsetRef = useRef(logOffset);
|
||||
logOffsetRef.current = logOffset;
|
||||
|
||||
const swallow = (e: unknown) => {
|
||||
if (!(e instanceof AuthError)) setLastError((e as Error).message);
|
||||
};
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
try {
|
||||
const ps = await clientRef.current.api<Project[]>("/projects");
|
||||
setProjects(ps);
|
||||
setLastError("");
|
||||
setSelectedProjectId((cur) => cur || (ps[0]?.id ?? ""));
|
||||
} catch (e) {
|
||||
swallow(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAgents = useCallback(async (projectList: Project[]) => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
projectList.map((p) =>
|
||||
clientRef.current
|
||||
.api<Agent[]>(`/projects/${encodeURIComponent(p.id)}/agents`)
|
||||
.catch(() => [] as Agent[]),
|
||||
),
|
||||
);
|
||||
setAgents(results.flat());
|
||||
} catch (e) {
|
||||
swallow(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRun = useCallback(async (projectId: string, name: string) => {
|
||||
const path = `/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(name)}`;
|
||||
try {
|
||||
const cm = await clientRef.current.api<Checkmark>(`${path}/checkmark`);
|
||||
setCheckmark(cm);
|
||||
setCheckmarkMissing(false);
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return;
|
||||
if ((e as ApiError).status === 404) {
|
||||
setCheckmark(null);
|
||||
setCheckmarkMissing(true);
|
||||
} else swallow(e);
|
||||
}
|
||||
try {
|
||||
const entries = await clientRef.current.api<LogEntry[]>(
|
||||
`${path}/log?limit=${LOG_LIMIT}&offset=${logOffsetRef.current}`,
|
||||
);
|
||||
setLog(entries);
|
||||
} catch (e) {
|
||||
swallow(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadApprovals = useCallback(async (projectId: string) => {
|
||||
if (!projectId) {
|
||||
setApprovals([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setApprovals(
|
||||
await clientRef.current.api<Approval[]>(
|
||||
`/projects/${encodeURIComponent(projectId)}/approvals`,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
swallow(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadHosts = useCallback(async () => {
|
||||
try {
|
||||
setHosts(await clientRef.current.api<Host[]>("/hosts"));
|
||||
} catch (e) {
|
||||
swallow(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCommands = useCallback(async () => {
|
||||
try {
|
||||
setCommands(await clientRef.current.api<Command[]>("/commands?limit=50"));
|
||||
} catch (e) {
|
||||
swallow(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadShared = useCallback(async () => {
|
||||
try {
|
||||
const [logRows, context] = await Promise.all([
|
||||
clientRef.current.api<LogEntry[]>("/shared/log"),
|
||||
clientRef.current.api<SharedContext[]>("/shared/context"),
|
||||
]);
|
||||
setShared({ log: logRows, context });
|
||||
} catch (e) {
|
||||
swallow(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* One refresh cycle for whatever section is active (plus always-cheap projects/agents
|
||||
* so the nav counts and inbox stay live). */
|
||||
const tick = useCallback(async () => {
|
||||
const ps = await clientRef.current
|
||||
.api<Project[]>("/projects")
|
||||
.catch((e) => {
|
||||
swallow(e);
|
||||
return null;
|
||||
});
|
||||
if (ps) {
|
||||
setProjects(ps);
|
||||
setSelectedProjectId((cur) => cur || (ps[0]?.id ?? ""));
|
||||
await loadAgents(ps);
|
||||
}
|
||||
const s = sectionRef.current;
|
||||
const run = selectedRunRef.current;
|
||||
if (run) await loadRun(run.projectId, run.name);
|
||||
if (s === "approvals") await loadApprovals(selectedProjectRef.current);
|
||||
if (s === "servers") await loadHosts();
|
||||
if (s === "activity") await loadCommands();
|
||||
if (s === "shared") await loadShared();
|
||||
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadShared]);
|
||||
|
||||
// Initial load + polling loop. The first tick populates projects *and* agents (and the
|
||||
// active section) up front, so the Runs inbox is filled without waiting a poll interval.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
await tick();
|
||||
if (alive) setLoading(false);
|
||||
})();
|
||||
const id = setInterval(() => {
|
||||
if (!document.hidden) void tick();
|
||||
}, POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [tick]);
|
||||
|
||||
const setSection = useCallback(
|
||||
(s: Section) => {
|
||||
setSectionRaw(s);
|
||||
setCmd({ text: "", error: false, busy: false });
|
||||
if (s === "approvals") void loadApprovals(selectedProjectRef.current);
|
||||
if (s === "servers") void loadHosts();
|
||||
if (s === "activity") void loadCommands();
|
||||
if (s === "shared") void loadShared();
|
||||
},
|
||||
[loadApprovals, loadHosts, loadCommands, loadShared],
|
||||
);
|
||||
|
||||
const selectProject = useCallback(
|
||||
(id: string) => {
|
||||
setSelectedProjectId(id);
|
||||
if (sectionRef.current === "approvals") void loadApprovals(id);
|
||||
},
|
||||
[loadApprovals],
|
||||
);
|
||||
|
||||
const selectRun = useCallback(
|
||||
(projectId: string, name: string) => {
|
||||
setSelectedRun({ projectId, name });
|
||||
setLogOffset(0);
|
||||
logOffsetRef.current = 0;
|
||||
setCheckmark(null);
|
||||
setCheckmarkMissing(false);
|
||||
setLog([]);
|
||||
void loadRun(projectId, name);
|
||||
},
|
||||
[loadRun],
|
||||
);
|
||||
|
||||
const pageLog = useCallback(
|
||||
(dir: 1 | -1) => {
|
||||
const next = Math.max(0, logOffset + dir * LOG_LIMIT);
|
||||
if (next === logOffset) return;
|
||||
setLogOffset(next);
|
||||
logOffsetRef.current = next;
|
||||
const run = selectedRunRef.current;
|
||||
if (run) void loadRun(run.projectId, run.name);
|
||||
},
|
||||
[logOffset, loadRun],
|
||||
);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void tick();
|
||||
}, [tick]);
|
||||
|
||||
// ---- control actions (enqueue + track) ----
|
||||
const enqueueAndTrack = useCallback(
|
||||
async (path: string, body: unknown, label: string): Promise<Command | null> => {
|
||||
setCmd({ text: `${label}: queued…`, error: false, busy: true });
|
||||
try {
|
||||
const command = await clientRef.current.api<Command>(path, { method: "POST", body });
|
||||
const final = await clientRef.current.trackCommand(command.id);
|
||||
if (!final) {
|
||||
setCmd({
|
||||
text: `${label}: still running (see Activity). Is the worker up?`,
|
||||
error: false,
|
||||
busy: false,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const ok = final.status === "done";
|
||||
const detail = final.error || (final.result ? JSON.stringify(final.result) : "");
|
||||
setCmd({
|
||||
text: `${label} ${ok ? "done" : "failed"}${detail ? " — " + detail : ""}`,
|
||||
error: !ok,
|
||||
busy: false,
|
||||
});
|
||||
return final;
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return null;
|
||||
setCmd({ text: `${label} failed: ${(e as Error).message}`, error: true, busy: false });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const spawnAgent = useCallback(
|
||||
async (f: SpawnBody) => {
|
||||
const body: Record<string, unknown> = {
|
||||
name: f.name.trim(),
|
||||
role: f.role || null,
|
||||
task: f.task.trim() || null,
|
||||
};
|
||||
if (f.placement === "worktree" && f.worktree.trim()) body.worktree = f.worktree.trim();
|
||||
if (f.placement === "subdir" && f.subdir.trim()) body.subdir = f.subdir.trim();
|
||||
const p = encodeURIComponent(selectedProjectRef.current);
|
||||
const final = await enqueueAndTrack(`/projects/${p}/agents/spawn`, body, `spawn ${body.name}`);
|
||||
await loadAgents(projects);
|
||||
return final?.status === "done";
|
||||
},
|
||||
[enqueueAndTrack, loadAgents, projects],
|
||||
);
|
||||
|
||||
const killAgent = useCallback(
|
||||
async (projectId: string, name: string) => {
|
||||
const p = encodeURIComponent(projectId);
|
||||
await enqueueAndTrack(`/projects/${p}/agents/${encodeURIComponent(name)}/kill`, undefined, `kill ${name}`);
|
||||
await loadAgents(projects);
|
||||
},
|
||||
[enqueueAndTrack, loadAgents, projects],
|
||||
);
|
||||
|
||||
const deleteAgent = useCallback(
|
||||
async (projectId: string, name: string) => {
|
||||
const p = encodeURIComponent(projectId);
|
||||
try {
|
||||
await clientRef.current.api(`/projects/${p}/agents/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
setCmd({ text: `agent '${name}' row deleted`, error: false, busy: false });
|
||||
if (selectedRunRef.current?.name === name) setSelectedRun(null);
|
||||
await loadAgents(projects);
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return;
|
||||
setCmd({ text: (e as Error).message, error: true, busy: false });
|
||||
}
|
||||
},
|
||||
[loadAgents, projects],
|
||||
);
|
||||
|
||||
const submitAnswer = useCallback(
|
||||
async (answer: string, resume: boolean) => {
|
||||
const run = selectedRunRef.current;
|
||||
if (!run) return false;
|
||||
const path = `/projects/${encodeURIComponent(run.projectId)}/agents/${encodeURIComponent(run.name)}`;
|
||||
try {
|
||||
await clientRef.current.api(`${path}/answer`, { method: "POST", body: { answer } });
|
||||
if (resume) {
|
||||
await enqueueAndTrack(`${path}/resume`, { answer }, "resume");
|
||||
} else {
|
||||
setCmd({ text: "Answer saved (agent still paused).", error: false, busy: false });
|
||||
}
|
||||
await loadAgents(projects);
|
||||
await loadRun(run.projectId, run.name);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return false;
|
||||
setCmd({ text: (e as Error).message, error: true, busy: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[enqueueAndTrack, loadAgents, loadRun, projects],
|
||||
);
|
||||
|
||||
const createProject = useCallback(
|
||||
async (b: ProjectBody) => {
|
||||
try {
|
||||
await clientRef.current.api("/projects", {
|
||||
method: "POST",
|
||||
body: {
|
||||
id: b.id.trim(),
|
||||
root_dir: b.root_dir.trim(),
|
||||
git_remote: b.git_remote.trim() || null,
|
||||
credential_ref: b.credential_ref.trim() || null,
|
||||
},
|
||||
});
|
||||
setCmd({ text: `repository '${b.id}' registered`, error: false, busy: false });
|
||||
await loadProjects();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return false;
|
||||
setCmd({ text: (e as Error).message, error: true, busy: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[loadProjects],
|
||||
);
|
||||
|
||||
const updateProject = useCallback(
|
||||
async (id: string, b: Omit<ProjectBody, "id">) => {
|
||||
try {
|
||||
await clientRef.current.api(`/projects/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
root_dir: b.root_dir.trim(),
|
||||
git_remote: b.git_remote.trim() || null,
|
||||
credential_ref: b.credential_ref.trim() || null,
|
||||
},
|
||||
});
|
||||
setCmd({ text: `repository '${id}' updated`, error: false, busy: false });
|
||||
await loadProjects();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return false;
|
||||
setCmd({ text: (e as Error).message, error: true, busy: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[loadProjects],
|
||||
);
|
||||
|
||||
const deleteProject = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await clientRef.current.api(`/projects/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
setCmd({ text: `repository '${id}' removed`, error: false, busy: false });
|
||||
setSelectedProjectId((cur) => (cur === id ? "" : cur));
|
||||
await loadProjects();
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return;
|
||||
setCmd({ text: (e as Error).message, error: true, busy: false });
|
||||
}
|
||||
},
|
||||
[loadProjects],
|
||||
);
|
||||
|
||||
const submitApproval = useCallback(
|
||||
async (b: ApprovalBody) => {
|
||||
const p = encodeURIComponent(selectedProjectRef.current);
|
||||
await enqueueAndTrack(
|
||||
`/projects/${p}/approvals`,
|
||||
{
|
||||
branch: b.branch.trim(),
|
||||
status: b.status,
|
||||
agent_name: b.agent_name.trim() || null,
|
||||
sha: b.sha.trim() || null,
|
||||
note: b.note.trim() || null,
|
||||
},
|
||||
`${b.status} ${b.branch}`,
|
||||
);
|
||||
await loadApprovals(selectedProjectRef.current);
|
||||
},
|
||||
[enqueueAndTrack, loadApprovals],
|
||||
);
|
||||
|
||||
const createHost = useCallback(
|
||||
async (b: HostBody) => {
|
||||
try {
|
||||
await clientRef.current.api("/hosts", {
|
||||
method: "POST",
|
||||
body: {
|
||||
hostname: b.hostname.trim(),
|
||||
forge_type: b.forge_type,
|
||||
token_env_var: b.token_env_var.trim() || null,
|
||||
base_url: b.base_url.trim() || null,
|
||||
},
|
||||
});
|
||||
setCmd({ text: `git server '${b.hostname}' added`, error: false, busy: false });
|
||||
await loadHosts();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return false;
|
||||
setCmd({ text: (e as Error).message, error: true, busy: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[loadHosts],
|
||||
);
|
||||
|
||||
const updateHost = useCallback(
|
||||
async (hostname: string, b: Omit<HostBody, "hostname">) => {
|
||||
try {
|
||||
await clientRef.current.api(`/hosts/${encodeURIComponent(hostname)}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
forge_type: b.forge_type,
|
||||
token_env_var: b.token_env_var.trim() || null,
|
||||
base_url: b.base_url.trim() || null,
|
||||
},
|
||||
});
|
||||
setCmd({ text: `git server '${hostname}' updated`, error: false, busy: false });
|
||||
await loadHosts();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return false;
|
||||
setCmd({ text: (e as Error).message, error: true, busy: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[loadHosts],
|
||||
);
|
||||
|
||||
const deleteHost = useCallback(
|
||||
async (hostname: string) => {
|
||||
try {
|
||||
await clientRef.current.api(`/hosts/${encodeURIComponent(hostname)}`, { method: "DELETE" });
|
||||
setCmd({ text: `git server '${hostname}' removed`, error: false, busy: false });
|
||||
await loadHosts();
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return;
|
||||
setCmd({ text: (e as Error).message, error: true, busy: false });
|
||||
}
|
||||
},
|
||||
[loadHosts],
|
||||
);
|
||||
|
||||
const pollCi = useCallback(async () => {
|
||||
await enqueueAndTrack("/poll-ci", undefined, "poll-ci (all projects)");
|
||||
await loadCommands();
|
||||
}, [enqueueAndTrack, loadCommands]);
|
||||
|
||||
const setSharedKey = useCallback(
|
||||
async (key: string, value: string) => {
|
||||
try {
|
||||
await clientRef.current.api(`/shared/context/${encodeURIComponent(key)}`, {
|
||||
method: "PUT",
|
||||
body: { value },
|
||||
});
|
||||
setCmd({ text: `shared context '${key}' set`, error: false, busy: false });
|
||||
await loadShared();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return false;
|
||||
setCmd({ text: (e as Error).message, error: true, busy: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[loadShared],
|
||||
);
|
||||
|
||||
const value: StoreValue = {
|
||||
section,
|
||||
setSection,
|
||||
projects,
|
||||
agents,
|
||||
selectedProjectId,
|
||||
selectProject,
|
||||
selectedRun,
|
||||
selectRun,
|
||||
checkmark,
|
||||
checkmarkMissing,
|
||||
log,
|
||||
logOffset,
|
||||
pageLog,
|
||||
approvals,
|
||||
hosts,
|
||||
commands,
|
||||
shared,
|
||||
cmd,
|
||||
lastError,
|
||||
loading,
|
||||
refresh,
|
||||
spawnAgent,
|
||||
killAgent,
|
||||
deleteAgent,
|
||||
submitAnswer,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
submitApproval,
|
||||
createHost,
|
||||
updateHost,
|
||||
deleteHost,
|
||||
pollCi,
|
||||
setSharedKey,
|
||||
};
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/* Design-system primitives ported from the Leeworks kit: flat, dark, border-led.
|
||||
* Every value rendered here comes from the API and is placed via React children /
|
||||
* textContent — never dangerouslySetInnerHTML — so agent-authored strings stay inert. */
|
||||
"use client";
|
||||
|
||||
import type { ReactNode, ChangeEvent } from "react";
|
||||
import type { Tone } from "@/lib/format";
|
||||
import { statusLabel, statusTone } from "@/lib/format";
|
||||
|
||||
export function Badge({
|
||||
tone = "neutral",
|
||||
pill = false,
|
||||
dot = false,
|
||||
children,
|
||||
}: {
|
||||
tone?: Tone;
|
||||
pill?: boolean;
|
||||
dot?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className={`badge badge-${tone}${pill ? " pill" : ""}`}>
|
||||
{dot && <span className="dot" />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Status badge that maps a raw handler status string to a tone + tidy label. */
|
||||
export function StatusBadge({ status }: { status: string | null | undefined }) {
|
||||
return <Badge tone={statusTone(status)}>{statusLabel(status)}</Badge>;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
interactive = false,
|
||||
onClick,
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
interactive?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`card${interactive ? " interactive" : ""} ${className}`.trim()}
|
||||
onClick={onClick}
|
||||
role={interactive ? "button" : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
export function Button({
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
onClick,
|
||||
disabled,
|
||||
type = "button",
|
||||
children,
|
||||
}: {
|
||||
variant?: ButtonVariant;
|
||||
size?: "md" | "sm";
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
type?: "button" | "submit";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={`btn btn-${variant}${size === "sm" ? " btn-sm" : ""}`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ label, children }: { label?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="field">
|
||||
{label && <span className="field-label">{label}</span>}
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
disabled,
|
||||
}: {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<input
|
||||
className="input"
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 3,
|
||||
}: {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={value}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<select
|
||||
className="select"
|
||||
value={value}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => onChange(e.target.value)}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tabs({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
tabs: { value: string; label: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="tabs" role="tablist">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
role="tab"
|
||||
aria-selected={value === t.value}
|
||||
className={`tab${value === t.value ? " active" : ""}`}
|
||||
onClick={() => onChange(t.value)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stat({
|
||||
value,
|
||||
label,
|
||||
sub,
|
||||
accent = false,
|
||||
}: {
|
||||
value: ReactNode;
|
||||
label: string;
|
||||
sub?: string;
|
||||
accent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className={`stat-value${accent ? " accent" : ""}`}>{value}</div>
|
||||
<div className="stat-label">{label}</div>
|
||||
{sub && <div className="stat-sub">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Callout({
|
||||
tone = "info",
|
||||
children,
|
||||
}: {
|
||||
tone?: "info" | "danger" | "success";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <div className={`callout callout-${tone}`}>{children}</div>;
|
||||
}
|
||||
|
||||
/** Renders a unified-diff / patch, tinting +/- lines. Content is textContent. */
|
||||
export function CodeBlock({
|
||||
code,
|
||||
language,
|
||||
title,
|
||||
}: {
|
||||
code: string;
|
||||
language?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const lines = code.split("\n");
|
||||
return (
|
||||
<div className="codeblock">
|
||||
{(title || language) && (
|
||||
<div className="codeblock-head">
|
||||
<span>{title ?? ""}</span>
|
||||
<span>{language ?? ""}</span>
|
||||
</div>
|
||||
)}
|
||||
<pre>
|
||||
{lines.map((line, i) => {
|
||||
const cls = line.startsWith("+")
|
||||
? "diff-add"
|
||||
: line.startsWith("-")
|
||||
? "diff-del"
|
||||
: undefined;
|
||||
return (
|
||||
<span key={i} className={cls}>
|
||||
{line}
|
||||
{i < lines.length - 1 ? "\n" : ""}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle${on ? " on" : ""}`}
|
||||
aria-pressed={on}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="knob" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user