From 5193a101a65f56f88d67d327d91041ccf27b618a Mon Sep 17 00:00:00 2001
From: iris
Date: Mon, 12 Oct 2026 18:53:06 +0200
Subject: [PATCH 1/2] swarm-ui: a review runs page lists the review
classifier's recent runs
One card per run, newest first, from GET /api/review-classifier/runs: repo,
PR, head commit, trigger, when, how long, how many classifications and the
outcome as a badge. A card opens to the try-out's result table, the overall
verdict, the reason and the posted review's link. TryOutResult takes the
answer it shows rather than a whole try-out result, and its styles move to
their own file.
---
docs/swarm/ui.md | 28 +++
frontend/packages/swarm-ui/src/App.tsx | 2 +
.../src/pages/review-runs/ReviewRunsPage.css | 47 +++++
.../src/pages/review-runs/ReviewRunsPage.tsx | 160 ++++++++++++++++++
.../src/pages/review-runs/RunsTypes.ts | 78 +++++++++
.../pages/review-tryout/ReviewTryOutPage.css | 10 --
.../src/pages/review-tryout/TryOutResult.css | 11 ++
.../src/pages/review-tryout/TryOutResult.tsx | 27 ++-
.../packages/swarm-ui/src/shell/Shell.tsx | 8 +-
9 files changed, 355 insertions(+), 16 deletions(-)
create mode 100644 frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.css
create mode 100644 frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.tsx
create mode 100644 frontend/packages/swarm-ui/src/pages/review-runs/RunsTypes.ts
create mode 100644 frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.css
diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md
index 712585815..dbd46c47a 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 44493a1ea..d6432f103 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 000000000..75e49738f
--- /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 000000000..f7869d5ae
--- /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 ? (
+
+ ) : 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.
+
+
+ );
+}
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 000000000..c07563e10
--- /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 3d9b683db..5419d9b4d 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 000000000..1c0aef8be
--- /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 771f52eca..3c9ff858a 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 7cab64c1c..ad3a6dda5 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
From c2dc18413b63d56713adead581998455cda73384 Mon Sep 17 00:00:00 2001
From: iris
Date: Mon, 12 Oct 2026 19:06:47 +0200
Subject: [PATCH 2/2] swarm-ui: the review runs page leaves out the requester
line when a run has none
---
.../swarm-ui/src/pages/review-runs/ReviewRunsPage.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.tsx b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.tsx
index f7869d5ae..6cabe1b04 100644
--- a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.tsx
@@ -82,7 +82,9 @@ function RunCard({