Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
Author SHA1 Message Date
iris
5b49e0e539 swarm ui: a contraction in the review section of the UI doc 2026-10-15 03:38:48 +02:00
iris
a68d73fa74 swarm ui: one review page
The two review tabs, runs and try-out, are one: /review. It is a column of
three sections: try a classification and the classifier settings, each a
folded bar (the settings' bar says what they are now), then the recent runs,
always open. The settings had been pinned under the try-out form; they are now
a section of their own. A new Disclosure primitive is the folding bar, a
native details wearing the Panel's classes, so the folded and open sections
read as the same surface.

/review-runs and /review-tryout are gone, with no redirect.
2026-10-15 03:38:48 +02:00
19 changed files with 223 additions and 45 deletions

View file

@ -232,7 +232,7 @@ key.
A dedicated forge user, separate from swarm-controller's site-admin account A dedicated forge user, separate from swarm-controller's site-admin account
and from every agent's account. It's the reviewer someone requests, it and from every agent's account. It's the reviewer someone requests, it
authors every classifier review, and it pushes the branches the try-out page authors every classifier review, and it pushes the branches the try-out section
creates. creates.
`swarm-controller` creates it as `review-classifier`, with the email `swarm-controller` creates it as `review-classifier`, with the email
@ -256,7 +256,7 @@ collaborator:
allowlist, the account counts only once it's on that list, and read access allowlist, the account counts only once it's on that list, and read access
is then enough for reviews. is then enough for reviews.
The try-out page's **open a PR** pushes a branch to the repo itself, so it The try-out's **open a PR** pushes a branch to the repo itself, so it
needs **write** whatever the branch protection says. needs **write** whatever the branch protection says.
The repo also needs a rules file on its default branch (see The repo also needs a rules file on its default branch (see

View file

@ -311,5 +311,5 @@ with no restart. Every run spends money on that key.
It needs neither the forge nor a hive on its host. It reaches the queue and the It needs neither the forge nor a hive on its host. It reaches the queue and the
secret store by name, under its own `review-classifier` leaf. On a host secret store by name, under its own `review-classifier` leaf. On a host
without the store, that leaf is the one file to copy, as for the CI runner. without the store, that leaf is the one file to copy, as for the CI runner.
The swarm UI's try-out page drives it through swarm-controller; what a run is The swarm UI's try-out drives it through swarm-controller; what a run is
and what it costs are in [`review-classifier.md`](review-classifier.md). and what it costs are in [`review-classifier.md`](review-classifier.md).

View file

@ -17,8 +17,7 @@ domain, covers host-level detail for one hive.
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress | | `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
| `/issues` | a cross-repo issue report | | `/issues` | a cross-repo issue report |
| `/review-runs` | the review classifier's recent runs: which PRs, what it answered, which failed | | `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings |
| `/review-tryout` | try a review classification against a real PR |
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
An agent created here or with `swarmctl agent create` starts `paused`; set it An agent created here or with `swarmctl agent create` starts `paused`; set it
@ -57,9 +56,18 @@ 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 agent, or link an account, without leaving the terminal. A subagent's tab opens
its agent's details. its agent's details.
### Review runs ### Review
`/review-runs` lists what the [review classifier](review-classifier.md) has `/review` is the one page for the [review classifier](review-classifier.md),
with three sections in a column. **try a classification** and **classifier
settings** come first, each folded to a single bar: the bar of the settings
says what they're now, and what you typed in a section stays when you fold it.
**recent runs** is under them and always open. The sections share nothing and
ask for their own data.
#### Recent runs
The **recent runs** section 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 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 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 started, how long it took, how many classifications ran, and the outcome as a
@ -84,9 +92,9 @@ does. An empty list says there are no runs yet, and a failed load shows the reas
controller gave. The page shows no token counts, cache counts or cost, and controller gave. The page shows no token counts, cache counts or cost, and
stores and posts nothing. stores and posts nothing.
### Review try-out #### Try a classification
`/review-tryout` shows how the [review classifier](review-classifier.md) The **try a classification** section shows how the [review classifier](review-classifier.md)
answers a classification on a real PR, before you put it in a repo's config. 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 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 name, a description, a polarity (positive or negative) and a threshold from 0
@ -130,9 +138,9 @@ first. If the classifier's account may not push to the repo, the page says so:
a repo admin adds it as a collaborator with write access a repo admin adds it as a collaborator with write access
([the service account](review-classifier.md#the-service-account)). ([the service account](review-classifier.md#the-service-account)).
### Classifier settings #### Classifier settings
Under the try-out form, **classifier settings** shows the runtime, model and The **classifier settings** section shows the runtime, model and
effort the [review classifier](review-classifier.md) runs with and how many effort the [review classifier](review-classifier.md) runs with and how many
requested reviews run at once, and lets you change them. The runtime is **API requested reviews run at once, and lets you change them. The runtime is **API
key** (claude on the Anthropic key) or **ACP** (opencode on a provider). On the key** (claude on the Anthropic key) or **ACP** (opencode on a provider). On the

View file

@ -8,8 +8,7 @@ import { ComponentsPage } from "./pages/ComponentsPage.js";
import { JobsPage } from "./pages/JobsPage.js"; import { JobsPage } from "./pages/JobsPage.js";
import { HivesPage } from "./pages/HivesPage.js"; import { HivesPage } from "./pages/HivesPage.js";
import { IssueReportPage } from "./pages/IssueReportPage.js"; import { IssueReportPage } from "./pages/IssueReportPage.js";
import { ReviewRunsPage } from "./pages/review-runs/ReviewRunsPage.js"; import { ReviewPage } from "./pages/review/ReviewPage.js";
import { ReviewTryOutPage } from "./pages/review-tryout/ReviewTryOutPage.js";
import { SchedulesPage } from "./pages/schedules/SchedulesPage.js"; import { SchedulesPage } from "./pages/schedules/SchedulesPage.js";
import { StartPage } from "./pages/StartPage.js"; import { StartPage } from "./pages/StartPage.js";
import { Panel } from "./ui/panel/Panel.js"; import { Panel } from "./ui/panel/Panel.js";
@ -37,8 +36,7 @@ export function App() {
<Route path="/jobs" component={JobsPage} /> <Route path="/jobs" component={JobsPage} />
<Route path="/schedules" component={SchedulesPage} /> <Route path="/schedules" component={SchedulesPage} />
<Route path="/issues" component={IssueReportPage} /> <Route path="/issues" component={IssueReportPage} />
<Route path="/review-runs" component={ReviewRunsPage} /> <Route path="/review" component={ReviewPage} />
<Route path="/review-tryout" component={ReviewTryOutPage} />
<Route path="/components" component={ComponentsPage} /> <Route path="/components" component={ComponentsPage} />
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>

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,162 @@
// <ReviewRuns> — 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
// shows. Reads `GET /api/review-classifier/runs`. This section 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 "./ReviewRuns.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}
{run.requester ? (
<p class="ui-runs-meta">Requested by {run.requester}.</p>
) : null}
</div>
) : null}
</Card>
);
}
export function ReviewRuns() {
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="recent 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

@ -10,7 +10,7 @@ import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Button } from "../../ui/button/Button.js"; import { Button } from "../../ui/button/Button.js";
import { Panel } from "../../ui/panel/Panel.js"; import { Disclosure } from "../../ui/disclosure/Disclosure.js";
import { SelectField } from "../../ui/select-field/SelectField.js"; import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextField } from "../../ui/text-field/TextField.js"; import { TextField } from "../../ui/text-field/TextField.js";
import { import {
@ -20,6 +20,7 @@ import {
changed, changed,
draftOf, draftOf,
settingsProblem, settingsProblem,
settingsSummary,
toSettings, toSettings,
type ClassifierSettingsBody, type ClassifierSettingsBody,
type Runtime, type Runtime,
@ -54,6 +55,9 @@ export function ClassifierSettings() {
const [saveError, setSaveError] = useState<ProblemDetails | null>(null); const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false); const [justSaved, setJustSaved] = useState(false);
// Folded until wanted: the settings are changed now and then, and the
// folded header says what they are.
const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -114,7 +118,13 @@ export function ClassifierSettings() {
const problem = draft === null ? null : settingsProblem(draft); const problem = draft === null ? null : settingsProblem(draft);
return ( return (
<Panel title="classifier settings" icon="⚙️"> <Disclosure
title="classifier settings"
icon="⚙️"
hint={saved ? settingsSummary(saved) : undefined}
open={open}
onToggle={setOpen}
>
<p> <p>
The runtime, model and effort the review classifier runs with, and how The runtime, model and effort the review classifier runs with, and how
many requested reviews run at once. The API key runtime runs claude on many requested reviews run at once. The API key runtime runs claude on
@ -234,6 +244,6 @@ export function ClassifierSettings() {
) : null} ) : null}
</form> </form>
) : null} ) : null}
</Panel> </Disclosure>
); );
} }

