diff --git a/docs/swarm/review-classifier.md b/docs/swarm/review-classifier.md index 48ac2af6..58c27dfc 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 section +authors every classifier review, and it pushes the branches the try-out page 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's **open a PR** pushes a branch to the repo itself, so it +The try-out page'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 3a097d7c..154ac7a3 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 drives it through swarm-controller; what a run is +The swarm UI's try-out page 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 8cd3539e..2195fc50 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -17,7 +17,8 @@ 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` | the review classifier: its recent runs, a try-out on a real PR, and its settings | +| `/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 | Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). An agent created here or with `swarmctl agent create` starts `paused`; set it @@ -56,18 +57,9 @@ 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 +### Review runs -`/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 +`/review-runs` 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 @@ -92,9 +84,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. -#### Try a classification +### Review try-out -The **try a classification** section shows how the [review classifier](review-classifier.md) +`/review-tryout` 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 @@ -138,9 +130,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 -The **classifier settings** section shows the runtime, model and +Under the try-out form, **classifier settings** 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 5fac588c..b6c34ec2 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -8,7 +8,8 @@ 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 { ReviewPage } from "./pages/review/ReviewPage.js"; +import { ReviewRunsPage } from "./pages/review-runs/ReviewRunsPage.js"; +import { ReviewTryOutPage } from "./pages/review-tryout/ReviewTryOutPage.js"; import { SchedulesPage } from "./pages/schedules/SchedulesPage.js"; import { StartPage } from "./pages/StartPage.js"; import { Panel } from "./ui/panel/Panel.js"; @@ -36,7 +37,8 @@ export function App() { - + + diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.css b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.css similarity index 100% rename from frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.css rename to frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.css diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.tsx similarity index 94% rename from frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx rename to frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.tsx index 7e14d594..6cabe1b0 100644 --- a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRunsPage.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 -// shows. Reads `GET /api/review-classifier/runs`. This section stores nothing and +// 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 // 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 "./ReviewRuns.css"; +import "./ReviewRunsPage.css"; const RUNS = "/api/review-classifier/runs"; const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; @@ -91,7 +91,7 @@ function RunCard({ ); } -export function ReviewRuns() { +export function ReviewRunsPage() { const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); const [runs, setRuns] = useState(null); @@ -126,7 +126,7 @@ export function ReviewRuns() { 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; @@ -118,13 +114,7 @@ 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 @@ -244,6 +234,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 581f139b..58995b7c 100644 --- a/frontend/packages/swarm-ui/src/pages/review-settings/SettingsTypes.ts +++ b/frontend/packages/swarm-ui/src/pages/review-settings/SettingsTypes.ts @@ -182,15 +182,3 @@ 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 63a4d63d..bdcc2eec 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 "./ReviewTryOut.css"; +import "./ReviewTryOutPage.css"; const POLARITY_OPTIONS = [ { value: "positive", label: "positive — the PR should match" }, diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.css b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.css similarity index 100% rename from frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.css rename to frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.css diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx similarity index 95% rename from frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx rename to frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx index da344bbb..78fcc663 100644 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.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,8 +18,11 @@ // 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. The section keeps nothing you typed and no +// again. Apart from the settings, the page 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"; @@ -27,9 +30,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 { @@ -46,7 +49,7 @@ import { type RulesAnswer, type TryOutResult as Result, } from "./TryOutTypes.js"; -import "./ReviewTryOut.css"; +import "./ReviewTryOutPage.css"; const TRY_OUT = "/api/review-classifier/try-out"; const RULES = "/api/review-classifier/rules"; @@ -73,7 +76,7 @@ function blankDraft(): ClassificationDraft { }; } -export function ReviewTryOut() { +export function ReviewTryOutPage() { const [repo, setRepo] = useState(""); const [pr, setPr] = useState(""); const [drafts, setDrafts] = useState(() => [ @@ -111,10 +114,6 @@ export function ReviewTryOut() { 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; @@ -314,13 +313,7 @@ export function ReviewTryOut() { 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: @@ -433,7 +426,7 @@ export function ReviewTryOut() {

) : 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 deleted file mode 100644 index 24cb3597..00000000 --- a/frontend/packages/swarm-ui/src/pages/review/ReviewPage.css +++ /dev/null @@ -1,6 +0,0 @@ -/* 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 deleted file mode 100644 index 5c148e7c..00000000 --- a/frontend/packages/swarm-ui/src/pages/review/ReviewPage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// — 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 05cbcc46..1f1b7746 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -54,7 +54,8 @@ 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", label: "review", accent: "var(--amber)" }, + { href: "/review-runs", label: "review runs", accent: "var(--amber)" }, + { href: "/review-tryout", label: "review try-out", accent: "var(--red)" }, { 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 45a7a708..e897e126 100644 --- a/frontend/packages/swarm-ui/src/shell/routes.ts +++ b/frontend/packages/swarm-ui/src/shell/routes.ts @@ -9,7 +9,12 @@ // 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"]); +const WIDE_BODY_ROUTES = new Set([ + "/issues", + "/agents", + "/review-runs", + "/review-tryout", +]); // `/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 deleted file mode 100644 index 910da3c9..00000000 --- a/frontend/packages/swarm-ui/src/ui/disclosure/Disclosure.css +++ /dev/null @@ -1,47 +0,0 @@ -/* 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 deleted file mode 100644 index 07cbc87f..00000000 --- a/frontend/packages/swarm-ui/src/ui/disclosure/Disclosure.tsx +++ /dev/null @@ -1,50 +0,0 @@ -// — 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 f0ccf453..ce10142c 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"]) { + for (const path of ["/issues", "/agents", "/review-runs", "/review-tryout"]) { 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 9de6d20f..e76cddfa 100644 --- a/frontend/packages/swarm-ui/test/settings-types.test.mjs +++ b/frontend/packages/swarm-ui/test/settings-types.test.mjs @@ -10,7 +10,6 @@ import { maxValue, priceValue, settingsProblem, - settingsSummary, toSettings, } from "../src/pages/review-settings/SettingsTypes.ts"; @@ -118,29 +117,3 @@ 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", - ); -});