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:
parent
7b1435eaea
commit
a68d73fa74
19 changed files with 223 additions and 45 deletions
|
|
@ -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() {
|
|||
<Route path="/jobs" component={JobsPage} />
|
||||
<Route path="/schedules" component={SchedulesPage} />
|
||||
<Route path="/issues" component={IssueReportPage} />
|
||||
<Route path="/review-runs" component={ReviewRunsPage} />
|
||||
<Route path="/review-tryout" component={ReviewTryOutPage} />
|
||||
<Route path="/review" component={ReviewPage} />
|
||||
<Route path="/components" component={ComponentsPage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
// 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<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const [runs, setRuns] = useState<ClassifierRun[] | null>(null);
|
||||
|
|
@ -126,7 +126,7 @@ export function ReviewRunsPage() {
|
|||
|
||||
return (
|
||||
<Panel
|
||||
title="review runs"
|
||||
title="recent runs"
|
||||
icon="📜"
|
||||
actions={
|
||||
<RefreshIntervalPicker
|
||||
|
|
@ -10,7 +10,7 @@ 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 { Disclosure } from "../../ui/disclosure/Disclosure.js";
|
||||
import { SelectField } from "../../ui/select-field/SelectField.js";
|
||||
import { TextField } from "../../ui/text-field/TextField.js";
|
||||
import {
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
changed,
|
||||
draftOf,
|
||||
settingsProblem,
|
||||
settingsSummary,
|
||||
toSettings,
|
||||
type ClassifierSettingsBody,
|
||||
type Runtime,
|
||||
|
|
@ -54,6 +55,9 @@ export function ClassifierSettings() {
|
|||
const [saveError, setSaveError] = useState<ProblemDetails | null>(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 (
|
||||
<Panel title="classifier settings" icon="⚙️">
|
||||
<Disclosure
|
||||
title="classifier settings"
|
||||
icon="⚙️"
|
||||
hint={saved ? settingsSummary(saved) : undefined}
|
||||
open={open}
|
||||
onToggle={setOpen}
|
||||
>
|
||||
<p>
|
||||
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}
|
||||
</form>
|
||||
) : null}
|
||||
</Panel>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(" · ");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// (`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<ClassificationDraft[]>(() => [
|
||||
|
|
@ -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 (
|
||||
<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>
|
||||
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() {
|
|||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</Panel>
|
||||
</Disclosure>
|
||||
<ConfirmDialog
|
||||
open={confirmLoad}
|
||||
label="replace the classifications"
|
||||
|
|
@ -462,7 +469,6 @@ export function ReviewTryOutPage() {
|
|||
{result?.state === "done" ? <TryOutResult result={result} /> : null}
|
||||
</Panel>
|
||||
) : null}
|
||||
<ClassifierSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
20
frontend/packages/swarm-ui/src/pages/review/ReviewPage.tsx
Normal file
20
frontend/packages/swarm-ui/src/pages/review/ReviewPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)" },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
47
frontend/packages/swarm-ui/src/ui/disclosure/Disclosure.css
Normal file
47
frontend/packages/swarm-ui/src/ui/disclosure/Disclosure.css
Normal 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;
|
||||
}
|
||||
}
|
||||
50
frontend/packages/swarm-ui/src/ui/disclosure/Disclosure.tsx
Normal file
50
frontend/packages/swarm-ui/src/ui/disclosure/Disclosure.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"]) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue