From baf282131fc25d851262d80f7f873a394310f317 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 12 Oct 2026 20:04:15 +0200 Subject: [PATCH] swarm-ui: the review try-out page shows and changes the review classifier's settings A classifier settings panel under the try-out form reads and saves the model, effort and how many requested reviews run at once through the settings route. A save replaces all three; a refused one shows the route's reason and leaves the form as typed. There is no API key field. --- docs/swarm/ui.md | 14 ++ .../review-settings/ClassifierSettings.css | 25 +++ .../review-settings/ClassifierSettings.tsx | 167 ++++++++++++++++++ .../pages/review-settings/SettingsTypes.ts | 79 +++++++++ .../pages/review-tryout/ReviewTryOutPage.tsx | 11 +- 5 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.css create mode 100644 frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.tsx create mode 100644 frontend/packages/swarm-ui/src/pages/review-settings/SettingsTypes.ts diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 1b5b1d5f..fac28025 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -116,6 +116,20 @@ again. The page saves no classification and no result, and posts nothing to the forge; like every table in the UI, the result table remembers its column filters in the browser. It shows no token counts, cache counts or cost. +### Classifier settings + +Under the try-out form, **classifier settings** shows the 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 model is free text, one word +as claude's `--model` takes it, such as `haiku` or `sonnet`. The effort is one +of low, medium, high, xhigh and max, or claude's own default. Concurrent +reviews is a whole number from 1 to 8; try-outs don't count toward it. + +**save** is on once something changed and the fields are valid. A save replaces +all three settings. If the controller refuses it, the page shows the reason and +leaves the form as you typed it. A change takes effect from the next run, with +no restart. The page has no API key field: admins set the key in the bao UI. + ### Scheduled prompts `/schedules` has one card per scheduled prompt. The **name** is the card's diff --git a/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.css b/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.css new file mode 100644 index 00000000..01757fca --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.css @@ -0,0 +1,25 @@ +/* The settings form: the three fields on one line, then the save action. */ +.ui-classifier-settings { + display: flex; + flex-direction: column; + gap: 1em; + margin-top: 1em; +} +.ui-classifier-settings-fields { + display: flex; + flex-wrap: wrap; + gap: 0.75em; +} +.ui-classifier-settings-fields > * { + flex: 1 1 14em; +} +.ui-classifier-settings-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75em; +} +.ui-classifier-settings-note { + margin: 0; + color: var(--muted); +} diff --git a/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.tsx b/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.tsx new file mode 100644 index 00000000..154eab73 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.tsx @@ -0,0 +1,167 @@ +// — the review classifier's model, effort and how many +// requested reviews run at once, shown and saved here +// (`GET`/`PUT /api/review-classifier/settings`). A PUT replaces the stored +// settings whole, so the form sends all three fields. A refused save shows the +// route's own reason and leaves the form as typed. There is no API key field: +// admins set the key in the bao 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 { Panel } from "../../ui/panel/Panel.js"; +import { SelectField } from "../../ui/select-field/SelectField.js"; +import { TextField } from "../../ui/text-field/TextField.js"; +import { + EFFORTS, + MAX_REVIEWS, + MIN_REVIEWS, + changed, + draftOf, + settingsProblem, + toSettings, + type ClassifierSettingsBody, + type SettingsDraft, +} from "./SettingsTypes.js"; +import "./ClassifierSettings.css"; + +const SETTINGS = "/api/review-classifier/settings"; + +const EFFORT_OPTIONS = [ + { value: "", label: "claude's default" }, + ...EFFORTS.map((e) => ({ value: e, label: e })), +]; + +export function ClassifierSettings() { + // The settings as the controller holds them; null until the first read. + const [saved, setSaved] = useState(null); + const [draft, setDraft] = useState(null); + const [loadError, setLoadError] = useState(null); + const [saveError, setSaveError] = useState(null); + const [saving, setSaving] = useState(false); + const [justSaved, setJustSaved] = useState(false); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const res = await fetch(SETTINGS); + if (cancelled) return; + if (!res.ok) { + setLoadError(await readApiError(res)); + return; + } + const body = (await res.json()) as ClassifierSettingsBody; + if (cancelled) return; + setSaved(body); + setDraft(draftOf(body)); + } catch (e: unknown) { + if (!cancelled) setLoadError({ detail: String(e) }); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + function edit(next: SettingsDraft) { + setDraft(next); + setJustSaved(false); + } + + async function save(e: Event) { + e.preventDefault(); + if (draft === null || settingsProblem(draft) !== null) return; + setSaving(true); + setSaveError(null); + setJustSaved(false); + try { + const res = await fetch(SETTINGS, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(toSettings(draft)), + }); + if (!res.ok) { + // The form stays as typed, so the operator can fix it and save again. + setSaveError(await readApiError(res)); + return; + } + const body = (await res.json()) as ClassifierSettingsBody; + setSaved(body); + setDraft(draftOf(body)); + setJustSaved(true); + } catch (err: unknown) { + setSaveError({ detail: String(err) }); + } finally { + setSaving(false); + } + } + + const problem = draft === null ? null : settingsProblem(draft); + + return ( + +

