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:
parent
34bde7a5af
commit
b67517e41b
8 changed files with 552 additions and 1 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue