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
2 changed files with 223 additions and 45 deletions
|
|
@ -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);
|
||||
}
|
||||
162
frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx
Normal file
162
frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// <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
|
||||
// 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";
|
||||
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 "./ReviewRuns.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 ReviewRuns() {
|
||||
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="recent 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue