Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
iris 2026-10-14 21:57:23 +02:00 • committed by mara
commit a68d73fa74
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 are 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

@ -1,8 +1,8 @@
// <ReviewRunsPage> — the review classifier's recent runs, newest first // <ReviewRuns> — the review classifier's recent runs, newest first
// (`docs/swarm/review-classifier.md`, "Recent runs"): which PRs it ran on, // (`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 // 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 page // run; selecting a card expands it to the same result table the try-out
// shows. Reads `GET /api/review-classifier/runs`. The page stores nothing and // shows. Reads `GET /api/review-classifier/runs`. This section stores nothing and
// posts nothing, and shows no token counts, cache counts or cost. // posts nothing, and shows no token counts, cache counts or cost.
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
@ -25,7 +25,7 @@ import {
durationLabel, durationLabel,
type ClassifierRun, type ClassifierRun,
} from "./RunsTypes.js"; } from "./RunsTypes.js";
import "./ReviewRunsPage.css"; import "./ReviewRuns.css";
const RUNS = "/api/review-classifier/runs"; const RUNS = "/api/review-classifier/runs";
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
@ -91,7 +91,7 @@ function RunCard({
); );
} }
export function ReviewRunsPage() { export function ReviewRuns() {
const [intervalMs, setIntervalMs] = const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS); useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const [runs, setRuns] = useState<ClassifierRun[] | null>(null); const [runs, setRuns] = useState<ClassifierRun[] | null>(null);
@ -126,7 +126,7 @@ export function ReviewRunsPage() {
return ( return (
<Panel <Panel
title="review runs" title="recent runs"
icon="📜" icon="📜"
actions={ actions={
<RefreshIntervalPicker <RefreshIntervalPicker

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

@ -1,4 +1,4 @@
// <ReviewTryOutPage> — write a review classification and see how the model // <ReviewTryOut> — write a review classification and see how the model
// answers it on a real PR, before it goes into a repo's config // 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 // (`docs/swarm/review-classifier.md`, "Try-out"). Pick a repo and a PR, write
// one or more classifications, run: the page posts them to // one or more classifications, run: the page posts them to
@ -18,11 +18,8 @@
// rules file, with the operator as commit author, and links to the forge's page // rules file, with the operator as commit author, and links to the forge's page
// for opening the PR from that branch. // for opening the PR from that branch.
// //
// Under the form, the classifier's own settings: its model, effort and review
// concurrency (`ClassifierSettings`).
//
// The inputs stay as typed after a run, so the operator can edit and run // The inputs stay as typed after a run, so the operator can edit and run
// again. Apart from the settings, the page keeps nothing you typed and no // again. The section keeps nothing you typed and no
// result, and a run posts nothing to the forge; the only thing the browser // 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. // remembers is the result table's column filters, as for every table in the UI.
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
@ -30,9 +27,9 @@ 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 { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js"; import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Disclosure } from "../../ui/disclosure/Disclosure.js";
import { Panel } from "../../ui/panel/Panel.js"; import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js"; import { TextField } from "../../ui/text-field/TextField.js";
import { ClassifierSettings } from "../review-settings/ClassifierSettings.js";
import { ClassificationEditor } from "./ClassificationEditor.js"; import { ClassificationEditor } from "./ClassificationEditor.js";
import { TryOutResult } from "./TryOutResult.js"; import { TryOutResult } from "./TryOutResult.js";
import { import {
@ -49,7 +46,7 @@ import {
type RulesAnswer, type RulesAnswer,
type TryOutResult as Result, type TryOutResult as Result,
} from "./TryOutTypes.js"; } from "./TryOutTypes.js";
import "./ReviewTryOutPage.css"; import "./ReviewTryOut.css";
const TRY_OUT = "/api/review-classifier/try-out"; const TRY_OUT = "/api/review-classifier/try-out";
const RULES = "/api/review-classifier/rules"; const RULES = "/api/review-classifier/rules";
@ -76,7 +73,7 @@ function blankDraft(): ClassificationDraft {
}; };
} }
export function ReviewTryOutPage() { export function ReviewTryOut() {
const [repo, setRepo] = useState(""); const [repo, setRepo] = useState("");
const [pr, setPr] = useState(""); const [pr, setPr] = useState("");
const [drafts, setDrafts] = useState<ClassificationDraft[]>(() => [ const [drafts, setDrafts] = useState<ClassificationDraft[]>(() => [
@ -114,6 +111,10 @@ export function ReviewTryOutPage() {
const running = starting || runId !== 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. // The repos with an open PR, read once when the page opens.
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -313,7 +314,13 @@ export function ReviewTryOutPage() {
return ( return (
<div class="ui-tryout-page"> <div class="ui-tryout-page">
<Panel title="review try-out" icon="🧪"> <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> <p>
Run a review classification against a real PR and see how the model 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: answers it. A run posts nothing to the forge, and nothing is saved:
@ -426,7 +433,7 @@ export function ReviewTryOutPage() {
</p> </p>
) : null} ) : null}
</form> </form>
</Panel> </Disclosure>
<ConfirmDialog <ConfirmDialog
open={confirmLoad} open={confirmLoad}
label="replace the classifications" label="replace the classifications"
@ -462,7 +469,6 @@ export function ReviewTryOutPage() {
{result?.state === "done" ? <TryOutResult result={result} /> : null} {result?.state === "done" ? <TryOutResult result={result} /> : null}
</Panel> </Panel>
) : null} ) : null}
<ClassifierSettings />
</div> </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",
);
});