Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
iris 2026-10-12 18:53:06 +02:00 • committed by mara
commit 5193a101a6
9 changed files with 355 additions and 16 deletions

View file

@ -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)

View file

@ -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() {
<Route path="/jobs" component={JobsPage} />
<Route path="/schedules" component={SchedulesPage} />
<Route path="/issues" component={IssueReportPage} />
<Route path="/review-runs" component={ReviewRunsPage} />
<Route path="/review-tryout" component={ReviewTryOutPage} />
<Route path="/components" component={ComponentsPage} />
<Route component={NotFound} />

View file

@ -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);
}

View file

@ -0,0 +1,160 @@
// <ReviewRunsPage> — 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 (
<Card class="ui-runs-card">
<button
type="button"
class="ui-runs-head"
aria-expanded={open}
aria-controls={bodyId}
onClick={onToggle}
>
<span class="ui-runs-title">
<strong>
{run.repo} #{run.pr}
</strong>
{run.head_sha ? <code>{run.head_sha.slice(0, 7)}</code> : null}
</span>
<Badge
tone={OUTCOME_TONE[run.outcome]}
value={OUTCOME_LABEL[run.outcome]}
/>
<span class="ui-runs-meta">
{TRIGGER_LABEL[run.trigger]} ·{" "}
<RelativeTime epochMs={run.started_unix * 1000} /> · took{" "}
{durationLabel(run.started_unix, run.finished_unix)} ·{" "}
{classificationCount(run)}
</span>
</button>
{open ? (
<div class="ui-runs-body" id={bodyId}>
{run.detail ? <p class="ui-runs-detail">{run.detail}</p> : null}
{run.rows && run.rows.length > 0 ? (
<TryOutResult
result={run}
filterKey="swarm-ui:review-runs:table-filters"
/>
) : null}
{run.review_url ? (
<p class="ui-runs-link">
<a href={run.review_url} target="_blank" rel="noreferrer">
the posted review
</a>
</p>
) : null}
<p class="ui-runs-meta">Requested by {run.requester}.</p>
</div>
) : null}
</Card>
);
}
export function ReviewRunsPage() {
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const [runs, setRuns] = useState<ClassifierRun[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(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<ReadonlySet<string>>(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 (
<Panel
title="review runs"
icon="📜"
actions={
<RefreshIntervalPicker
id="review-runs-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
}
>
<p>
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.
</p>
{error ? (
<ApiErrorPanel context="the runs could not be loaded" problem={error} />
) : null}
{runs !== null && runs.length === 0 && !error ? (
<p class="ui-runs-empty">No runs yet.</p>
) : null}
<div class="ui-runs-list">
{(runs ?? []).map((run) => (
<RunCard
key={run.id}
run={run}
open={open.has(run.id)}
onToggle={() => toggle(run.id)}
/>
))}
</div>
</Panel>
);
}

View file

@ -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<RunOutcome, string> = {
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<RunOutcome, BadgeTone> = {
approved: "positive",
"changes-requested": "accent",
"no-verdict": "neutral",
"queue-full": "warning",
"post-failed": "negative",
done: "positive",
failed: "negative",
};
export const TRIGGER_LABEL: Record<RunTrigger, string> = {
"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"}`;
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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<TryOutRow["verdict"], string> = {
match: "match",
@ -44,7 +45,21 @@ const COLUMNS: TableColumn<TryOutRow>[] = [
},
];
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<Result, "rows" | "overall"> & {
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 (
<div class="ui-tryout-result">
@ -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 }) {
/>
</p>
) : null}
<p class="ui-tryout-sha">
Ran on head <code>{result.head_sha.slice(0, 12)}</code>
</p>
{result.head_sha ? (
<p class="ui-tryout-sha">
Ran on head <code>{result.head_sha.slice(0, 12)}</code>
</p>
) : null}
</div>
);
}

View file

@ -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