+ The model and effort the review classifier runs with, and how many + requested reviews run at once. A change takes effect from the next run, + with no restart. Try-outs don't count toward the limit. The API key is + set in the bao UI, not here. +

+ {loadError ? ( + + ) : null} + {draft ? ( +
void save(e)}> +
+ edit({ ...draft, model })} + /> + edit({ ...draft, effort })} + /> + edit({ ...draft, max })} + /> +
+ {saveError ? ( + + ) : null} +
+ + {justSaved ? ( + + Saved. + + ) : null} +
+ {problem !== null ? ( +

{problem}

+ ) : 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 new file mode 100644 index 00000000..a012c01e --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/review-settings/SettingsTypes.ts @@ -0,0 +1,79 @@ +// Wire shape of the review classifier's settings +// (`GET`/`PUT /api/review-classifier/settings`), and the checks the form +// makes before a save is worth sending. The backend stays the real gate: +// these only keep an obviously bad form from a round trip. + +export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; +export type Effort = (typeof EFFORTS)[number]; + +/** The settings in force. A PUT replaces them whole. */ +export interface ClassifierSettingsBody { + model: string; + /** `null` is claude's own default, which passes no `--effort`. */ + effort: Effort | null; + max_concurrent_reviews: number; +} + +/** The form's fields as the user typed them. */ +export interface SettingsDraft { + model: string; + /** An effort, or `""` for claude's default. */ + effort: string; + max: string; +} + +export const MIN_REVIEWS = 1; +export const MAX_REVIEWS = 8; + +export function draftOf(s: ClassifierSettingsBody): SettingsDraft { + return { + model: s.model, + effort: s.effort ?? "", + max: String(s.max_concurrent_reviews), + }; +} + +/** The reviews limit as a number, or null when it is not a whole number in + * range. */ +export function maxValue(text: string): number | null { + const t = text.trim(); + if (!/^\d+$/.test(t)) return null; + const n = Number(t); + return n >= MIN_REVIEWS && n <= MAX_REVIEWS ? n : null; +} + +/** Why the draft is not ready to save, or null when it is. */ +export function settingsProblem(d: SettingsDraft): string | null { + const model = d.model.trim(); + if (model === "") return "give a model"; + if (/\s/.test(model) || model.startsWith("-")) { + return "a model is one word, as claude's --model takes it, and does not start with -"; + } + if (maxValue(d.max) === null) { + return `concurrent reviews is a whole number from ${MIN_REVIEWS} to ${MAX_REVIEWS}`; + } + return null; +} + +/** The draft as the request body. Call only when `settingsProblem` is null. */ +export function toSettings(d: SettingsDraft): ClassifierSettingsBody { + return { + model: d.model.trim(), + effort: d.effort === "" ? null : (d.effort as Effort), + max_concurrent_reviews: maxValue(d.max) ?? MIN_REVIEWS, + }; +} + +/** Whether `d` would save something other than `saved`. */ +export function changed( + d: SettingsDraft, + saved: ClassifierSettingsBody | null, +): boolean { + if (saved === null) return true; + const now = draftOf(saved); + return ( + d.model.trim() !== now.model || + d.effort !== now.effort || + d.max.trim() !== now.max + ); +} diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx index 99cc9423..879299d3 100644 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx @@ -13,10 +13,13 @@ // **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. // +// 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 page keeps nothing you typed and no result, and 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. +// again. Apart from the settings, the page keeps nothing you typed and no +// result, and 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"; @@ -24,6 +27,7 @@ import { Button } from "../../ui/button/Button.js"; import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.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 { @@ -399,6 +403,7 @@ export function ReviewTryOutPage() { {result?.state === "done" ? : null} ) : null} + ); }