View file

@ -182,3 +182,15 @@ export function changed(
d.priceCacheWrite.trim() !== now.priceCacheWrite) d.priceCacheWrite.trim() !== now.priceCacheWrite)
); );
} }
/** The settings in force as one short line, for a folded view of them: the
* runtime and the model it runs, the effort when one is set, and how many
* reviews run at once. The model is the provider's under ACP. */
export function settingsSummary(s: ClassifierSettingsBody): string {
const model = s.runtime === "acp" ? (s.acp?.model ?? "") : s.model;
const parts = [s.runtime, model, s.effort ? `${s.effort} effort` : ""].filter(
(p) => p !== "",
);
parts.push(`${s.max_concurrent_reviews} at once`);
return parts.join(" · ");
}

View file

@ -6,7 +6,7 @@ import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js"; import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
import { TextField } from "../../ui/text-field/TextField.js"; import { TextField } from "../../ui/text-field/TextField.js";
import type { ClassificationDraft, Polarity } from "./TryOutTypes.js"; import type { ClassificationDraft, Polarity } from "./TryOutTypes.js";
import "./ReviewTryOutPage.css"; import "./ReviewTryOut.css";
const POLARITY_OPTIONS = [ const POLARITY_OPTIONS = [
{ value: "positive", label: "positive — the PR should match" }, { value: "positive", label: "positive — the PR should match" },

View file

@ -0,0 +1,64 @@
/* 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-target > button {
flex: none;
align-self: flex-end;
}
.ui-tryout-loaded {
margin: 0;
color: var(--muted);
}
.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);
}

View file

@ -0,0 +1,474 @@
// <ReviewTryOut> — 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 repo and PR fields offer the repos that have an open PR and the open PRs
// of the repo (`GET /api/pulls/repos`, `GET /api/pulls?repo=…`), and stay free
// text: the run also works on a merged or closed PR.
//
// **load the repo's rules** fills the rows from the rules on the repo's default
// branch (`GET /api/review-classifier/rules`), to run as they are or to edit.
//
// **open a PR** posts the rows to `POST /api/review-classifier/open-pr`, which
// has the classifier's forge account push a branch adding them to the repo's
// rules file, with the operator as commit author, and links to the forge's page
// for opening the PR from that branch.
//
// The inputs stay as typed after a run, so the operator can edit and run
// again. The section keeps nothing you typed and no
// result, and a run 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 { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Disclosure } from "../../ui/disclosure/Disclosure.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 {
draftFromRule,
hasTypedText,
prNumber,
repoProblem,
rowsProblem,
runProblem,
toBody,
type ClassificationDraft,
type OpenPrAnswer,
type OpenPull,
type RulesAnswer,
type TryOutResult as Result,
} from "./TryOutTypes.js";
import "./ReviewTryOut.css";
const TRY_OUT = "/api/review-classifier/try-out";
const RULES = "/api/review-classifier/rules";
const OPEN_PR = "/api/review-classifier/open-pr";
const PULL_REPOS = "/api/pulls/repos";
const PULLS = "/api/pulls";
// How long the repo field rests before its PRs are fetched, so typing a repo
// does not ask for the PRs of each prefix of it.
const PICK_DELAY_MS = 300;
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 ReviewTryOut() {
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 [loadingRules, setLoadingRules] = useState(false);
const [rulesError, setRulesError] = useState<ProblemDetails | null>(null);
// Where the rows came from, until the next load replaces it.
const [loaded, setLoaded] = useState<(RulesAnswer & { repo: string }) | null>(
null,
);
const [confirmLoad, setConfirmLoad] = useState(false);
const [opening, setOpening] = useState(false);
const [opened, setOpened] = useState<
(OpenPrAnswer & { repo: string }) | null
>(null);
const [openError, setOpenError] = useState<ProblemDetails | null>(null);
// What the two fields offer to complete to. The fields stay free text, so a
// repo or a PR the lists lack, a merged one say, can still be typed.
const [repoChoices, setRepoChoices] = useState<string[]>([]);
const [repoChoicesError, setRepoChoicesError] =
useState<ProblemDetails | null>(null);
const [pulls, setPulls] = useState<OpenPull[]>([]);
const [pullsError, setPullsError] = useState<ProblemDetails | null>(null);
const running = starting || runId !== null;
// Folded until wanted. What was typed survives a fold, and so does a run's
// result, which sits under the form rather than in it.
const [open, setOpen] = useState(false);
// The repos with an open PR, read once when the page opens.
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await fetch(PULL_REPOS);
if (cancelled) return;
if (!res.ok) {
setRepoChoicesError(await readApiError(res));
return;
}
setRepoChoices((await res.json()) as string[]);
} catch (e: unknown) {
if (!cancelled) setRepoChoicesError({ detail: String(e) });
}
})();
return () => {
cancelled = true;
};
}, []);
// The open PRs of the repo once it is typed or picked and has stopped
// changing. A repo that is not valid yet has no list.
useEffect(() => {
setPulls([]);
setPullsError(null);
const target = repo.trim();
if (repoProblem(target) !== null) return;
let cancelled = false;
const timer = setTimeout(() => {
void (async () => {
try {
const res = await fetch(
`${PULLS}?repo=${encodeURIComponent(target)}`,
);
if (cancelled) return;
if (!res.ok) {
setPullsError(await readApiError(res));
return;
}
setPulls((await res.json()) as OpenPull[]);
} catch (e: unknown) {
if (!cancelled) setPullsError({ detail: String(e) });
}
})();
}, PICK_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [repo]);
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 openPrProblem = rowsProblem(repo, drafts);
const problem = runProblem(repo, pr, drafts);
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);
}
}
// Reads the repo's rules from its default branch into the rows. A failed
// load leaves the rows as they were.
async function loadRules() {
const target = repo.trim();
setConfirmLoad(false);
setLoadingRules(true);
setRulesError(null);
try {
const res = await fetch(`${RULES}?repo=${encodeURIComponent(target)}`);
if (!res.ok) {
setRulesError(await readApiError(res));
return;
}
const answer = (await res.json()) as RulesAnswer;
setDrafts(answer.classifications.map((c) => draftFromRule(c, nextKey++)));
setLoaded({ ...answer, repo: target });
} catch (err: unknown) {
setRulesError({ detail: String(err) });
} finally {
setLoadingRules(false);
}
}
// Pushes the rows on a new branch of the repo and keeps the link to open
// the PR from it.
async function openPr() {
const target = repo.trim();
setOpening(true);
setOpened(null);
setOpenError(null);
try {
const res = await fetch(OPEN_PR, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
repo: target,
classifications: drafts.map(toBody),
}),
});
if (!res.ok) {
setOpenError(await readApiError(res));
return;
}
setOpened({ ...((await res.json()) as OpenPrAnswer), repo: target });
} catch (err: unknown) {
setOpenError({ detail: String(err) });
} finally {
setOpening(false);
}
}
// Typed rows are the operator's work, so replacing them is asked first.
function askLoadRules() {
if (hasTypedText(drafts)) setConfirmLoad(true);
else void loadRules();
}
function changeDraft(next: ClassificationDraft) {
setDrafts((ds) => ds.map((d) => (d.key === next.key ? next : d)));
}
return (
<div class="ui-tryout-page">
<Disclosure
title="try a classification"
icon="🧪"
hint="run one on a real PR before it goes into a repo's rules"
open={open}
onToggle={setOpen}
>
<p>
Run a review classification against a real PR and see how the model
answers it. A run posts nothing to the forge, and nothing is saved:
change the classification and run it again until it answers the way
you want. Then open a PR to add it to the repo's rules.
</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}
suggestions={repoChoices.map((value) => ({ value }))}
/>
<TextField
id="tryout-pr"
label="PR number"
value={pr}
pattern="[0-9]+"
onInput={setPr}
suggestions={pulls.map((p) => ({
value: String(p.number),
label: p.title,
}))}
/>
<Button
onClick={askLoadRules}
disabled={loadingRules || running || repoProblem(repo) !== null}
>
{loadingRules ? "loading…" : "load the repo's rules"}
</Button>
</div>
{repoChoicesError ? (
<ApiErrorPanel
context="the repos with an open PR could not be listed"
problem={repoChoicesError}
/>
) : null}
{pullsError ? (
<ApiErrorPanel
context="the repo's open PRs could not be listed"
problem={pullsError}
/>
) : null}
{rulesError ? (
<ApiErrorPanel
context="the repo's rules could not be loaded"
problem={rulesError}
/>
) : null}
{loaded ? (
<p class="ui-tryout-loaded">
Loaded {loaded.classifications.length} from{" "}
<code>{loaded.path}</code> on <code>{loaded.branch}</code> of{" "}
{loaded.repo}, read at commit{" "}
<code>{loaded.commit.slice(0, 12)}</code>. Edit them below, then
run.
</p>
) : null}
{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>
<Button
onClick={() => void openPr()}
disabled={opening || openPrProblem !== null}
>
{opening ? "pushing…" : "open a PR"}
</Button>
</div>
{problem !== null ? <p class="ui-tryout-hint">{problem}</p> : null}
{openError ? (
<ApiErrorPanel
context="the branch for the PR could not be pushed"
problem={openError}
/>
) : null}
{opened ? (
<p class="ui-tryout-loaded">
Pushed <code>{opened.branch}</code> to {opened.repo}, changing{" "}
<code>{opened.path}</code> with you as the author.{" "}
<a href={opened.compare_url} target="_blank" rel="noopener">
Open the PR on the forge
</a>
.
</p>
) : null}
</form>
</Disclosure>
<ConfirmDialog
open={confirmLoad}
label="replace the classifications"
confirmLabel="replace them"
onCancel={() => setConfirmLoad(false)}
onConfirm={() => void loadRules()}
>
<p>
Loading the rules of {repo.trim()} replaces the classifications you
have written below.
</p>
</ConfirmDialog>
{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,6 @@
/* The folded bars, then the runs: one column, one gap between the parts. */
.ui-review-page {
display: flex;
flex-direction: column;
gap: 1em;
}

View file

@ -0,0 +1,20 @@
// <ReviewPage> — the review classifier on one page (`docs/swarm/ui.md`,
// "Review"): the way to try a classification on a PR, the classifier's own
// settings, and the runs it has done. The first two fold, since they are
// used now and then and a folded header says what is in them; the runs are
// what the page is for, so they are always in view, under the two bars.
// Each part keeps its own data and requests: this page only lays them out.
import { ClassifierSettings } from "../review-settings/ClassifierSettings.js";
import { ReviewRuns } from "../review-runs/ReviewRuns.js";
import { ReviewTryOut } from "../review-tryout/ReviewTryOut.js";
import "./ReviewPage.css";
export function ReviewPage() {
return (
<div class="ui-review-page">
<ReviewTryOut />
<ClassifierSettings />
<ReviewRuns />
</div>
);
}

View file

@ -54,8 +54,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ href: "/jobs", label: "jobs", accent: "var(--pink)" }, { href: "/jobs", label: "jobs", accent: "var(--pink)" },
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" }, { href: "/schedules", label: "schedules", accent: "var(--cyan)" },
{ href: "/issues", label: "issues", accent: "var(--yellow)" }, { href: "/issues", label: "issues", accent: "var(--yellow)" },
{ href: "/review-runs", label: "review runs", accent: "var(--amber)" }, { href: "/review", label: "review", accent: "var(--amber)" },
{ href: "/review-tryout", label: "review try-out", accent: "var(--red)" },
{ href: "/components", label: "components", accent: "var(--blue)" }, { href: "/components", label: "components", accent: "var(--blue)" },
]; ];

