diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 71258581..dbd46c47 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -16,6 +16,7 @@ domain, covers host-level detail for one hive. | `/jobs` | the controller's job graph — where agent creation and credential mints show progress | | `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/issues` | a cross-repo issue report | +| `/review-runs` | the review classifier's recent runs: which PRs, what it answered, which failed | | `/review-tryout` | try a review classification against a real PR | Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). @@ -53,6 +54,33 @@ page. Selecting outside it, or Escape, closes it. You can stop or pause the agent, or link an account, without leaving the terminal. A subagent's tab opens its agent's details. +### Review runs + +`/review-runs` lists what the [review classifier](review-classifier.md) has +run, newest first, one card per run. A card shows the repo and PR, the head +commit, whether the run was a try-out or a requested review, how long ago it +started, how long it took, how many classifications ran, and the outcome as a +badge. Select a card to open it: it shows the same result table as the +[try-out](#review-try-out), the overall verdict, the reason when the outcome +has one, and a link to the posted review when there is one. + +An outcome can be more than approve or request changes: + +| badge | what happened | +| ------------------- | -------------------------------------------------- | +| `approved` | the run posted an approval | +| `changes requested` | the run posted a request for changes | +| `comment only` | there was no clean verdict, so it posted a comment | +| `queue full` | the queue was full, so it turned the run away | +| `post failed` | the run finished but posting the review failed | +| `done` | a try-out finished | +| `failed` | the run failed; the card says why | + +The page refreshes every 30 seconds, and a card you opened stays open when it +does. An empty list says there are no runs yet, and a failed load shows the reason the +controller gave. The page shows no token counts, cache counts or cost, and +stores and posts nothing. + ### Review try-out `/review-tryout` shows how the [review classifier](review-classifier.md) diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 44493a1e..d6432f10 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -8,6 +8,7 @@ import { ComponentsPage } from "./pages/ComponentsPage.js"; import { JobsPage } from "./pages/JobsPage.js"; import { HivesPage } from "./pages/HivesPage.js"; import { IssueReportPage } from "./pages/IssueReportPage.js"; +import { ReviewRunsPage } from "./pages/review-runs/ReviewRunsPage.js"; import { ReviewTryOutPage } from "./pages/review-tryout/ReviewTryOutPage.js"; import { SchedulesPage } from "./pages/schedules/SchedulesPage.js"; import { Panel } from "./ui/panel/Panel.js"; @@ -34,6 +35,7 @@ export function App() { + diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.css b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.css new file mode 100644 index 00000000..75e49738 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.css @@ -0,0 +1,47 @@ +/* The run list: one card per run, the head row a button that opens it. */ +.ui-runs-list { + display: flex; + flex-direction: column; + gap: 0.75em; + margin-top: 1em; +} +.ui-runs-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5em 0.75em; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.ui-runs-head:focus-visible { + outline: 2px solid var(--purple); + outline-offset: 2px; +} +.ui-runs-title { + display: inline-flex; + align-items: baseline; + gap: 0.5em; +} +.ui-runs-meta { + color: var(--muted); +} +.ui-runs-head .ui-runs-meta { + flex-basis: 100%; +} +.ui-runs-body { + display: flex; + flex-direction: column; + gap: 0.6em; + margin-top: 0.5em; +} +.ui-runs-body p { + margin: 0; +} +.ui-runs-empty { + color: var(--muted); +} diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.tsx b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.tsx new file mode 100644 index 00000000..f7869d5a --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.tsx @@ -0,0 +1,160 @@ +// — the review classifier's recent runs, newest first +// (`docs/swarm/review-classifier.md`, "Recent runs"): which PRs it ran on, +// what it answered, and which runs failed or were turned away. One card per +// run; selecting a card expands it to the same result table the try-out page +// shows. Reads `GET /api/review-classifier/runs`. The page stores nothing and +// posts nothing, and shows no token counts, cache counts or cost. +import { useState } from "preact/hooks"; +import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; +import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; +import { Badge } from "@hive/shared/badge.js"; +import { Card } from "../../ui/card/Card.js"; +import { Panel } from "../../ui/panel/Panel.js"; +import { + RefreshIntervalPicker, + useRefreshInterval, + type RefreshIntervalMs, +} from "../../ui/refresh-interval/RefreshInterval.js"; +import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; +import { TryOutResult } from "../review-tryout/TryOutResult.js"; +import { + OUTCOME_LABEL, + OUTCOME_TONE, + TRIGGER_LABEL, + classificationCount, + durationLabel, + type ClassifierRun, +} from "./RunsTypes.js"; +import "./ReviewRunsPage.css"; + +const RUNS = "/api/review-classifier/runs"; +const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; + +function RunCard({ + run, + open, + onToggle, +}: { + run: ClassifierRun; + open: boolean; + onToggle: () => void; +}) { + const bodyId = `review-run-${run.id}`; + return ( + + + {open ? ( +
+ {run.detail ?

{run.detail}

: null} + {run.rows && run.rows.length > 0 ? ( + + ) : null} + {run.review_url ? ( + + ) : null} +

Requested by {run.requester}.

+
+ ) : null} +
+ ); +} + +export function ReviewRunsPage() { + const [intervalMs, setIntervalMs] = + useState(DEFAULT_INTERVAL_MS); + const [runs, setRuns] = useState(null); + const [error, setError] = useState(null); + // Which cards are expanded. Survives a refresh, so a list that updates + // under you does not fold the card you are reading. + const [open, setOpen] = useState>(new Set()); + + async function load() { + try { + const res = await fetch(RUNS); + if (!res.ok) { + setError(await readApiError(res)); + return; + } + setRuns((await res.json()) as ClassifierRun[]); + setError(null); + } catch (e: unknown) { + setError({ detail: String(e) }); + } + } + + useRefreshInterval(intervalMs, () => void load()); + + function toggle(id: string) { + setOpen((cur) => { + const next = new Set(cur); + if (!next.delete(id)) next.add(id); + return next; + }); + } + + return ( + + } + > +

+ The PRs the review classifier ran on, newest first: what it answered, + and which runs failed or were turned away. Select a run to see its + result table. +

+ {error ? ( + + ) : null} + {runs !== null && runs.length === 0 && !error ? ( +

No runs yet.

+ ) : null} +
+ {(runs ?? []).map((run) => ( + toggle(run.id)} + /> + ))} +
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/RunsTypes.ts b/frontend/packages/swarm-ui/src/pages/review-runs/RunsTypes.ts new file mode 100644 index 00000000..c07563e1 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/review-runs/RunsTypes.ts @@ -0,0 +1,78 @@ +// Wire shape of the review classifier's run list +// (`GET /api/review-classifier/runs`), and how the page words it. Rows and +// the overall verdict are the try-out's own shapes. +import type { BadgeTone } from "@hive/shared/badge.js"; +import type { TryOutOverall, TryOutRow } from "../review-tryout/TryOutTypes.js"; + +export type RunTrigger = "try-out" | "review"; + +export type RunOutcome = + | "approved" + | "changes-requested" + | "no-verdict" + | "queue-full" + | "post-failed" + | "done" + | "failed"; + +/** One run, as the list returns it. Fields marked optional are absent when + * they do not apply to the run. */ +export interface ClassifierRun { + id: string; + trigger: RunTrigger; + repo: string; + pr: number; + requester: string; + head_sha?: string; + started_unix: number; + finished_unix: number; + outcome: RunOutcome; + /** Why, for an outcome that needs a reason. */ + detail?: string; + rows?: TryOutRow[]; + overall?: TryOutOverall; + /** The review the run posted, when it posted one. */ + review_url?: string; +} + +export const OUTCOME_LABEL: Record = { + approved: "approved", + "changes-requested": "changes requested", + "no-verdict": "comment only", + "queue-full": "queue full", + "post-failed": "post failed", + done: "done", + failed: "failed", +}; + +export const OUTCOME_TONE: Record = { + approved: "positive", + "changes-requested": "accent", + "no-verdict": "neutral", + "queue-full": "warning", + "post-failed": "negative", + done: "positive", + failed: "negative", +}; + +export const TRIGGER_LABEL: Record = { + "try-out": "try-out", + review: "requested review", +}; + +/** How long a run took, as `45s`, `3m 12s` or `1h 4m`. A clock that ran + * backwards reads as `0s`. */ +export function durationLabel(startedUnix: number, finishedUnix: number) { + const total = Math.max(0, Math.round(finishedUnix - startedUnix)); + if (total < 60) return `${total}s`; + const minutes = Math.floor(total / 60); + if (minutes < 60) return `${minutes}m ${total % 60}s`; + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + +/** How many classifications ran, in words. A run that never got as far as + * running any has no rows. */ +export function classificationCount(run: ClassifierRun): string { + const n = run.rows?.length ?? 0; + return `${n} ${n === 1 ? "classification" : "classifications"}`; +} diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.css b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.css index 3d9b683d..5419d9b4 100644 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.css +++ b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.css @@ -62,13 +62,3 @@ margin: 0; color: var(--muted); } -.ui-tryout-row-error { - color: var(--red); -} -.ui-tryout-overall, -.ui-tryout-sha { - margin: 0.75em 0 0; -} -.ui-tryout-sha { - color: var(--muted); -} diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.css b/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.css new file mode 100644 index 00000000..1c0aef8b --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.css @@ -0,0 +1,11 @@ +/* A finished run's table, the overall verdict under it and the head commit. */ +.ui-tryout-row-error { + color: var(--red); +} +.ui-tryout-overall, +.ui-tryout-sha { + margin: 0.75em 0 0; +} +.ui-tryout-sha { + color: var(--muted); +} diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.tsx b/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.tsx index 771f52ec..3c9ff858 100644 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.tsx @@ -5,6 +5,7 @@ import { Badge } from "@hive/shared/badge.js"; import { Table, type TableColumn } from "../../ui/table/Table.js"; import type { TryOutResult as Result, TryOutRow } from "./TryOutTypes.js"; +import "./TryOutResult.css"; const VERDICT_LABEL: Record = { match: "match", @@ -44,7 +45,21 @@ const COLUMNS: TableColumn[] = [ }, ]; -export function TryOutResult({ result }: { result: Result }) { +/** What the table needs of a run: the try-out's answer, or a listed run's. The + * head commit is absent for a run that never read one. */ +export type TryOutAnswer = Pick & { + head_sha?: string; +}; + +export function TryOutResult({ + result, + filterKey = "swarm-ui:review-tryout:table-filters", +}: { + result: TryOutAnswer; + /** Where the column filters are remembered; a page showing several tables + * keeps them apart from the try-out's. */ + filterKey?: string; +}) { const rows = result.rows ?? []; return (
@@ -54,7 +69,7 @@ export function TryOutResult({ result }: { result: Result }) { // By position: two classifications may share a name, and a shared key // would let one result render over the other. rowKey={(r) => String(rows.indexOf(r))} - storageKey="swarm-ui:review-tryout:table-filters" + storageKey={filterKey} emptyMessage="the run returned no classifications" /> {result.overall ? ( @@ -66,9 +81,11 @@ export function TryOutResult({ result }: { result: Result }) { />

) : null} -

