From a68d73fa7423fd3304c690c608649fbb71694566 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 14 Oct 2026 21:57:23 +0200 Subject: [PATCH] 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. --- docs/swarm/review-classifier.md | 4 +- docs/swarm/services.md | 2 +- docs/swarm/ui.md | 24 ++++++--- frontend/packages/swarm-ui/src/App.tsx | 6 +-- .../{ReviewRunsPage.css => ReviewRuns.css} | 0 .../{ReviewRunsPage.tsx => ReviewRuns.tsx} | 12 ++--- .../review-settings/ClassifierSettings.tsx | 16 ++++-- .../pages/review-settings/SettingsTypes.ts | 12 +++++ .../review-tryout/ClassificationEditor.tsx | 2 +- ...{ReviewTryOutPage.css => ReviewTryOut.css} | 0 ...{ReviewTryOutPage.tsx => ReviewTryOut.tsx} | 28 +++++++---- .../swarm-ui/src/pages/review/ReviewPage.css | 6 +++ .../swarm-ui/src/pages/review/ReviewPage.tsx | 20 ++++++++ .../packages/swarm-ui/src/shell/Shell.tsx | 3 +- .../packages/swarm-ui/src/shell/routes.ts | 7 +-- .../swarm-ui/src/ui/disclosure/Disclosure.css | 47 +++++++++++++++++ .../swarm-ui/src/ui/disclosure/Disclosure.tsx | 50 +++++++++++++++++++ .../packages/swarm-ui/test/routes.test.mjs | 2 +- .../swarm-ui/test/settings-types.test.mjs | 27 ++++++++++ 19 files changed, 223 insertions(+), 45 deletions(-) rename frontend/packages/swarm-ui/src/pages/review-runs/{ReviewRunsPage.css => ReviewRuns.css} (100%) rename frontend/packages/swarm-ui/src/pages/review-runs/{ReviewRunsPage.tsx => ReviewRuns.tsx} (94%) rename frontend/packages/swarm-ui/src/pages/review-tryout/{ReviewTryOutPage.css => ReviewTryOut.css} (100%) rename frontend/packages/swarm-ui/src/pages/review-tryout/{ReviewTryOutPage.tsx => ReviewTryOut.tsx} (95%) create mode 100644 frontend/packages/swarm-ui/src/pages/review/ReviewPage.css create mode 100644 frontend/packages/swarm-ui/src/pages/review/ReviewPage.tsx create mode 100644 frontend/packages/swarm-ui/src/ui/disclosure/Disclosure.css create mode 100644 frontend/packages/swarm-ui/src/ui/disclosure/Disclosure.tsx diff --git a/docs/swarm/review-classifier.md b/docs/swarm/review-classifier.md index 58c27dfc..48ac2af6 100644 --- a/docs/swarm/review-classifier.md +++ b/docs/swarm/review-classifier.md @@ -232,7 +232,7 @@ key. 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 -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. `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 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. The repo also needs a rules file on its default branch (see diff --git a/docs/swarm/services.md b/docs/swarm/services.md index 154ac7a3..3a097d7c 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -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 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. -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). diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 2195fc50..e7e9290a 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -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 | | `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/issues` | a cross-repo issue report | -| `/review-runs` | the review classifier's recent runs: which PRs, what it answered, which failed | -| `/review-tryout` | try a review classification against a real PR | +| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings | Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). 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 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 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 @@ -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 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. 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 @@ -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 ([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 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 diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index b6c34ec2..5fac588c 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -8,8 +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 { ReviewRunsPage } from "./pages/review-runs/ReviewRunsPage.js"; -import { ReviewTryOutPage } from "./pages/review-tryout/ReviewTryOutPage.js"; +import { ReviewPage } from "./pages/review/ReviewPage.js"; import { SchedulesPage } from "./pages/schedules/SchedulesPage.js"; import { StartPage } from "./pages/StartPage.js"; import { Panel } from "./ui/panel/Panel.js"; @@ -37,8 +36,7 @@ export function App() { - - + diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.css b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.css similarity index 100% rename from frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.css rename to frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.css diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.tsx b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx similarity index 94% rename from frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.tsx rename to frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx index 6cabe1b0..7e14d594 100644 --- a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx @@ -1,8 +1,8 @@ -// — the review classifier's recent runs, newest first +// — 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 page -// shows. Reads `GET /api/review-classifier/runs`. The page stores nothing and +// 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"; @@ -25,7 +25,7 @@ import { durationLabel, type ClassifierRun, } from "./RunsTypes.js"; -import "./ReviewRunsPage.css"; +import "./ReviewRuns.css"; const RUNS = "/api/review-classifier/runs"; const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; @@ -91,7 +91,7 @@ function RunCard({ ); } -export function ReviewRunsPage() { +export function ReviewRuns() { const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); const [runs, setRuns] = useState(null); @@ -126,7 +126,7 @@ export function ReviewRunsPage() { return ( (null); const [saving, setSaving] = 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(() => { let cancelled = false; @@ -114,7 +118,13 @@ export function ClassifierSettings() { const problem = draft === null ? null : settingsProblem(draft); return ( - +

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 @@ -234,6 +244,6 @@ export function ClassifierSettings() { ) : null} ) : null} - + ); } diff --git a/frontend/packages/swarm-ui/src/pages/review-settings/SettingsTypes.ts b/frontend/packages/swarm-ui/src/pages/review-settings/SettingsTypes.ts index 58995b7c..581f139b 100644 --- a/frontend/packages/swarm-ui/src/pages/review-settings/SettingsTypes.ts +++ b/frontend/packages/swarm-ui/src/pages/review-settings/SettingsTypes.ts @@ -182,3 +182,15 @@ export function changed( 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(" · "); +} diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ClassificationEditor.tsx b/frontend/packages/swarm-ui/src/pages/review-tryout/ClassificationEditor.tsx index bdcc2eec..63a4d63d 100644 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/ClassificationEditor.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-tryout/ClassificationEditor.tsx @@ -6,7 +6,7 @@ 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"; +import "./ReviewTryOut.css"; const POLARITY_OPTIONS = [ { value: "positive", label: "positive — the PR should match" }, diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.css b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.css similarity index 100% rename from frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.css rename to frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.css diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx similarity index 95% rename from frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx rename to frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx index 78fcc663..da344bbb 100644 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx @@ -1,4 +1,4 @@ -// — write a review classification and see how the model +// — 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 @@ -18,11 +18,8 @@ // rules file, with the operator as commit author, and links to the forge's page // 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 -// 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 // remembers is the result table's column filters, as for every table in the UI. 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 { 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 { ClassifierSettings } from "../review-settings/ClassifierSettings.js"; import { ClassificationEditor } from "./ClassificationEditor.js"; import { TryOutResult } from "./TryOutResult.js"; import { @@ -49,7 +46,7 @@ import { type RulesAnswer, type TryOutResult as Result, } from "./TryOutTypes.js"; -import "./ReviewTryOutPage.css"; +import "./ReviewTryOut.css"; const TRY_OUT = "/api/review-classifier/try-out"; const RULES = "/api/review-classifier/rules"; @@ -76,7 +73,7 @@ function blankDraft(): ClassificationDraft { }; } -export function ReviewTryOutPage() { +export function ReviewTryOut() { const [repo, setRepo] = useState(""); const [pr, setPr] = useState(""); const [drafts, setDrafts] = useState(() => [ @@ -114,6 +111,10 @@ export function ReviewTryOutPage() { 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; @@ -313,7 +314,13 @@ export function ReviewTryOutPage() { return (

- +

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: @@ -426,7 +433,7 @@ export function ReviewTryOutPage() {

) : null} -
+ : null} ) : null} -
); } diff --git a/frontend/packages/swarm-ui/src/pages/review/ReviewPage.css b/frontend/packages/swarm-ui/src/pages/review/ReviewPage.css new file mode 100644 index 00000000..24cb3597 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/review/ReviewPage.css @@ -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; +} diff --git a/frontend/packages/swarm-ui/src/pages/review/ReviewPage.tsx b/frontend/packages/swarm-ui/src/pages/review/ReviewPage.tsx new file mode 100644 index 00000000..5c148e7c --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/review/ReviewPage.tsx @@ -0,0 +1,20 @@ +// — 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 ( +
+ + + +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 1f1b7746..05cbcc46 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -54,8 +54,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-runs", label: "review runs", accent: "var(--amber)" }, - { href: "/review-tryout", label: "review try-out", accent: "var(--red)" }, + { href: "/review", label: "review", accent: "var(--amber)" }, { href: "/components", label: "components", accent: "var(--blue)" }, ]; diff --git a/frontend/packages/swarm-ui/src/shell/routes.ts b/frontend/packages/swarm-ui/src/shell/routes.ts index e897e126..45a7a708 100644 --- a/frontend/packages/swarm-ui/src/shell/routes.ts +++ b/frontend/packages/swarm-ui/src/shell/routes.ts @@ -9,12 +9,7 @@ // 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", - "/review-runs", - "/review-tryout", -]); +const WIDE_BODY_ROUTES = new Set(["/issues", "/agents", "/review"]); // `/agents/:name/terminal` (and a subagent's, under // `/agents/:name/subagents/:subagent/`) is full-bleed: the terminal fills diff --git a/frontend/packages/swarm-ui/src/ui/disclosure/Disclosure.css b/frontend/packages/swarm-ui/src/ui/disclosure/Disclosure.css new file mode 100644 index 00000000..910da3c9 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/disclosure/Disclosure.css @@ -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; + } +} diff --git a/frontend/packages/swarm-ui/src/ui/disclosure/Disclosure.tsx b/frontend/packages/swarm-ui/src/ui/disclosure/Disclosure.tsx new file mode 100644 index 00000000..07cbc87f --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/disclosure/Disclosure.tsx @@ -0,0 +1,50 @@ +// — 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 `
`, 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 ( +
onToggle((e.currentTarget as HTMLDetailsElement).open)} + > + + {icon ? ( + + ) : null} +

{title}

+ {hint ? {hint} : null} +
+
{children}
+
+ ); +} diff --git a/frontend/packages/swarm-ui/test/routes.test.mjs b/frontend/packages/swarm-ui/test/routes.test.mjs index ce10142c..f0ccf453 100644 --- a/frontend/packages/swarm-ui/test/routes.test.mjs +++ b/frontend/packages/swarm-ui/test/routes.test.mjs @@ -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", () => { - for (const path of ["/issues", "/agents", "/review-runs", "/review-tryout"]) { + for (const path of ["/issues", "/agents", "/review"]) { assert.equal(isWideBodyRoute(path), true, path); } for (const path of ["/", "/jobs", "/schedules", "/components"]) { diff --git a/frontend/packages/swarm-ui/test/settings-types.test.mjs b/frontend/packages/swarm-ui/test/settings-types.test.mjs index e76cddfa..9de6d20f 100644 --- a/frontend/packages/swarm-ui/test/settings-types.test.mjs +++ b/frontend/packages/swarm-ui/test/settings-types.test.mjs @@ -10,6 +10,7 @@ import { maxValue, priceValue, settingsProblem, + settingsSummary, toSettings, } 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("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", + ); +});