Compare commits
19 changed files with 45 additions and 223 deletions
|
|
@ -232,7 +232,7 @@ key.
|
||||||
|
|
||||||
A dedicated forge user, separate from swarm-controller's site-admin account
|
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
|
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.
|
creates.
|
||||||
|
|
||||||
`swarm-controller` creates it as `review-classifier`, with the email
|
`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
|
allowlist, the account counts only once it's on that list, and read access
|
||||||
is then enough for reviews.
|
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.
|
needs **write** whatever the branch protection says.
|
||||||
|
|
||||||
The repo also needs a rules file on its default branch (see
|
The repo also needs a rules file on its default branch (see
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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.
|
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).
|
and what it costs are in [`review-classifier.md`](review-classifier.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 |
|
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
||||||
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
|
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
|
||||||
| `/issues` | a cross-repo issue report |
|
| `/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).
|
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
|
||||||
An agent created here or with `swarmctl agent create` starts `paused`; set it
|
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
|
agent, or link an account, without leaving the terminal. A subagent's tab opens
|
||||||
its agent's details.
|
its agent's details.
|
||||||
|
|
||||||
### Review
|
### Review runs
|
||||||
|
|
||||||
`/review` is the one page for the [review classifier](review-classifier.md),
|
`/review-runs` lists what the [review classifier](review-classifier.md) has
|
||||||
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
|
|
||||||
run, newest first, one card per run. A card shows the repo and PR, the head
|
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
|
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
|
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
|
controller gave. The page shows no token counts, cache counts or cost, and
|
||||||
stores and posts nothing.
|
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.
|
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
|
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
|
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
|
a repo admin adds it as a collaborator with write access
|
||||||
([the service account](review-classifier.md#the-service-account)).
|
([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
|
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
|
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
|
key** (claude on the Anthropic key) or **ACP** (opencode on a provider). On the
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||||
import { JobsPage } from "./pages/JobsPage.js";
|
import { JobsPage } from "./pages/JobsPage.js";
|
||||||
import { HivesPage } from "./pages/HivesPage.js";
|
import { HivesPage } from "./pages/HivesPage.js";
|
||||||
import { IssueReportPage } from "./pages/IssueReportPage.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 { SchedulesPage } from "./pages/schedules/SchedulesPage.js";
|
||||||
import { StartPage } from "./pages/StartPage.js";
|
import { StartPage } from "./pages/StartPage.js";
|
||||||
import { Panel } from "./ui/panel/Panel.js";
|
import { Panel } from "./ui/panel/Panel.js";
|
||||||
|
|
@ -36,7 +37,8 @@ export function App() {
|
||||||
<Route path="/jobs" component={JobsPage} />
|
<Route path="/jobs" component={JobsPage} />
|
||||||
<Route path="/schedules" component={SchedulesPage} />
|
<Route path="/schedules" component={SchedulesPage} />
|
||||||
<Route path="/issues" component={IssueReportPage} />
|
<Route path="/issues" component={IssueReportPage} />
|
||||||
<Route path="/review" component={ReviewPage} />
|
<Route path="/review-runs" component={ReviewRunsPage} />
|
||||||
|
<Route path="/review-tryout" component={ReviewTryOutPage} />
|
||||||
<Route path="/components" component={ComponentsPage} />
|
<Route path="/components" component={ComponentsPage} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
/* The run list: one card per run, the head row a button that opens it. */
|
||||||
|
.ui-runs-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75em;
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
.ui-runs-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em 0.75em;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ui-runs-head:focus-visible {
|
||||||
|
outline: 2px solid var(--purple);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.ui-runs-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
|
.ui-runs-meta {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.ui-runs-head .ui-runs-meta {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
.ui-runs-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6em;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
.ui-runs-body p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.ui-runs-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
// <ReviewRunsPage> — 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
|
||||||
|
// 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";
|
||||||
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
|
import { Badge } from "@hive/shared/badge.js";
|
||||||
|
import { Card } from "../../ui/card/Card.js";
|
||||||
|
import { Panel } from "../../ui/panel/Panel.js";
|
||||||
|
import {
|
||||||
|
RefreshIntervalPicker,
|
||||||
|
useRefreshInterval,
|
||||||
|
type RefreshIntervalMs,
|
||||||
|
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||||
|
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||||
|
import { TryOutResult } from "../review-tryout/TryOutResult.js";
|
||||||
|
import {
|
||||||
|
OUTCOME_LABEL,
|
||||||
|
OUTCOME_TONE,
|
||||||
|
TRIGGER_LABEL,
|
||||||
|
classificationCount,
|
||||||
|
durationLabel,
|
||||||
|
type ClassifierRun,
|
||||||
|
} from "./RunsTypes.js";
|
||||||
|
import "./ReviewRunsPage.css";
|
||||||
|
|
||||||
|
const RUNS = "/api/review-classifier/runs";
|
||||||
|
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||||
|
|
||||||
|
function RunCard({
|
||||||
|
run,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
run: ClassifierRun;
|
||||||
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const bodyId = `review-run-${run.id}`;
|
||||||
|
return (
|
||||||
|
<Card class="ui-runs-card">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ui-runs-head"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={bodyId}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<span class="ui-runs-title">
|
||||||
|
<strong>
|
||||||
|
{run.repo} #{run.pr}
|
||||||
|
</strong>
|
||||||
|
{run.head_sha ? <code>{run.head_sha.slice(0, 7)}</code> : null}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
tone={OUTCOME_TONE[run.outcome]}
|
||||||
|
value={OUTCOME_LABEL[run.outcome]}
|
||||||
|
/>
|
||||||
|
<span class="ui-runs-meta">
|
||||||
|
{TRIGGER_LABEL[run.trigger]} ·{" "}
|
||||||
|
<RelativeTime epochMs={run.started_unix * 1000} /> · took{" "}
|
||||||
|
{durationLabel(run.started_unix, run.finished_unix)} ·{" "}
|
||||||
|
{classificationCount(run)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div class="ui-runs-body" id={bodyId}>
|
||||||
|
{run.detail ? <p class="ui-runs-detail">{run.detail}</p> : null}
|
||||||
|
{run.rows && run.rows.length > 0 ? (
|
||||||
|
<TryOutResult
|
||||||
|
result={run}
|
||||||
|
filterKey="swarm-ui:review-runs:table-filters"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{run.review_url ? (
|
||||||
|
<p class="ui-runs-link">
|
||||||
|
<a href={run.review_url} target="_blank" rel="noreferrer">
|
||||||
|
the posted review
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{run.requester ? (
|
||||||
|
<p class="ui-runs-meta">Requested by {run.requester}.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewRunsPage() {
|
||||||
|
const [intervalMs, setIntervalMs] =
|
||||||
|
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||||
|
const [runs, setRuns] = useState<ClassifierRun[] | null>(null);
|
||||||
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||||
|
// Which cards are expanded. Survives a refresh, so a list that updates
|
||||||
|
// under you does not fold the card you are reading.
|
||||||
|
const [open, setOpen] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(RUNS);
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(await readApiError(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRuns((await res.json()) as ClassifierRun[]);
|
||||||
|
setError(null);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError({ detail: String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useRefreshInterval(intervalMs, () => void load());
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
setOpen((cur) => {
|
||||||
|
const next = new Set(cur);
|
||||||
|
if (!next.delete(id)) next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title="review runs"
|
||||||
|
icon="📜"
|
||||||
|
actions={
|
||||||
|
<RefreshIntervalPicker
|
||||||
|
id="review-runs-refresh"
|
||||||
|
value={intervalMs}
|
||||||
|
onChange={setIntervalMs}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
The PRs the review classifier ran on, newest first: what it answered,
|
||||||
|
and which runs failed or were turned away. Select a run to see its
|
||||||
|
result table.
|
||||||
|
</p>
|
||||||
|
{error ? (
|
||||||
|
<ApiErrorPanel context="the runs could not be loaded" problem={error} />
|
||||||
|
) : null}
|
||||||
|
{runs !== null && runs.length === 0 && !error ? (
|
||||||
|
<p class="ui-runs-empty">No runs yet.</p>
|
||||||
|
) : null}
|
||||||
|
<div class="ui-runs-list">
|
||||||
|
{(runs ?? []).map((run) => (
|
||||||
|
<RunCard
|
||||||
|
key={run.id}
|
||||||
|
run={run}
|
||||||
|
open={open.has(run.id)}
|
||||||
|
onToggle={() => toggle(run.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ import { useEffect, useState } from "preact/hooks";
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
import { Button } from "../../ui/button/Button.js";
|
import { Button } from "../../ui/button/Button.js";
|
||||||
import { Disclosure } from "../../ui/disclosure/Disclosure.js";
|
import { Panel } from "../../ui/panel/Panel.js";
|
||||||
import { SelectField } from "../../ui/select-field/SelectField.js";
|
import { SelectField } from "../../ui/select-field/SelectField.js";
|
||||||
import { TextField } from "../../ui/text-field/TextField.js";
|
import { TextField } from "../../ui/text-field/TextField.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -20,7 +20,6 @@ import {
|
||||||
changed,
|
changed,
|
||||||
draftOf,
|
draftOf,
|
||||||
settingsProblem,
|
settingsProblem,
|
||||||
settingsSummary,
|
|
||||||
toSettings,
|
toSettings,
|
||||||
type ClassifierSettingsBody,
|
type ClassifierSettingsBody,
|
||||||
type Runtime,
|
type Runtime,
|
||||||
|
|
@ -55,9 +54,6 @@ export function ClassifierSettings() {
|
||||||
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
|
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [justSaved, setJustSaved] = 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -118,13 +114,7 @@ export function ClassifierSettings() {
|
||||||
const problem = draft === null ? null : settingsProblem(draft);
|
const problem = draft === null ? null : settingsProblem(draft);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Disclosure
|
<Panel title="classifier settings" icon="⚙️">
|
||||||
title="classifier settings"
|
|
||||||
icon="⚙️"
|
|
||||||
hint={saved ? settingsSummary(saved) : undefined}
|
|
||||||
open={open}
|
|
||||||
onToggle={setOpen}
|
|
||||||
>
|
|
||||||
<p>
|
<p>
|
||||||
The runtime, model and effort the review classifier runs with, and how
|
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
|
many requested reviews run at once. The API key runtime runs claude on
|
||||||
|
|
@ -244,6 +234,6 @@ export function ClassifierSettings() {
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
</Disclosure>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -182,15 +182,3 @@ export function changed(
|
||||||
d.priceCacheWrite.trim() !== now.priceCacheWrite)
|
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 { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
|
||||||
import { TextField } from "../../ui/text-field/TextField.js";
|
import { TextField } from "../../ui/text-field/TextField.js";
|
||||||
import type { ClassificationDraft, Polarity } from "./TryOutTypes.js";
|
import type { ClassificationDraft, Polarity } from "./TryOutTypes.js";
|
||||||
import "./ReviewTryOut.css";
|
import "./ReviewTryOutPage.css";
|
||||||
|
|
||||||
const POLARITY_OPTIONS = [
|
const POLARITY_OPTIONS = [
|
||||||
{ value: "positive", label: "positive — the PR should match" },
|
{ value: "positive", label: "positive — the PR should match" },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
/* 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-target > button {
|
||||||
|
flex: none;
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
.ui-tryout-loaded {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,468 @@
|
||||||
|
// <ReviewTryOutPage> — 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 repo and PR fields offer the repos that have an open PR and the open PRs
|
||||||
|
// of the repo (`GET /api/pulls/repos`, `GET /api/pulls?repo=…`), and stay free
|
||||||
|
// text: the run also works on a merged or closed PR.
|
||||||
|
//
|
||||||
|
// **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.
|
||||||
|
//
|
||||||
|
// **open a PR** posts the rows to `POST /api/review-classifier/open-pr`, which
|
||||||
|
// has the classifier's forge account push a branch adding them to the repo's
|
||||||
|
// 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
|
||||||
|
// 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";
|
||||||
|
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 { 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 {
|
||||||
|
draftFromRule,
|
||||||
|
hasTypedText,
|
||||||
|
prNumber,
|
||||||
|
repoProblem,
|
||||||
|
rowsProblem,
|
||||||
|
runProblem,
|
||||||
|
toBody,
|
||||||
|
type ClassificationDraft,
|
||||||
|
type OpenPrAnswer,
|
||||||
|
type OpenPull,
|
||||||
|
type RulesAnswer,
|
||||||
|
type TryOutResult as Result,
|
||||||
|
} from "./TryOutTypes.js";
|
||||||
|
import "./ReviewTryOutPage.css";
|
||||||
|
|
||||||
|
const TRY_OUT = "/api/review-classifier/try-out";
|
||||||
|
const RULES = "/api/review-classifier/rules";
|
||||||
|
const OPEN_PR = "/api/review-classifier/open-pr";
|
||||||
|
const PULL_REPOS = "/api/pulls/repos";
|
||||||
|
const PULLS = "/api/pulls";
|
||||||
|
// How long the repo field rests before its PRs are fetched, so typing a repo
|
||||||
|
// does not ask for the PRs of each prefix of it.
|
||||||
|
const PICK_DELAY_MS = 300;
|
||||||
|
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<ClassificationDraft[]>(() => [
|
||||||
|
blankDraft(),
|
||||||
|
]);
|
||||||
|
// The id of the run being polled; null when none is in flight.
|
||||||
|
const [runId, setRunId] = useState<string | null>(null);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [result, setResult] = useState<Result | null>(null);
|
||||||
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||||
|
// Whole seconds since the run started, so a long wait shows it is alive.
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
|
||||||
|
const [loadingRules, setLoadingRules] = useState(false);
|
||||||
|
const [rulesError, setRulesError] = useState<ProblemDetails | null>(null);
|
||||||
|
// Where the rows came from, until the next load replaces it.
|
||||||
|
const [loaded, setLoaded] = useState<(RulesAnswer & { repo: string }) | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [confirmLoad, setConfirmLoad] = useState(false);
|
||||||
|
|
||||||
|
const [opening, setOpening] = useState(false);
|
||||||
|
const [opened, setOpened] = useState<
|
||||||
|
(OpenPrAnswer & { repo: string }) | null
|
||||||
|
>(null);
|
||||||
|
const [openError, setOpenError] = useState<ProblemDetails | null>(null);
|
||||||
|
|
||||||
|
// What the two fields offer to complete to. The fields stay free text, so a
|
||||||
|
// repo or a PR the lists lack, a merged one say, can still be typed.
|
||||||
|
const [repoChoices, setRepoChoices] = useState<string[]>([]);
|
||||||
|
const [repoChoicesError, setRepoChoicesError] =
|
||||||
|
useState<ProblemDetails | null>(null);
|
||||||
|
const [pulls, setPulls] = useState<OpenPull[]>([]);
|
||||||
|
const [pullsError, setPullsError] = useState<ProblemDetails | null>(null);
|
||||||
|
|
||||||
|
const running = starting || runId !== null;
|
||||||
|
|
||||||
|
// The repos with an open PR, read once when the page opens.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(PULL_REPOS);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!res.ok) {
|
||||||
|
setRepoChoicesError(await readApiError(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRepoChoices((await res.json()) as string[]);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (!cancelled) setRepoChoicesError({ detail: String(e) });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The open PRs of the repo once it is typed or picked and has stopped
|
||||||
|
// changing. A repo that is not valid yet has no list.
|
||||||
|
useEffect(() => {
|
||||||
|
setPulls([]);
|
||||||
|
setPullsError(null);
|
||||||
|
const target = repo.trim();
|
||||||
|
if (repoProblem(target) !== null) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${PULLS}?repo=${encodeURIComponent(target)}`,
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!res.ok) {
|
||||||
|
setPullsError(await readApiError(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPulls((await res.json()) as OpenPull[]);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (!cancelled) setPullsError({ detail: String(e) });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, PICK_DELAY_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [repo]);
|
||||||
|
|
||||||
|
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<typeof setTimeout> | 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 openPrProblem = rowsProblem(repo, drafts);
|
||||||
|
const problem = runProblem(repo, pr, drafts);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads the repo's rules from its default branch into the rows. A failed
|
||||||
|
// load leaves the rows as they were.
|
||||||
|
async function loadRules() {
|
||||||
|
const target = repo.trim();
|
||||||
|
setConfirmLoad(false);
|
||||||
|
setLoadingRules(true);
|
||||||
|
setRulesError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${RULES}?repo=${encodeURIComponent(target)}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
setRulesError(await readApiError(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const answer = (await res.json()) as RulesAnswer;
|
||||||
|
setDrafts(answer.classifications.map((c) => draftFromRule(c, nextKey++)));
|
||||||
|
setLoaded({ ...answer, repo: target });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setRulesError({ detail: String(err) });
|
||||||
|
} finally {
|
||||||
|
setLoadingRules(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pushes the rows on a new branch of the repo and keeps the link to open
|
||||||
|
// the PR from it.
|
||||||
|
async function openPr() {
|
||||||
|
const target = repo.trim();
|
||||||
|
setOpening(true);
|
||||||
|
setOpened(null);
|
||||||
|
setOpenError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(OPEN_PR, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
repo: target,
|
||||||
|
classifications: drafts.map(toBody),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setOpenError(await readApiError(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpened({ ...((await res.json()) as OpenPrAnswer), repo: target });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setOpenError({ detail: String(err) });
|
||||||
|
} finally {
|
||||||
|
setOpening(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typed rows are the operator's work, so replacing them is asked first.
|
||||||
|
function askLoadRules() {
|
||||||
|
if (hasTypedText(drafts)) setConfirmLoad(true);
|
||||||
|
else void loadRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeDraft(next: ClassificationDraft) {
|
||||||
|
setDrafts((ds) => ds.map((d) => (d.key === next.key ? next : d)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="ui-tryout-page">
|
||||||
|
<Panel title="review try-out" icon="🧪">
|
||||||
|
<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:
|
||||||
|
change the classification and run it again until it answers the way
|
||||||
|
you want. Then open a PR to add it to the repo's rules.
|
||||||
|
</p>
|
||||||
|
<form class="ui-tryout-form" onSubmit={(e) => void run(e)}>
|
||||||
|
<div class="ui-tryout-target">
|
||||||
|
<TextField
|
||||||
|
id="tryout-repo"
|
||||||
|
label="repo"
|
||||||
|
value={repo}
|
||||||
|
placeholder="owner/name"
|
||||||
|
onInput={setRepo}
|
||||||
|
suggestions={repoChoices.map((value) => ({ value }))}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="tryout-pr"
|
||||||
|
label="PR number"
|
||||||
|
value={pr}
|
||||||
|
pattern="[0-9]+"
|
||||||
|
onInput={setPr}
|
||||||
|
suggestions={pulls.map((p) => ({
|
||||||
|
value: String(p.number),
|
||||||
|
label: p.title,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={askLoadRules}
|
||||||
|
disabled={loadingRules || running || repoProblem(repo) !== null}
|
||||||
|
>
|
||||||
|
{loadingRules ? "loading…" : "load the repo's rules"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{repoChoicesError ? (
|
||||||
|
<ApiErrorPanel
|
||||||
|
context="the repos with an open PR could not be listed"
|
||||||
|
problem={repoChoicesError}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{pullsError ? (
|
||||||
|
<ApiErrorPanel
|
||||||
|
context="the repo's open PRs could not be listed"
|
||||||
|
problem={pullsError}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{rulesError ? (
|
||||||
|
<ApiErrorPanel
|
||||||
|
context="the repo's rules could not be loaded"
|
||||||
|
problem={rulesError}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{loaded ? (
|
||||||
|
<p class="ui-tryout-loaded">
|
||||||
|
Loaded {loaded.classifications.length} from{" "}
|
||||||
|
<code>{loaded.path}</code> on <code>{loaded.branch}</code> of{" "}
|
||||||
|
{loaded.repo}, read at commit{" "}
|
||||||
|
<code>{loaded.commit.slice(0, 12)}</code>. Edit them below, then
|
||||||
|
run.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{drafts.map((d) => (
|
||||||
|
<ClassificationEditor
|
||||||
|
key={d.key}
|
||||||
|
draft={d}
|
||||||
|
onChange={changeDraft}
|
||||||
|
onRemove={
|
||||||
|
drafts.length > 1
|
||||||
|
? () => setDrafts((ds) => ds.filter((x) => x.key !== d.key))
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div class="ui-tryout-actions">
|
||||||
|
<Button
|
||||||
|
onClick={() => setDrafts((ds) => [...ds, blankDraft()])}
|
||||||
|
disabled={drafts.length >= MAX_CLASSIFICATIONS}
|
||||||
|
>
|
||||||
|
add a classification
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={running || problem !== null}
|
||||||
|
>
|
||||||
|
{running ? "running…" : "run"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void openPr()}
|
||||||
|
disabled={opening || openPrProblem !== null}
|
||||||
|
>
|
||||||
|
{opening ? "pushing…" : "open a PR"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{problem !== null ? <p class="ui-tryout-hint">{problem}</p> : null}
|
||||||
|
{openError ? (
|
||||||
|
<ApiErrorPanel
|
||||||
|
context="the branch for the PR could not be pushed"
|
||||||
|
problem={openError}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{opened ? (
|
||||||
|
<p class="ui-tryout-loaded">
|
||||||
|
Pushed <code>{opened.branch}</code> to {opened.repo}, changing{" "}
|
||||||
|
<code>{opened.path}</code> with you as the author.{" "}
|
||||||
|
<a href={opened.compare_url} target="_blank" rel="noopener">
|
||||||
|
Open the PR on the forge
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmLoad}
|
||||||
|
label="replace the classifications"
|
||||||
|
confirmLabel="replace them"
|
||||||
|
onCancel={() => setConfirmLoad(false)}
|
||||||
|
onConfirm={() => void loadRules()}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Loading the rules of {repo.trim()} replaces the classifications you
|
||||||
|
have written below.
|
||||||
|
</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
{running || result || error ? (
|
||||||
|
<Panel title="result" icon="📋">
|
||||||
|
{running ? (
|
||||||
|
<p class="ui-tryout-running" role="status">
|
||||||
|
Running — a run takes a minute or more, and up to half an hour for
|
||||||
|
a long list ({elapsed}s so far).
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<ApiErrorPanel context="the try-out failed" problem={error} />
|
||||||
|
) : null}
|
||||||
|
{result?.state === "failed" ? (
|
||||||
|
<ApiErrorPanel
|
||||||
|
context="the run failed"
|
||||||
|
problem={{
|
||||||
|
title: "the classifier could not finish",
|
||||||
|
detail: result.error ?? "no reason given",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{result?.state === "done" ? <TryOutResult result={result} /> : null}
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
<ClassifierSettings />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
// <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,7 +54,8 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
||||||
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
||||||
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" },
|
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" },
|
||||||
{ href: "/issues", label: "issues", accent: "var(--yellow)" },
|
{ 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)" },
|
{ href: "/components", label: "components", accent: "var(--blue)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,12 @@
|
||||||
// modifier alongside the base class, not a replacement, so every other
|
// modifier alongside the base class, not a replacement, so every other
|
||||||
// route's layout is untouched. `/agents` joined for the same reason —
|
// route's layout is untouched. `/agents` joined for the same reason —
|
||||||
// its own table view (plus the list+detail split) hits the same cap.
|
// 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/terminal` (and a subagent's, under
|
||||||
// `/agents/:name/subagents/:subagent/`) is full-bleed: the terminal fills
|
// `/agents/:name/subagents/:subagent/`) is full-bleed: the terminal fills
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
// <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", () => {
|
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);
|
assert.equal(isWideBodyRoute(path), true, path);
|
||||||
}
|
}
|
||||||
for (const path of ["/", "/jobs", "/schedules", "/components"]) {
|
for (const path of ["/", "/jobs", "/schedules", "/components"]) {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
maxValue,
|
maxValue,
|
||||||
priceValue,
|
priceValue,
|
||||||
settingsProblem,
|
settingsProblem,
|
||||||
settingsSummary,
|
|
||||||
toSettings,
|
toSettings,
|
||||||
} from "../src/pages/review-settings/SettingsTypes.ts";
|
} 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("-1"), null);
|
||||||
assert.equal(priceValue("1e3"), 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