diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 34e69c3d..b7ef3651 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -16,7 +16,6 @@ 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-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 @@ -47,27 +46,6 @@ 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 try-out - -`/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 -to 100. **add a classification** adds more, and **run** starts the run. - -A run takes a minute or more, and up to half an hour for ten classifications. -The page says it's running and shows how long it has been, then shows the review's table: for each classification the verdict, -the model's certainty, the threshold, whether the verdict counted and the -reason. A classification that couldn't run shows its error where the reason -would be. Under the table are the overall verdict, approve or request changes, -and the head commit the run read. A failed run shows its error, for example when the swarm -has no API key yet. - -What you typed stays after a run, so you can edit the classification and run it -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. - ### Scheduled prompts `/schedules` has one card per scheduled prompt. The **name** is the card's diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 44493a1e..bd20222d 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -8,7 +8,6 @@ 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 { ReviewTryOutPage } from "./pages/review-tryout/ReviewTryOutPage.js"; import { SchedulesPage } from "./pages/schedules/SchedulesPage.js"; import { Panel } from "./ui/panel/Panel.js"; @@ -34,7 +33,6 @@ export function App() { - diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ClassificationEditor.tsx b/frontend/packages/swarm-ui/src/pages/review-tryout/ClassificationEditor.tsx deleted file mode 100644 index bdcc2eec..00000000 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/ClassificationEditor.tsx +++ /dev/null @@ -1,65 +0,0 @@ -// — the fields of one review classification: a name, a -// description (the prompt the model answers), which answer passes, and how -// certain the model has to be for its answer to count. -import { Button } from "../../ui/button/Button.js"; -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"; - -const POLARITY_OPTIONS = [ - { value: "positive", label: "positive — the PR should match" }, - { value: "negative", label: "negative — the PR should not match" }, -]; - -export function ClassificationEditor({ - draft, - onChange, - onRemove, -}: { - draft: ClassificationDraft; - onChange: (next: ClassificationDraft) => void; - /** Absent when this is the only classification: one is the minimum. */ - onRemove?: () => void; -}) { - const id = `tryout-${draft.key}`; - return ( -
- onChange({ ...draft, name })} - /> - onChange({ ...draft, description })} - /> -
- - onChange({ ...draft, polarity: polarity as Polarity }) - } - /> - onChange({ ...draft, threshold })} - /> - {onRemove ? : null} -
-
- ); -} diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.css b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.css deleted file mode 100644 index 1d51e603..00000000 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.css +++ /dev/null @@ -1,66 +0,0 @@ -/* 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-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); -} -.ui-tryout-row-error { - color: var(--red); -} -.ui-tryout-overall, -.ui-tryout-sha { - margin: 0.75em 0 0; -} -.ui-tryout-sha { - color: var(--muted); -} diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx deleted file mode 100644 index 8c134dfa..00000000 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOutPage.tsx +++ /dev/null @@ -1,239 +0,0 @@ -// — 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 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. -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 { TextField } from "../../ui/text-field/TextField.js"; -import { ClassificationEditor } from "./ClassificationEditor.js"; -import { TryOutResult } from "./TryOutResult.js"; -import { - draftProblem, - duplicateName, - prNumber, - repoProblem, - toBody, - type ClassificationDraft, - type TryOutResult as Result, -} from "./TryOutTypes.js"; -import "./ReviewTryOutPage.css"; - -const TRY_OUT = "/api/review-classifier/try-out"; -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 ReviewTryOutPage() { - const [repo, setRepo] = useState(""); - const [pr, setPr] = useState(""); - const [drafts, setDrafts] = useState(() => [ - blankDraft(), - ]); - // The id of the run being polled; null when none is in flight. - const [runId, setRunId] = useState(null); - const [starting, setStarting] = useState(false); - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - // Whole seconds since the run started, so a long wait shows it is alive. - const [elapsed, setElapsed] = useState(0); - - const running = starting || runId !== null; - - 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 | 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 problem = - repoProblem(repo) ?? - (prNumber(pr) === null ? "a PR number is a whole number above 0" : null) ?? - drafts.map(draftProblem).find((p) => p !== null) ?? - duplicateName(drafts) ?? - null; - - 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); - } - } - - function changeDraft(next: ClassificationDraft) { - setDrafts((ds) => ds.map((d) => (d.key === next.key ? next : d))); - } - - return ( -
- -

- Run a review classification against a real PR and see how the model - answers it. Nothing is posted to the forge, and nothing is saved: - change the classification and run it again until it answers the way - you want. -

-
void run(e)}> -
- - -
- {drafts.map((d) => ( - 1 - ? () => setDrafts((ds) => ds.filter((x) => x.key !== d.key)) - : undefined - } - /> - ))} -
- - -
- {problem !== null ?

{problem}

: null} - -
- {running || result || error ? ( - - {running ? ( -

- Running — a run takes a minute or more, and up to half an hour for - a long list ({elapsed}s so far). -

- ) : null} - {error ? ( - - ) : null} - {result?.state === "failed" ? ( - - ) : null} - {result?.state === "done" ? : null} -
- ) : null} -
- ); -} diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.tsx b/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.tsx deleted file mode 100644 index 771f52ec..00000000 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.tsx +++ /dev/null @@ -1,74 +0,0 @@ -// — a finished try-out: one row per classification, the -// overall verdict under them, and the head commit the run read. The columns -// are the review's own table (`docs/swarm/review-classifier.md`); no token -// counts, cache counts or cost, here or anywhere in the UI. -import { Badge } from "@hive/shared/badge.js"; -import { Table, type TableColumn } from "../../ui/table/Table.js"; -import type { TryOutResult as Result, TryOutRow } from "./TryOutTypes.js"; - -const VERDICT_LABEL: Record = { - match: "match", - "no-match": "no match", - "not-applicable": "not applicable", -}; - -const COLUMNS: TableColumn[] = [ - { key: "name", header: "classification", render: (r) => r.name }, - { key: "polarity", header: "polarity", render: (r) => r.polarity }, - { - key: "verdict", - header: "verdict", - render: (r) => (r.error ? "—" : VERDICT_LABEL[r.verdict]), - }, - { - key: "certainty", - header: "certainty", - render: (r) => (r.error ? "—" : r.certainty), - }, - { key: "threshold", header: "threshold", render: (r) => r.threshold }, - { - key: "counted", - header: "counted", - // A counted row shows whether it passed its polarity; one below its - // threshold does not count and has no pass or fail. - render: (r) => - r.error ? "—" : r.counted ? (r.passed ? "yes ✅" : "yes ❌") : "no", - }, - { - key: "reason", - header: "reason", - cellClass: "ui-table-prose", - // A row that could not run says why where the reason would be. - render: (r) => - r.error ? {r.error} : r.reason, - }, -]; - -export function TryOutResult({ result }: { result: Result }) { - const rows = result.rows ?? []; - return ( -
- String(rows.indexOf(r))} - storageKey="swarm-ui:review-tryout:table-filters" - emptyMessage="the run returned no classifications" - /> - {result.overall ? ( -

- Overall: - -

- ) : null} -

- Ran on head {result.head_sha.slice(0, 12)} -

- - ); -} diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutTypes.ts b/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutTypes.ts deleted file mode 100644 index 1114448f..00000000 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutTypes.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Wire shapes of the review classifier's try-out endpoints -// (`POST /api/review-classifier/try-out`, `GET .../try-out/{id}`), and the -// checks the form makes before a request is worth sending. The backend stays -// the real gate: these only keep an obviously bad form from a round trip. - -export type Polarity = "positive" | "negative"; - -/** One classification as the form holds it: every field as the user typed it. */ -export interface ClassificationDraft { - /** Local only, so a row keeps its identity when another is removed. */ - key: number; - name: string; - description: string; - polarity: Polarity; - threshold: string; -} - -/** The request's classification: what the backend is sent. */ -export interface ClassificationBody { - name: string; - description: string; - polarity: Polarity; - threshold: number; -} - -export type Verdict = "match" | "no-match" | "not-applicable"; - -export interface TryOutRow { - name: string; - polarity: Polarity; - verdict: Verdict; - certainty: number; - threshold: number; - /** Whether the verdict counted: its certainty reached the threshold. */ - counted: boolean; - /** For a counted row, whether it passed its polarity. */ - passed: boolean; - reason: string; - /** Set when this classification could not be run. */ - error?: string; -} - -export type TryOutOverall = "approve" | "request-changes"; - -export interface TryOutResult { - state: "running" | "done" | "failed"; - head_sha: string; - rows?: TryOutRow[]; - overall?: TryOutOverall; - error?: string; -} - -// Characters the endpoint accepts in each half of a repo: letters, digits, `.`, -// `_` and `-`, other than `.` and `..` themselves. -const SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/; - -export function repoProblem(repo: string): string | null { - const parts = repo.trim().split("/"); - return parts.length === 2 && parts.every((p) => SEGMENT.test(p)) - ? null - : "a repo is owner/name, with letters, digits, . _ and - in each part"; -} - -export function prNumber(pr: string): number | null { - const n = Number(pr.trim()); - return /^\d+$/.test(pr.trim()) && n > 0 ? n : null; -} - -/** The threshold as a number, or null when it is not a whole 0–100. */ -export function thresholdValue(text: string): number | null { - const t = text.trim(); - if (!/^\d+$/.test(t)) return null; - const n = Number(t); - return n <= 100 ? n : null; -} - -/** Why a classification is not ready to send, or null when it is. */ -export function draftProblem(d: ClassificationDraft): string | null { - if (d.name.trim() === "") return "give each classification a name"; - if (d.description.trim() === "") { - return `${d.name.trim()} needs a description`; - } - if (thresholdValue(d.threshold) === null) { - return `${d.name.trim()}: the threshold is a whole number from 0 to 100`; - } - return null; -} - -/** A name two classifications share, as a problem, or null: the result table - * names its rows by it, so a shared name leaves two rows nobody can tell apart. */ -export function duplicateName(drafts: ClassificationDraft[]): string | null { - const seen = new Set(); - for (const d of drafts) { - const name = d.name.trim(); - if (seen.has(name)) return `two classifications are named ${name}`; - seen.add(name); - } - return null; -} - -export function toBody(d: ClassificationDraft): ClassificationBody { - return { - name: d.name.trim(), - description: d.description, - polarity: d.polarity, - threshold: thresholdValue(d.threshold) ?? 0, - }; -} diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 324d7ad9..7989fc6f 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -51,7 +51,6 @@ 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-tryout", label: "review try-out", accent: "var(--red)" }, { href: "/components", label: "components", accent: "var(--blue)" }, ]; @@ -62,7 +61,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ // 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-tryout"]); +const WIDE_BODY_ROUTES = new Set(["/issues", "/agents"]); // `/agents/:name/terminal` (and a subagent's, under // `/agents/:name/subagents/:subagent/`) is full-bleed: the terminal fills