- Ran on head {result.head_sha.slice(0, 12)} -

+ {result.head_sha ? ( +

+ Ran on head {result.head_sha.slice(0, 12)} +

+ ) : null}
); } diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 7cab64c1..ad3a6dda 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -52,6 +52,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ { href: "/jobs", label: "jobs", accent: "var(--pink)" }, { href: "/schedules", label: "schedules", accent: "var(--cyan)" }, { href: "/issues", label: "issues", accent: "var(--yellow)" }, + { href: "/review-runs", label: "review runs", accent: "var(--amber)" }, { href: "/review-tryout", label: "review try-out", accent: "var(--red)" }, { href: "/components", label: "components", accent: "var(--blue)" }, ]; @@ -63,7 +64,12 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ // modifier alongside the base class, not a replacement, so every other // route's layout is untouched. `/agents` joined for the same reason — // its own table view (plus the list+detail split) hits the same cap. -const WIDE_BODY_ROUTES = new Set(["/issues", "/agents", "/review-tryout"]); +const WIDE_BODY_ROUTES = new Set([ + "/issues", + "/agents", + "/review-runs", + "/review-tryout", +]); // `/agents/:name/terminal` (and a subagent's, under // `/agents/:name/subagents/:subagent/`) is full-bleed: the terminal fills