Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
iris
c5b66dc5a0 swarm-ui: match the try-out page to the backend's limits
The repo check takes the endpoint's own character set and refuses . and .. parts. The running and expired messages say a run can last up to half an hour and that a controller restart drops the result.
2026-10-12 14:51:38 +02:00
iris
a4a4381cf6 swarm-ui: try-out rows keyed by position, distinct names, and accurate storage wording
Two classifications sharing a name made the result table's row key collide, so the form now refuses duplicate names and the table keys rows by position anyway. The page said it stores nothing in the browser, but the result table remembers its column filters like every table does; the header comment and the docs now say that. The row cap's comment says to keep it in step with the endpoint's own cap.
2026-10-12 14:51:38 +02:00
iris
b67517e41b 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.
2026-10-12 14:51:38 +02:00
8 changed files with 578 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,27 @@ 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, and up to half an hour for ten classifications.
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 no classification and no result, and posts nothing to the
forge; like every table in the UI, the result table remembers its column
filters in the browser. 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,239 @@
// <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 keeps nothing you typed and no result, and posts nothing to
// the forge; the only thing the browser remembers is the result table's
// column filters, as for every table in the UI.
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,
duplicateName,
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. It is the try-out
// endpoint's own cap, which answers a longer list with a 400 that 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 is gone, because it expired or the controller restarted: 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) ??
duplicateName(drafts) ??
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, and up to half an hour for
a long list ({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,74 @@
// <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}
// 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"
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,108 @@
// 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;
}
// Characters the endpoint accepts in each half of a repo: letters, digits, `.`,
// `_` and `-`, other than `.` and `..` themselves.
const SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/;
export function repoProblem(repo: string): string | null {
const parts = repo.trim().split("/");
return parts.length === 2 && parts.every((p) => SEGMENT.test(p))
? null
: "a repo is owner/name, with letters, digits, . _ and - in each part";
}
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;
}
/** A name two classifications share, as a problem, or null: the result table
* names its rows by it, so a shared name leaves two rows nobody can tell apart. */
export function duplicateName(drafts: ClassificationDraft[]): string | null {
const seen = new Set<string>();
for (const d of drafts) {
const name = d.name.trim();
if (seen.has(name)) return `two classifications are named ${name}`;
seen.add(name);
}
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