View file

@ -9,12 +9,7 @@
// modifier alongside the base class, not a replacement, so every other // modifier alongside the base class, not a replacement, so every other
// route's layout is untouched. `/agents` joined for the same reason — // route's layout is untouched. `/agents` joined for the same reason —
// its own table view (plus the list+detail split) hits the same cap. // its own table view (plus the list+detail split) hits the same cap.
const WIDE_BODY_ROUTES = new Set([ const WIDE_BODY_ROUTES = new Set(["/issues", "/agents", "/review"]);
"/issues",
"/agents",
"/review-runs",
"/review-tryout",
]);
// `/agents/:name/terminal` (and a subagent's, under // `/agents/:name/terminal` (and a subagent's, under
// `/agents/:name/subagents/:subagent/`) is full-bleed: the terminal fills // `/agents/:name/subagents/:subagent/`) is full-bleed: the terminal fills

View file

@ -0,0 +1,47 @@
/* A shut disclosure is only its header row, so it has no divider to draw and
no body to space; the divider shows once it is open. */
.ui-disclosure-summary {
cursor: pointer;
list-style: none;
border-bottom-color: transparent;
}
.ui-disclosure-summary::-webkit-details-marker {
display: none;
}
.ui-disclosure[open] > .ui-disclosure-summary {
border-bottom-color: var(--border);
}
.ui-disclosure-summary:hover .ui-panel-title {
text-decoration: underline;
}
.ui-disclosure-summary:focus-visible {
outline: 2px solid var(--blue);
outline-offset: -2px;
border-radius: 0.5em;
}
.ui-disclosure-hint {
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: 0.9em;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-disclosure-chevron {
margin-left: auto;
width: 0.55em;
height: 0.55em;
flex: none;
border-right: 2px solid var(--muted);
border-bottom: 2px solid var(--muted);
transform: rotate(-45deg);
transition: transform 0.15s ease;
}
.ui-disclosure[open] > .ui-disclosure-summary .ui-disclosure-chevron {
transform: rotate(45deg);
}
@media (prefers-reduced-motion: reduce) {
.ui-disclosure-chevron {
transition: none;
}
}

View file

@ -0,0 +1,50 @@
// <Disclosure> — a Panel that folds: the header row is the control, and the
// body shows only while it is open. For a section a page keeps within reach
// but that is not the point of the page, such as a form used now and then.
// It is a native `<details>`, so the keyboard and assistive tech get the
// open/closed state for free, and it wears the Panel's own classes, so a
// Disclosure and a Panel on one page read as the same kind of surface.
//
// Controlled: `open` and `onToggle`, so a page can open it for the user (a
// run started, say). The body stays in the DOM while it is shut, so what was
// typed into it survives a fold. `hint` is a short line beside the title that
// says what is in there without opening it.
import type { ComponentChildren } from "preact";
import "../panel/Panel.css";
import "./Disclosure.css";
export function Disclosure({
title,
icon,
hint,
open,
onToggle,
children,
}: {
title: string;
icon?: string;
hint?: ComponentChildren;
open: boolean;
onToggle: (open: boolean) => void;
children: ComponentChildren;
}) {
return (
<details
class="ui-panel ui-disclosure"
open={open}
onToggle={(e) => onToggle((e.currentTarget as HTMLDetailsElement).open)}
>
<summary class="ui-panel-header ui-disclosure-summary">
{icon ? (
<span class="ui-panel-icon" aria-hidden="true">
{icon}
</span>
) : null}
<h2 class="ui-panel-title">{title}</h2>
{hint ? <span class="ui-disclosure-hint">{hint}</span> : null}
<span class="ui-disclosure-chevron" aria-hidden="true" />
</summary>
<div class="ui-panel-body">{children}</div>
</details>
);
}

View file

@ -33,7 +33,7 @@ test("a terminal path with extra segments is not full-bleed", () => {
}); });
test("the wide-body routes are exactly the table pages", () => { test("the wide-body routes are exactly the table pages", () => {
for (const path of ["/issues", "/agents", "/review-runs", "/review-tryout"]) { for (const path of ["/issues", "/agents", "/review"]) {
assert.equal(isWideBodyRoute(path), true, path); assert.equal(isWideBodyRoute(path), true, path);
} }
for (const path of ["/", "/jobs", "/schedules", "/components"]) { for (const path of ["/", "/jobs", "/schedules", "/components"]) {

View file

@ -10,6 +10,7 @@ import {
maxValue, maxValue,
priceValue, priceValue,
settingsProblem, settingsProblem,
settingsSummary,
toSettings, toSettings,
} from "../src/pages/review-settings/SettingsTypes.ts"; } from "../src/pages/review-settings/SettingsTypes.ts";
@ -117,3 +118,29 @@ test("the reviews limit and a price parse as the form needs", () => {
assert.equal(priceValue("-1"), null); assert.equal(priceValue("-1"), null);
assert.equal(priceValue("1e3"), null); assert.equal(priceValue("1e3"), null);
}); });
test("the summary names the runtime, model and how many run at once", () => {
assert.equal(settingsSummary(apiKey), "api-key · haiku · 2 at once");
});
test("the summary names an effort when one is set", () => {
assert.equal(
settingsSummary({ ...apiKey, effort: "high" }),
"api-key · haiku · high effort · 2 at once",
);
});
test("under ACP the summary names the provider's model, not the claude one", () => {
assert.equal(
settingsSummary({
...apiKey,
runtime: "acp",
acp: {
base_url: "http://x",
model: "glm",
prices: { input: 1, output: 2 },
},
}),
"acp · glm · 2 at once",
);
});