Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: a review try-out page

A page to try a review classification against a real PR: repo, PR number and one or more classifications (name, description, polarity, threshold). Run posts to the controller's try-out endpoint and polls until the run is done or failed, saying it is running meanwhile, then shows the review's table, the overall verdict and the head commit. A row that could not run shows its error in place of a reason, and a failed run shows its error. The inputs stay so the run can be edited and repeated. Nothing is stored and nothing is posted to the forge. Route, nav entry and a section in docs/swarm/ui.md.
This commit is contained in:
iris 2026-10-12 01:05:34 +02:00 • committed by mara
commit b67517e41b
8 changed files with 552 additions and 1 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-tryout` | try a review classification against a real PR |
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
An agent created here or with `swarmctl agent create` starts `paused`; set it
@ -46,6 +47,26 @@ 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 try-out
`/review-tryout` shows how the [review classifier](review-classifier.md)
answers a classification on a real PR, before you put it in a repo's config.
Give it a repo as `owner/name` and a PR number, then write a classification: a
name, a description, a polarity (positive or negative) and a threshold from 0
to 100. **add a classification** adds more, and **run** starts the run.
A run takes a minute or more. The page says it's running and shows how long it
has been, then shows the review's table: for each classification the verdict,
the model's certainty, the threshold, whether the verdict counted and the
reason. A classification that couldn't run shows its error where the reason
would be. Under the table are the overall verdict, approve or request changes,
and the head commit the run read. A failed run shows its error, for example when the swarm
has no API key yet.
What you typed stays after a run, so you can edit the classification and run it
again. The page saves nothing and posts nothing to the forge. It shows no token
counts, cache counts or cost.
### Scheduled prompts
`/schedules` has one card per scheduled prompt. The **name** is the card's

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 { ReviewTryOutPage } from "./pages/review-tryout/ReviewTryOutPage.js";
import { SchedulesPage } from "./pages/schedules/SchedulesPage.js";
import { Panel } from "./ui/panel/Panel.js";
@ -33,6 +34,7 @@ export function App() {
<Route path="/jobs" component={JobsPage} />
<Route path="/schedules" component={SchedulesPage} />
<Route path="/issues" component={IssueReportPage} />
<Route path="/review-tryout" component={ReviewTryOutPage} />
<Route path="/components" component={ComponentsPage} />
<Route component={NotFound} />
</Switch>

View file

@ -0,0 +1,65 @@
// <ClassificationEditor> — the fields of one review classification: a name, a
// description (the prompt the model answers), which answer passes, and how
// certain the model has to be for its answer to count.
import { Button } from "../../ui/button/Button.js";
import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
import { TextField } from "../../ui/text-field/TextField.js";
import type { ClassificationDraft, Polarity } from "./TryOutTypes.js";
import "./ReviewTryOutPage.css";
const POLARITY_OPTIONS = [
{ value: "positive", label: "positive — the PR should match" },
{ value: "negative", label: "negative — the PR should not match" },
];
export function ClassificationEditor({
draft,
onChange,
onRemove,
}: {
draft: ClassificationDraft;
onChange: (next: ClassificationDraft) => void;
/** Absent when this is the only classification: one is the minimum. */
onRemove?: () => void;
}) {
const id = `tryout-${draft.key}`;
return (
<div class="ui-tryout-classification">
<TextField
id={`${id}-name`}
label="name"
value={draft.name}
placeholder="e.g. datetime"
onInput={(name) => onChange({ ...draft, name })}
/>
<TextAreaField
id={`${id}-description`}
label="description"
value={draft.description}
rows={4}
placeholder="what to look for in the PR, e.g. does it add a naive datetime?"
onInput={(description) => onChange({ ...draft, description })}
/>
<div class="ui-tryout-classification-row">
<SelectField
id={`${id}-polarity`}
label="polarity"
value={draft.polarity}
options={POLARITY_OPTIONS}
onChange={(polarity) =>
onChange({ ...draft, polarity: polarity as Polarity })
}
/>
<TextField
id={`${id}-threshold`}
label="threshold (0–100)"
value={draft.threshold}
pattern="[0-9]+"
onInput={(threshold) => onChange({ ...draft, threshold })}
/>
{onRemove ? <Button onClick={onRemove}>remove</Button> : null}
</div>
</div>
);
}

View file

@ -0,0 +1,66 @@
/* The form panel and the result panel under it, apart from each other. */
.ui-tryout-page {
display: flex;
flex-direction: column;
gap: 1em;
}
/* The try-out form: the repo and PR on one line, then one block per
classification, then the actions. */
.ui-tryout-form {
display: flex;
flex-direction: column;
gap: 1em;
margin-top: 1em;
}
.ui-tryout-target {
display: flex;
flex-wrap: wrap;
gap: 0.75em;
}
.ui-tryout-target > * {
flex: 1 1 14em;
}
.ui-tryout-classification {
display: flex;
flex-direction: column;
gap: 0.6em;
padding: 0.75em;
border: 1px solid var(--border);
border-radius: 0.5em;
}
.ui-tryout-classification-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75em;
}
.ui-tryout-classification-row > * {
flex: 1 1 12em;
}
.ui-tryout-classification-row > button {
flex: none;
}
.ui-tryout-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75em;
}
.ui-tryout-hint {
margin: 0;
color: var(--muted);
}
.ui-tryout-running {
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,233 @@
// <ReviewTryOutPage> — write a review classification and see how the model
// answers it on a real PR, before it goes into a repo's config
// (`docs/swarm/review-classifier.md`, "Try-out"). Pick a repo and a PR, write
// one or more classifications, run: the page posts them to
// swarm-controller's `POST /api/review-classifier/try-out`, which answers
// `202 {id}`, and polls `GET .../try-out/{id}` until the run is `done` or
// `failed`. A run takes a minute or more, so the page says it is running.
//
// The inputs stay as typed after a run, so the operator can edit and run
// again. The page stores nothing (not even in the browser) and posts nothing
// to the forge.
import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Button } from "../../ui/button/Button.js";
import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js";
import { ClassificationEditor } from "./ClassificationEditor.js";
import { TryOutResult } from "./TryOutResult.js";
import {
draftProblem,
prNumber,
repoProblem,
toBody,
type ClassificationDraft,
type TryOutResult as Result,
} from "./TryOutTypes.js";
import "./ReviewTryOutPage.css";
const TRY_OUT = "/api/review-classifier/try-out";
const POLL_MS = 2000;
// More classifications than this are not offered a row; the backend has its
// own cap and says so with a 400, which the page shows as it is.
const MAX_CLASSIFICATIONS = 10;
let nextKey = 0;
function blankDraft(): ClassificationDraft {
return {
key: nextKey++,
name: "",
description: "",
polarity: "positive",
threshold: "80",
};
}
export function ReviewTryOutPage() {
const [repo, setRepo] = useState("");
const [pr, setPr] = useState("");
const [drafts, setDrafts] = useState<ClassificationDraft[]>(() => [
blankDraft(),
]);
// The id of the run being polled; null when none is in flight.
const [runId, setRunId] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const [result, setResult] = useState<Result | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
// Whole seconds since the run started, so a long wait shows it is alive.
const [elapsed, setElapsed] = useState(0);
const running = starting || runId !== null;
useEffect(() => {
if (!running) return;
setElapsed(0);
const t = setInterval(() => setElapsed((s) => s + 1), 1000);
return () => clearInterval(t);
}, [running]);
// Polls the run until it leaves `running`. Cancelled by the next run, or by
// leaving the page.
useEffect(() => {
if (runId === null) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const stop = (problem: ProblemDetails) => {
setError(problem);
setRunId(null);
};
async function poll() {
try {
const res = await fetch(
`${TRY_OUT}/${encodeURIComponent(runId ?? "")}`,
);
if (cancelled) return;
if (res.status === 404) {
stop({
detail: "the result expired before it was read: run it again",
});
return;
}
if (!res.ok) {
const problem = await readApiError(res);
if (!cancelled) stop(problem);
return;
}
const body = (await res.json()) as Result;
if (cancelled) return;
setResult(body);
if (body.state === "running")
timer = setTimeout(() => void poll(), POLL_MS);
else setRunId(null);
} catch (e: unknown) {
if (!cancelled) stop({ detail: String(e) });
}
}
void poll();
return () => {
cancelled = true;
if (timer !== undefined) clearTimeout(timer);
};
}, [runId]);
const problem =
repoProblem(repo) ??
(prNumber(pr) === null ? "a PR number is a whole number above 0" : null) ??
drafts.map(draftProblem).find((p) => p !== null) ??
null;
async function run(e: Event) {
e.preventDefault();
const number = prNumber(pr);
if (problem !== null || number === null) return;
setStarting(true);
setError(null);
setResult(null);
try {
const res = await fetch(TRY_OUT, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
repo: repo.trim(),
pr: number,
classifications: drafts.map(toBody),
}),
});
if (!res.ok) {
setError(await readApiError(res));
return;
}
setRunId(((await res.json()) as { id: string }).id);
} catch (err: unknown) {
setError({ detail: String(err) });
} finally {
setStarting(false);
}
}
function changeDraft(next: ClassificationDraft) {
setDrafts((ds) => ds.map((d) => (d.key === next.key ? next : d)));
}
return (
<div class="ui-tryout-page">
<Panel title="review try-out" icon="🧪">
<p>
Run a review classification against a real PR and see how the model
answers it. Nothing is posted to the forge, and nothing is saved:
change the classification and run it again until it answers the way
you want.
</p>
<form class="ui-tryout-form" onSubmit={(e) => void run(e)}>
<div class="ui-tryout-target">
<TextField
id="tryout-repo"
label="repo"
value={repo}
placeholder="owner/name"
onInput={setRepo}
/>
<TextField
id="tryout-pr"
label="PR number"
value={pr}
pattern="[0-9]+"
onInput={setPr}
/>
</div>
{drafts.map((d) => (
<ClassificationEditor
key={d.key}
draft={d}
onChange={changeDraft}
onRemove={
drafts.length > 1
? () => setDrafts((ds) => ds.filter((x) => x.key !== d.key))
: undefined
}
/>
))}
<div class="ui-tryout-actions">
<Button
onClick={() => setDrafts((ds) => [...ds, blankDraft()])}
disabled={drafts.length >= MAX_CLASSIFICATIONS}
>
add a classification
</Button>
<Button
variant="primary"
type="submit"
disabled={running || problem !== null}
>
{running ? "running…" : "run"}
</Button>
</div>
{problem !== null ? <p class="ui-tryout-hint">{problem}</p> : null}
</form>
</Panel>
{running || result || error ? (
<Panel title="result" icon="📋">
{running ? (
<p class="ui-tryout-running" role="status">
Running — a run takes a minute or more ({elapsed}s so far).
</p>
) : null}
{error ? (
<ApiErrorPanel context="the try-out failed" problem={error} />
) : null}
{result?.state === "failed" ? (
<ApiErrorPanel
context="the run failed"
problem={{
title: "the classifier could not finish",
detail: result.error ?? "no reason given",
}}
/>
) : null}
{result?.state === "done" ? <TryOutResult result={result} /> : null}
</Panel>
) : null}
</div>
);
}

View file

@ -0,0 +1,72 @@
// <TryOutResult> — a finished try-out: one row per classification, the
// overall verdict under them, and the head commit the run read. The columns
// are the review's own table (`docs/swarm/review-classifier.md`); no token
// counts, cache counts or cost, here or anywhere in the UI.
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";
const VERDICT_LABEL: Record<TryOutRow["verdict"], string> = {
match: "match",
"no-match": "no match",
"not-applicable": "not applicable",
};
const COLUMNS: TableColumn<TryOutRow>[] = [
{ key: "name", header: "classification", render: (r) => r.name },
{ key: "polarity", header: "polarity", render: (r) => r.polarity },
{
key: "verdict",
header: "verdict",
render: (r) => (r.error ? "—" : VERDICT_LABEL[r.verdict]),
},
{
key: "certainty",
header: "certainty",
render: (r) => (r.error ? "—" : r.certainty),
},
{ key: "threshold", header: "threshold", render: (r) => r.threshold },
{
key: "counted",
header: "counted",
// A counted row shows whether it passed its polarity; one below its
// threshold does not count and has no pass or fail.
render: (r) =>
r.error ? "—" : r.counted ? (r.passed ? "yes ✅" : "yes ❌") : "no",
},
{
key: "reason",
header: "reason",
cellClass: "ui-table-prose",
// A row that could not run says why where the reason would be.
render: (r) =>
r.error ? <span class="ui-tryout-row-error">{r.error}</span> : r.reason,
},
];
export function TryOutResult({ result }: { result: Result }) {
const rows = result.rows ?? [];
return (
<div class="ui-tryout-result">
<Table
columns={COLUMNS}
rows={rows}
rowKey={(r) => r.name}
storageKey="swarm-ui:review-tryout:table-filters"
emptyMessage="the run returned no classifications"
/>
{result.overall ? (
<p class="ui-tryout-overall">
<strong>Overall: </strong>
<Badge
tone={result.overall === "approve" ? "positive" : "negative"}
value={result.overall === "approve" ? "approve" : "request changes"}
/>
</p>
) : null}
<p class="ui-tryout-sha">
Ran on head <code>{result.head_sha.slice(0, 12)}</code>
</p>
</div>
);
}

View file

@ -0,0 +1,91 @@
// Wire shapes of the review classifier's try-out endpoints
// (`POST /api/review-classifier/try-out`, `GET .../try-out/{id}`), and the
// checks the form makes before a request is worth sending. The backend stays
// the real gate: these only keep an obviously bad form from a round trip.
export type Polarity = "positive" | "negative";
/** One classification as the form holds it: every field as the user typed it. */
export interface ClassificationDraft {
/** Local only, so a row keeps its identity when another is removed. */
key: number;
name: string;
description: string;
polarity: Polarity;
threshold: string;
}
/** The request's classification: what the backend is sent. */
export interface ClassificationBody {
name: string;
description: string;
polarity: Polarity;
threshold: number;
}
export type Verdict = "match" | "no-match" | "not-applicable";
export interface TryOutRow {
name: string;
polarity: Polarity;
verdict: Verdict;
certainty: number;
threshold: number;
/** Whether the verdict counted: its certainty reached the threshold. */
counted: boolean;
/** For a counted row, whether it passed its polarity. */
passed: boolean;
reason: string;
/** Set when this classification could not be run. */
error?: string;
}
export type TryOutOverall = "approve" | "request-changes";
export interface TryOutResult {
state: "running" | "done" | "failed";
head_sha: string;
rows?: TryOutRow[];
overall?: TryOutOverall;
error?: string;
}
const REPO = /^[^/\s]+\/[^/\s]+$/;
export function repoProblem(repo: string): string | null {
return REPO.test(repo.trim()) ? null : "a repo is owner/name";
}
export function prNumber(pr: string): number | null {
const n = Number(pr.trim());
return /^\d+$/.test(pr.trim()) && n > 0 ? n : null;
}
/** The threshold as a number, or null when it is not a whole 0–100. */
export function thresholdValue(text: string): number | null {
const t = text.trim();
if (!/^\d+$/.test(t)) return null;
const n = Number(t);
return n <= 100 ? n : null;
}
/** Why a classification is not ready to send, or null when it is. */
export function draftProblem(d: ClassificationDraft): string | null {
if (d.name.trim() === "") return "give each classification a name";
if (d.description.trim() === "") {
return `${d.name.trim()} needs a description`;
}
if (thresholdValue(d.threshold) === null) {
return `${d.name.trim()}: the threshold is a whole number from 0 to 100`;
}
return null;
}
export function toBody(d: ClassificationDraft): ClassificationBody {
return {
name: d.name.trim(),
description: d.description,
polarity: d.polarity,
threshold: thresholdValue(d.threshold) ?? 0,
};
}

View file

@ -51,6 +51,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-tryout", label: "review try-out", accent: "var(--red)" },
{ href: "/components", label: "components", accent: "var(--blue)" },
];
@ -61,7 +62,7 @@ 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"]);
const WIDE_BODY_ROUTES = new Set(["/issues", "/agents", "/review-tryout"]);
// `/agents/:name/terminal` (and a subagent's, under
// `/agents/:name/subagents/:subagent/`) is full-bleed: the terminal fills