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

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