static-fire 0.0.0-stage → 0.54.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +236 -2
  3. package/bin/static-fire.js +46 -0
  4. package/package.json +30 -4
  5. package/pricing.yaml +20 -0
  6. package/src/adapters/index.ts +153 -0
  7. package/src/api.ts +250 -0
  8. package/src/cli.ts +383 -0
  9. package/src/core/apkit-install.ts +47 -0
  10. package/src/core/authored.ts +47 -0
  11. package/src/core/batch-state.ts +25 -0
  12. package/src/core/batch.ts +450 -0
  13. package/src/core/batches.ts +221 -0
  14. package/src/core/checks.ts +63 -0
  15. package/src/core/commands.ts +43 -0
  16. package/src/core/compare.ts +206 -0
  17. package/src/core/config.ts +25 -0
  18. package/src/core/copying.ts +18 -0
  19. package/src/core/coverage.ts +84 -0
  20. package/src/core/db.ts +608 -0
  21. package/src/core/derived.ts +74 -0
  22. package/src/core/engine.ts +85 -0
  23. package/src/core/facets.ts +143 -0
  24. package/src/core/grade.ts +197 -0
  25. package/src/core/grading-versions.ts +281 -0
  26. package/src/core/identity.ts +263 -0
  27. package/src/core/init.ts +321 -0
  28. package/src/core/judge-schema.ts +37 -0
  29. package/src/core/judge.ts +166 -0
  30. package/src/core/label.ts +43 -0
  31. package/src/core/lock.ts +40 -0
  32. package/src/core/migrate.ts +288 -0
  33. package/src/core/new-task.ts +85 -0
  34. package/src/core/outside-tools.ts +129 -0
  35. package/src/core/plan.ts +84 -0
  36. package/src/core/profiles.ts +42 -0
  37. package/src/core/project.ts +140 -0
  38. package/src/core/regrade.ts +242 -0
  39. package/src/core/score.ts +148 -0
  40. package/src/core/settings.ts +44 -0
  41. package/src/core/task-file.ts +95 -0
  42. package/src/core/task-page.ts +54 -0
  43. package/src/core/trial-detail.ts +73 -0
  44. package/src/core/types.ts +195 -0
  45. package/src/core/view-query.ts +102 -0
  46. package/src/core/view.ts +576 -0
  47. package/src/guide.md +624 -0
  48. package/src/server.ts +28 -0
  49. package/web/api.ts +6 -0
  50. package/web/app.tsx +1162 -0
  51. package/web/brand/offset-tokens.css +126 -0
  52. package/web/charts.tsx +190 -0
  53. package/web/compare.tsx +317 -0
  54. package/web/encoding.tsx +55 -0
  55. package/web/filter-menu.ts +195 -0
  56. package/web/how-it-works.tsx +92 -0
  57. package/web/index-switches.ts +29 -0
  58. package/web/index.html +17 -0
  59. package/web/levels.tsx +15 -0
  60. package/web/notices.ts +74 -0
  61. package/web/output-view.ts +61 -0
  62. package/web/pages.ts +42 -0
  63. package/web/run-status.ts +51 -0
  64. package/web/styles.css +368 -0
  65. package/web/task-card.ts +6 -0
  66. package/web/tasks.tsx +122 -0
  67. package/web/wording.ts +38 -0
package/web/app.tsx ADDED
@@ -0,0 +1,1162 @@
1
+ import { marked } from "marked";
2
+ import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import type { AgentConfig, Criterion, JudgeConfig, Suite, Trial } from "../src/core/types";
5
+ import type { TrialDetail } from "../src/core/trial-detail";
6
+ import type { View, ViewConfigSummary, ViewTrial } from "../src/core/view";
7
+ import { parseViewQuery, viewQueryParams, type Scope, type ViewQuery } from "../src/core/view-query";
8
+ import { coverageLine } from "../src/core/coverage";
9
+ import { titleHref, toggleSuite, viewTitle } from "./index-switches";
10
+ import { shownTask } from "./task-card";
11
+ import type { Facets } from "../src/core/facets";
12
+ import { configLabel } from "../src/core/label";
13
+ import type { RegradeEnded, RegradeRun } from "../src/api";
14
+ import { BarList, ConfigTable, dateRange, FrontierChart, money, secs, type HumanLine, type Point, type Row } from "./charts";
15
+ import { familyName, familySlot, HARNESS_NAME, HARNESS_ORDER, Marker } from "./encoding";
16
+ import { howItWorksHref, PAGES, pageOf, pathOf, type PageId } from "./pages";
17
+ import { api } from "./api";
18
+ import { ComparePage } from "./compare";
19
+ import { HowItWorksPage } from "./how-it-works";
20
+ import { TasksPage } from "./tasks";
21
+ import { clearAll, filterTags, menuSections, pick, searchMenu, SECTION_NAMES, withGradingPick, type MenuValue, type SectionId } from "./filter-menu";
22
+ import { runStatus, STATUS_REFRESH_MS, statusText, type RunStatus } from "./run-status";
23
+ import { LevelLegend, LevelTag } from "./levels";
24
+ import { alertText, gradingItem, notPlottedGroups, regradeNote, ungradedItem, type NotPlotted } from "./notices";
25
+ import { HTML_SANDBOX, outputKind, PROSE_SANDBOX, PROSE_TOKENS, proseDoc, sourceLabel, type OutputKind } from "./output-view";
26
+
27
+ /** The authored definitions the page lists: every suite and task (for the scope selector) and the config files. */
28
+ interface Catalog { suites: Suite[]; titles: Record<string, string>; configs: AgentConfig[] }
29
+ /** A loaded view and the scope it was asked for. */
30
+ interface Loaded { view: View; scope: Scope }
31
+
32
+ const pct = (v: number | null | undefined, d = 0) => (v == null ? "—" : v.toFixed(d));
33
+ const short = (hash: string) => hash.slice(0, 8);
34
+ const viewUrl = (q: ViewQuery) => { const p = viewQueryParams(q).toString(); return `/api/view${p ? `?${p}` : ""}`; };
35
+ /** The page's URL query is the view query: a copied URL opens the same view. A URL the page cannot read is an error. */
36
+ const readUrl = (): { query: ViewQuery; error: string } => {
37
+ try { return { query: parseViewQuery(new URLSearchParams(location.search)), error: "" }; } catch (e) { return { query: {}, error: (e as Error).message }; }
38
+ };
39
+
40
+ function App() {
41
+ // What the page asks for. The scope selector sets `scope`; the filter bar (#10) adds the other fields.
42
+ // Which page the path names (web/pages.ts). Only the Results page reads its query: another page's URL has none.
43
+ const [page, setPage] = useState<PageId>(() => pageOf(location.pathname));
44
+ const [start] = useState(() => (pageOf(location.pathname) === "results" ? readUrl() : { query: {}, error: "" }));
45
+ const [query, setQuery] = useState<ViewQuery>(start.query);
46
+ const [urlError, setUrlError] = useState(start.error);
47
+ const [facets, setFacets] = useState<Facets | null>(null);
48
+ const [loaded, setLoaded] = useState<Loaded | null>(null);
49
+ const [catalog, setCatalog] = useState<Catalog | null>(null);
50
+ const [tick, setTick] = useState(0);
51
+ const batches = useBatches(tick);
52
+ const [trialId, setTrialId] = useState<string | null>(null);
53
+ const [newOpen, setNewOpen] = useState(false);
54
+ const [loadError, setLoadError] = useState(""); // the view's request
55
+ const [catalogError, setCatalogError] = useState(""); // the suites, tasks and configs requests: loaded once, so kept
56
+ const [regrade, setRegrade] = useState<RegradeStatus>({ running: null, last: null });
57
+ const [regradeError, setRegradeError] = useState("");
58
+ const wasRegrading = useRef(false);
59
+
60
+ useEffect(() => {
61
+ const es = new EventSource("/api/events");
62
+ es.onmessage = () => setTick((t) => t + 1);
63
+ return () => es.close();
64
+ }, []);
65
+ useEffect(() => {
66
+ Promise.all([api<Suite[]>("/api/suites"), api<{ id: string; title: string }[]>("/api/tasks"), api<AgentConfig[]>("/api/configs")])
67
+ .then(([suites, tasks, configs]) => {
68
+ // The titles in the project's order: each suite's tasks in turn, then the tasks in no suite (the Filter menu's Task section).
69
+ const order = [...new Set([...suites.flatMap((s) => s.tasks), ...tasks.map((t) => t.id)])];
70
+ const title = new Map(tasks.map((t) => [t.id, t.title]));
71
+ setCatalog({ suites, titles: Object.fromEntries(order.filter((id) => title.has(id)).map((id) => [id, title.get(id)!])), configs });
72
+ })
73
+ .catch((e: Error) => setCatalogError(e.message));
74
+ }, []);
75
+ // Every change to the query is a history entry; Back and Forward set the query from the URL again.
76
+ const search = viewQueryParams(query).toString();
77
+ useEffect(() => {
78
+ if (urlError || page !== "results") return; // the query is the Results page's URL, not another page's
79
+ const here = new URLSearchParams(location.search).toString();
80
+ if (here === search) return;
81
+ let same = false;
82
+ try { same = viewQueryParams(parseViewQuery(new URLSearchParams(location.search))).toString() === search; } catch { /* a bad URL is replaced */ }
83
+ const next = `${location.pathname}${search ? `?${search}` : ""}`;
84
+ if (same) history.replaceState(null, "", next); else history.pushState(null, "", next);
85
+ }, [search, urlError, page]);
86
+ useEffect(() => {
87
+ // Back and Forward to another page leave the Results query as it was, so coming back keeps the filters.
88
+ const back = () => {
89
+ const p = pageOf(location.pathname);
90
+ setPage(p);
91
+ if (p === "results") { const r = readUrl(); setQuery(r.query); setUrlError(r.error); }
92
+ };
93
+ window.addEventListener("popstate", back);
94
+ return () => window.removeEventListener("popstate", back);
95
+ }, []);
96
+ useEffect(() => {
97
+ if (page !== "results") return; // loaded again on the way back to Results
98
+ let live = true;
99
+ api<Facets>(`/api/facets${search ? `?${search}` : ""}`)
100
+ .then((f) => { if (live) setFacets(f); })
101
+ .catch(() => { if (live) setFacets(null); }); // the view's own request reports a bad filter
102
+ return () => { live = false; };
103
+ }, [search, tick, page]);
104
+ // The server's regrade (one at a time, in its process), so a reload and a second tab see it. Read on each
105
+ // event, and every second while one runs: its end must show even when the last write came before it cleared.
106
+ useEffect(() => {
107
+ let live = true;
108
+ const read = () => api<RegradeStatus>("/api/regrade").then((r) => {
109
+ if (!live) return;
110
+ setRegrade(r);
111
+ if (wasRegrading.current && !r.running) setTick((t) => t + 1); // it ended: load the view again
112
+ wasRegrading.current = !!r.running;
113
+ }).catch(() => {});
114
+ read();
115
+ const timer = regrade.running ? setInterval(read, 1000) : undefined;
116
+ return () => { live = false; clearInterval(timer); };
117
+ }, [tick, !!regrade.running]);
118
+ useEffect(() => setRegradeError(""), [search]);
119
+ const startRegrade = () => {
120
+ setRegradeError("");
121
+ fetch(`/api/regrade${search ? `?${search}` : ""}`, { method: "POST" })
122
+ .then(async (r) => {
123
+ const body = await r.json().catch(() => null);
124
+ if (r.status === 409 && body?.running) setRegrade((was) => ({ ...was, running: body.running }));
125
+ else if (!r.ok) setRegradeError(body?.error ?? `${r.status} ${r.statusText}`);
126
+ setTick((t) => t + 1);
127
+ })
128
+ .catch((e: Error) => setRegradeError(e.message));
129
+ };
130
+ const url = viewUrl(query);
131
+ useEffect(() => {
132
+ if (page !== "results") return; // loaded again on the way back to Results, with the trials of any batch that ran meanwhile
133
+ let live = true; // a response for an earlier query must not replace the current one
134
+ const scope = query.scope ?? { kind: "index" as const };
135
+ api<View>(url)
136
+ .then((view) => { if (live) { setLoaded({ view, scope }); setLoadError(""); } })
137
+ .catch((e: Error) => { if (live) { setLoaded(null); setLoadError(e.message); } });
138
+ return () => { live = false; };
139
+ }, [url, tick, page]);
140
+ /** Opens another page as a history entry. Results gets its query back, so its filters survive the trip. */
141
+ const go = (to: PageId) => {
142
+ if (to === page) return;
143
+ history.pushState(null, "", hrefOf(to));
144
+ if (to === "results") setUrlError(""); // the pushed URL holds the query in state, not the unreadable one
145
+ setPage(to);
146
+ window.scrollTo(0, 0);
147
+ };
148
+ /** Opens an address inside the dashboard, such as an entry of the Tasks page, as a history entry. */
149
+ const goHref = (href: string) => {
150
+ history.pushState(null, "", href);
151
+ setPage(pageOf(new URL(href, location.href).pathname));
152
+ window.scrollTo(0, 0);
153
+ };
154
+ const hrefOf = (to: PageId) => (to === "results" ? `${pathOf(to)}${search ? `?${search}` : ""}` : pathOf(to));
155
+
156
+ const view = loaded?.view;
157
+ const trial = view?.trials.find((t) => t.id === trialId) ?? null;
158
+ return (
159
+ <div className="app">
160
+ <header className="topbar">
161
+ <div className="brand">Static Fire</div>
162
+ <nav className="nav" aria-label="Pages">
163
+ {PAGES.map((p) => (
164
+ <a key={p.id} href={hrefOf(p.id)} className={p.id === page ? "on" : undefined} aria-current={p.id === page ? "page" : undefined}
165
+ onClick={(e) => { if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; e.preventDefault(); go(p.id); }}>{p.title}</a>
166
+ ))}
167
+ </nav>
168
+ <div className="spacer" />
169
+ <RunStatusButton batches={batches} onOpen={() => go("runs")} />
170
+ </header>
171
+
172
+ {page === "runs" ? <RunsPage batches={batches} tick={tick} onNew={() => setNewOpen(true)} />
173
+ : page === "compare" ? <ComparePage tick={tick} titles={catalog?.titles ?? {}} />
174
+ : page === "tasks" ? <TasksPage />
175
+ : page === "how" ? <HowItWorksPage />
176
+ : urlError ? <div className="empty card"><div className="kicker">This link has a filter the dashboard cannot read</div><p className="error" style={{ whiteSpace: "pre-wrap" }}>{urlError}</p><a className="btn" href={location.pathname}>Reset filters</a></div>
177
+ : catalogError ? <div className="empty card"><div className="kicker">Could not load the suites, tasks and configs</div><p className="error" style={{ whiteSpace: "pre-wrap" }}>{catalogError}</p><a className="btn" href={location.href}>Reload</a></div>
178
+ : loadError ? <div className="empty card"><div className="kicker">Could not load the view</div><p className="error" style={{ whiteSpace: "pre-wrap" }}>{loadError}</p></div>
179
+ : !loaded || !catalog ? <div className="empty card"><div className="kicker">Static Fire</div><h1>Loading…</h1></div>
180
+ : <Dashboard loaded={loaded} catalog={catalog} onScope={(scope, suites) => setQuery((q) => withScope(q, scope, suites, catalog, facets))} onTrial={setTrialId} onNew={() => go("runs")} onLink={goHref}
181
+ bar={<FilterBar query={query} view={loaded.view} facets={facets} titles={catalog.titles}
182
+ onChange={(patch) => setQuery((q) => ("scope" in patch ? withScope({ ...q, ...patch }, patch.scope ?? { kind: "index" }, patch.suites, catalog, facets)
183
+ : patch.grading ? gradingPicked({ ...q, ...patch }, loaded.view, catalog, facets) : { ...q, ...patch }))} />}
184
+ notice={(notPlotted) => <NoticeAlert view={loaded.view} scope={loaded.scope} titles={catalog.titles} search={search} regrade={regrade} error={regradeError}
185
+ notPlotted={notPlotted} onRegrade={startRegrade} onVersion={(grading) => setQuery((q) => gradingPicked({ ...q, grading, judge: undefined }, loaded.view, catalog, facets))} onLink={goHref} />} />}
186
+ {page === "results" && trial && view && <TrialDrawer view={view} trial={trial} tick={tick} onClose={() => setTrialId(null)} />}
187
+ {page === "runs" && newOpen && <NewRun onClose={() => setNewOpen(false)} onStarted={() => { setNewOpen(false); setTick((t) => t + 1); }} />}
188
+ </div>
189
+ );
190
+ }
191
+
192
+ /** The query after the page sets a grading pick: on the index, the suites become an explicit pick (web/filter-menu.ts withGradingPick). */
193
+ function gradingPicked(q: ViewQuery, view: View, catalog: Catalog, facets: Facets | null): ViewQuery {
194
+ const taskId = facets?.grading.find((g) => g.setups.some((s) => s.hash.startsWith(q.grading!)))?.taskId;
195
+ return withGradingPick(q, taskId, view.filters.suiteIds ?? [], catalog.suites);
196
+ }
197
+
198
+ /**
199
+ * The query with a new scope and suite pick (`suites` undefined: nothing picked, the default set). A picked grading
200
+ * version belongs to one task: it is kept only when the new scope still covers that task, because the view refuses
201
+ * a grading version outside its scope. Back on the index with no suite picked, the kept grading pick picks the
202
+ * suite of its task, since the default set has no exception for it (withGradingPick).
203
+ */
204
+ function withScope(q: ViewQuery, scope: Scope, suites: string[] | undefined, catalog: Catalog, facets: Facets | null): ViewQuery {
205
+ const next: ViewQuery = { ...q, scope, suites: scope.kind === "index" && suites?.length ? suites : undefined };
206
+ if (!q.grading) return next;
207
+ const taskId = facets?.grading.find((g) => g.setups.some((s) => s.hash.startsWith(q.grading!)))?.taskId;
208
+ const covered = scope.kind === "task" ? [scope.id]
209
+ : catalog.suites.filter((s) => !next.suites || next.suites.includes(s.id)).flatMap((s) => s.tasks);
210
+ if (!taskId || !covered.includes(taskId)) return { ...next, grading: undefined };
211
+ return scope.kind === "index" && !next.suites ? withGradingPick(next, taskId, [], catalog.suites) : next;
212
+ }
213
+
214
+ /**
215
+ * The filters besides scope (the chips): one Filter button that opens a searchable menu of collapsed sections,
216
+ * and a tag per active filter, one per picked value of a config facet (the data is web/filter-menu.ts). Each value shows how many trials the view would
217
+ * list with it. Every filter is a field of the query, so of the URL.
218
+ */
219
+ function FilterBar({ query, view, facets, titles, onChange }: {
220
+ query: ViewQuery; view: View; facets: Facets | null; titles: Record<string, string>; onChange: (patch: Partial<ViewQuery>) => void;
221
+ }) {
222
+ const [open, setOpen] = useState(false);
223
+ const [text, setText] = useState("");
224
+ const [expanded, setExpanded] = useState<Set<SectionId>>(new Set());
225
+ const root = useRef<HTMLDivElement>(null), button = useRef<HTMLButtonElement>(null), searchBox = useRef<HTMLInputElement>(null);
226
+ const sections = menuSections({ facets, query, judge: view.filters.judge, titles });
227
+ const tags = filterTags(sections);
228
+ const found = text.trim() ? searchMenu(sections, text) : null;
229
+ // Open: the search box takes focus. A click outside the menu or Escape closes it.
230
+ useEffect(() => {
231
+ if (!open) return;
232
+ searchBox.current?.focus();
233
+ const away = (e: MouseEvent) => { if (!root.current?.contains(e.target as Node)) setOpen(false); };
234
+ const esc = (e: KeyboardEvent) => { if (e.key === "Escape") { setOpen(false); button.current?.focus(); } };
235
+ document.addEventListener("mousedown", away);
236
+ document.addEventListener("keydown", esc);
237
+ return () => { document.removeEventListener("mousedown", away); document.removeEventListener("keydown", esc); };
238
+ }, [open]);
239
+ // Each opening starts with no search text and every section collapsed.
240
+ const toggleMenu = () => { if (!open) { setText(""); setExpanded(new Set()); } setOpen(!open); };
241
+ const toggleSection = (id: SectionId) => setExpanded((was) => { const next = new Set(was); if (!next.delete(id)) next.add(id); return next; });
242
+
243
+ const option = (v: MenuValue, labelled: boolean) => {
244
+ const detail = [labelled ? v.group : undefined, v.detail].filter(Boolean).join(" · ");
245
+ return (
246
+ <button key={`${v.section}:${v.key}`} type="button" className={`opt${v.picked ? " on" : ""}`} aria-pressed={v.picked} title={v.title} onClick={() => onChange(pick(v))}>
247
+ <span>{labelled && <em>{v.sectionName}: </em>}{v.label}{v.picked && " ✓"}{detail && <small>{detail}</small>}</span>
248
+ <span className="opt-count" title={`${v.count} trial${v.count === 1 ? "" : "s"}`}>{v.count}</span>
249
+ </button>
250
+ );
251
+ };
252
+ // An open section's values; a grading version under its task's heading when more than one task is in scope.
253
+ const values = (list: MenuValue[]) => list.map((v, i) => (
254
+ <Fragment key={`${v.section}:${v.key}`}>
255
+ {v.group && v.group !== list[i - 1]?.group && <div className="grp">{v.group}</div>}
256
+ {option(v, false)}
257
+ </Fragment>
258
+ ));
259
+ const day = (iso: string | null) => iso?.slice(0, 10);
260
+ const dates = (
261
+ <div className="menu-dates">
262
+ <label>From<input type="date" aria-label="From" value={(query.since ?? "").slice(0, 10)} min={day(facets?.dates.first ?? null)} max={day(facets?.dates.last ?? null)} onChange={(e) => onChange({ since: e.target.value || undefined })} /></label>
263
+ <label>To<input type="date" aria-label="To" value={(query.until ?? "").slice(0, 10)} min={day(facets?.dates.first ?? null)} max={day(facets?.dates.last ?? null)} onChange={(e) => onChange({ until: e.target.value || undefined })} /></label>
264
+ </div>
265
+ );
266
+ return (
267
+ <div className="filter-bar" aria-label="Filters">
268
+ <div className="pop" ref={root}>
269
+ <button ref={button} type="button" className={`btn filter-btn${open ? " on" : ""}`} aria-expanded={open} aria-haspopup="dialog" onClick={toggleMenu}>
270
+ <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><path d="M1 3h14M4 8h8M6.5 13h3" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" fill="none" /></svg>
271
+ Filter
272
+ </button>
273
+ {open && (
274
+ <div className="menu" role="dialog" aria-label="Filter">
275
+ <input ref={searchBox} type="search" aria-label="Search filters" placeholder="Search all filters, for example opus" value={text} onChange={(e) => setText(e.target.value)} />
276
+ <div className="menu-list">
277
+ {found ? (
278
+ <>
279
+ {found.values.map((v) => option(v, true))}
280
+ {found.dates && <div className="menu-flat-dates"><em>{SECTION_NAMES.dates}</em>{dates}</div>}
281
+ {found.values.length === 0 && !found.dates && <p className="muted menu-none">No filter matches.</p>}
282
+ </>
283
+ ) : sections.map((s) => {
284
+ const on = expanded.has(s.id);
285
+ return (
286
+ <div key={s.id} className="menu-sec">
287
+ <button type="button" className="sec" aria-expanded={on} onClick={() => toggleSection(s.id)}>
288
+ <span><i aria-hidden="true">{on ? "▾" : "▸"}</i>{s.name}</span>
289
+ <span className={s.picked ? "sec-on" : undefined}>{s.summary}</span>
290
+ </button>
291
+ {on && <div className="in">{s.id === "dates" ? dates : values(s.values)}</div>}
292
+ </div>
293
+ );
294
+ })}
295
+ </div>
296
+ </div>
297
+ )}
298
+ </div>
299
+ {tags.map((t) => (
300
+ <span key={t.key} className={`tag${t.section === "grading" ? " grading" : ""}`}>
301
+ <em>{t.name}:</em> {t.value}
302
+ <button type="button" aria-label={`Remove filter ${t.text}`} title="Remove this filter" onClick={() => onChange(t.remove)}>×</button>
303
+ </span>
304
+ ))}
305
+ {tags.length > 0 && <button type="button" className="btn clear-all" onClick={() => onChange(clearAll(query))}>Clear all</button>}
306
+ </div>
307
+ );
308
+ }
309
+
310
+ /** The task ids a scope covers in this view. */
311
+ const tasksOf = (view: View, sel: Scope) => (sel.kind === "task" ? [sel.id] : Object.keys(view.tasks));
312
+
313
+ /**
314
+ * What the selected scope says about one config: a table row. Score and cost are null when the scope has
315
+ * none. The chart point is this same row (pointFor), so the chart and the table never disagree.
316
+ */
317
+ function rowFor(view: View, s: ViewConfigSummary, sel: Scope): Row {
318
+ const tasks = tasksOf(view, sel);
319
+ const stat = sel.kind === "index" ? s.index : s.tasks[sel.id]?.score;
320
+ const per = tasks.map((t) => s.tasks[t]).filter((t) => t && t.valid > 0);
321
+ const costs = per.map((t) => t.costUsd).filter((c): c is number => c != null);
322
+ const times = per.map((t) => t.durationMs).filter((x): x is number => x != null);
323
+ const pooled = tasks.map((t) => s.pooled[t]).filter((p) => p && p.n > 0);
324
+ return {
325
+ s, score: stat?.mean ?? null, ci: stat?.ci ?? null,
326
+ cost: costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : null,
327
+ n: pooled.reduce((a, p) => a + p.n, 0),
328
+ from: pooled.map((p) => p.from!).sort()[0] ?? null,
329
+ to: pooled.map((p) => p.to!).sort().at(-1) ?? null,
330
+ costSource: per.some((t) => t.costSource === "estimated") ? "estimated" : "reported",
331
+ durationMs: times.length ? times.reduce((a, b) => a + b, 0) / times.length : null,
332
+ };
333
+ }
334
+
335
+ /** The chart point of one config: its row, when it has both a score and a cost. */
336
+ function pointFor(view: View, s: ViewConfigSummary, sel: Scope): Point | null {
337
+ const r = rowFor(view, s, sel);
338
+ return r.score == null || r.cost == null ? null : { ...r, score: r.score, cost: r.cost };
339
+ }
340
+
341
+ /** Why a config with trials in view has no point: a suite in view it does not cover in full. */
342
+ function partialText(s: ViewConfigSummary, sel: Scope, suites: Map<string, Suite>): string | null {
343
+ const ids = sel.kind === "index" ? Object.keys(s.partial) : [];
344
+ const parts = ids.flatMap((id) => {
345
+ const p = s.partial[id];
346
+ return p ? [`${ids.length > 1 ? `${suites.get(id)?.title ?? id} ` : ""}partial (${p.have} of ${p.of} tasks)`] : [];
347
+ });
348
+ return parts.length ? parts.join(", ") : null;
349
+ }
350
+
351
+ /** A config the chart cannot plot, for the alert's details: the suites in view it has not completed, and its score. */
352
+ function notPlotted(view: View, s: ViewConfigSummary, sel: Scope, suites: Map<string, Suite>): NotPlotted {
353
+ const partial = sel.kind === "index"
354
+ ? Object.entries(s.partial).flatMap(([id, p]) => p ? [{ suite: suites.get(id)?.title ?? id, have: p.have, of: p.of }] : [])
355
+ : [];
356
+ return { label: s.label, partial, score: rowFor(view, s, sel).score };
357
+ }
358
+
359
+ function Dashboard({ loaded, catalog, onScope, onTrial, onNew, onLink, bar, notice }: {
360
+ loaded: Loaded; catalog: Catalog; onScope: (s: Scope, suites?: string[]) => void; onTrial: (id: string) => void; onNew: () => void;
361
+ /** Opens an address inside the dashboard: the title's entry on the Tasks page. */
362
+ onLink: (href: string) => void; bar?: ReactNode;
363
+ /** The one alert (#82), given the configs the chart cannot plot (none in the Table, which lists them with a reason). */
364
+ notice?: (notPlotted: NotPlotted[]) => ReactNode;
365
+ }) {
366
+ const { view, scope: sel } = loaded;
367
+ const { trials, summary } = view;
368
+ const suites = new Map(catalog.suites.map((s) => [s.id, s]));
369
+ const taskIds = Object.keys(view.tasks);
370
+ const taskTitle = (id: string) => view.tasks[id]?.def.title ?? catalog.titles[id] ?? id;
371
+ const valid = trials.filter((t) => t.status === "done" && t.grades);
372
+ const starts = valid.map((t) => t.startedAt).filter((x): x is string => !!x).sort();
373
+
374
+ const inView = sel.kind === "index" ? view.filters.suiteIds : [];
375
+ const title = viewTitle(sel, inView, suites, taskTitle);
376
+ const titleLink = titleHref(sel, inView);
377
+ const coverage = sel.kind === "index" ? coverageLine(view.coverage, view.filters.judge.label) : null;
378
+ const agents = summary.configs.filter((s) => s.harness !== "human");
379
+ const points = agents.map((s) => pointFor(view, s, sel)).filter((p): p is Point => p != null);
380
+ const humans: HumanLine[] = summary.configs.filter((s) => s.harness === "human").flatMap((s) => {
381
+ const stat = sel.kind === "index" ? s.index : s.tasks[sel.id]?.score;
382
+ return stat?.mean != null ? [{ s, score: stat.mean }] : [];
383
+ });
384
+ // Every config in view the chart draws neither as a point nor as a human line, agents and humans alike.
385
+ const unplotted = summary.configs.filter((s) => !points.some((p) => p.s.configId === s.configId) && !humans.some((h) => h.s.configId === s.configId));
386
+
387
+ const pickConfig = (configId: string) => {
388
+ const t = trials.filter((t) => t.configId === configId && t.status === "done" && (sel.kind !== "task" || t.taskId === sel.id))
389
+ .sort((a, b) => (b.grades?.score ?? 0) - (a.grades?.score ?? 0))[0];
390
+ if (t) onTrial(t.id);
391
+ };
392
+ const harnesses = HARNESS_ORDER.filter((h) => h !== "human" && agents.some((s) => s.harness === h));
393
+ const families = [...new Set(agents.map((s) => s.family))].sort((a, b) => familySlot(a) - familySlot(b));
394
+ const one = sel.kind === "task" ? view.tasks[sel.id] : null;
395
+ const [shown, setShown] = useState<"chart" | "table">("chart");
396
+ // The table: every config the chart draws (points and human lines, by score), then the ones it cannot draw.
397
+ const tableRows: Row[] = [
398
+ ...[...points, ...summary.configs.filter((s) => humans.some((h) => h.s.configId === s.configId)).map((s) => rowFor(view, s, sel))]
399
+ .sort((a, b) => b.score! - a.score!),
400
+ ...unplotted.map((s) => { const r = rowFor(view, s, sel); return { ...r, reason: partialText(s, sel, suites) ?? (r.score == null ? "no valid score" : "no cost data") }; }),
401
+ ];
402
+
403
+ return (
404
+ <>
405
+ <section className="hero">
406
+ <div className="kicker">View</div>
407
+ <h1>{titleLink ? <a className="title-link" href={titleLink} title={`Read about ${title} on the Tasks page`}
408
+ onClick={(e) => { if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; e.preventDefault(); onLink(titleLink); }}>{title}</a> : title}</h1>
409
+ <div className="meta">
410
+ <span><b>{taskIds.length}</b> task{taskIds.length === 1 ? "" : "s"} × <b>{summary.configs.length}</b> config{summary.configs.length === 1 ? "" : "s"} · <b>{valid.length}</b> valid trial{valid.length === 1 ? "" : "s"}</span>
411
+ <span>Judge <b>{view.filters.judge.label}</b></span>
412
+ {starts.length > 0 && <span>Ran {dateRange(starts[0]!, starts.at(-1)!)}</span>}
413
+ {one && <span><VersionLabel label={one.label} hash={one.taskHash} /></span>}
414
+ </div>
415
+ </section>
416
+
417
+ <div className="chips" role="group" aria-label="Score shown">
418
+ <button className={`chip ${sel.kind === "index" && !view.coverage?.picked ? "on" : ""}`} aria-pressed={sel.kind === "index" && !view.coverage?.picked}
419
+ title="The best set of suites the data supports" onClick={() => onScope({ kind: "index" })}>Agent Performance Index</button>
420
+ {catalog.suites.map((s) => {
421
+ const on = inView.includes(s.id), next = toggleSuite(sel, inView, s.id, catalog.suites);
422
+ return <button key={s.id} className={`chip ${on ? "on" : ""}`} aria-pressed={on} disabled={!next}
423
+ title={next ? `${on ? "Leave out" : "Add"} ${s.title}` : "At least one suite stays in view"} onClick={() => next && onScope({ kind: "index" }, next)}>{s.title}</button>;
424
+ })}
425
+ </div>
426
+ {coverage && <p className="note coverage">{coverage}</p>}
427
+ {bar}
428
+
429
+ {trials.length === 0 ? (
430
+ <div className="empty card">
431
+ <div className="kicker">{title}</div>
432
+ <h1>No trials in this view</h1>
433
+ <p>Run agent configs on these tasks and each trial shows here as soon as it is graded.</p>
434
+ <button className="btn btn-primary" onClick={onNew}>New run</button>
435
+ {notice?.([])}
436
+ </div>
437
+ ) : <>
438
+ <section className="card">
439
+ <div className="card-head">
440
+ <h2>{title} vs. cost per task</h2>
441
+ <div className="view-toggle" role="group" aria-label="Show as">
442
+ <button className={shown === "chart" ? "on" : ""} aria-pressed={shown === "chart"} onClick={() => setShown("chart")}>Chart</button>
443
+ <button className={shown === "table" ? "on" : ""} aria-pressed={shown === "table"} onClick={() => setShown("table")}>Table</button>
444
+ </div>
445
+ </div>
446
+ <p className="sub">{shown === "chart"
447
+ ? "Each point is one agent config: its mean over every comparable valid trial, with a 95% confidence interval. Click a point to read its best output."
448
+ : "One row per config in the view, pooled over every comparable valid trial. The chart and the table show the same configs."}</p>
449
+ {shown === "chart" ? <>
450
+ <div className="legend">
451
+ {harnesses.map((h) => <span key={h}><Marker s={{ harness: h, profile: "x", family: "_" }} neutral />{HARNESS_NAME[h]}</span>)}
452
+ <span className="sep" />
453
+ {families.map((f) => <span key={f}><span className="swatch" style={{ background: `var(--dataviz-${familySlot(f)})` }} />{familyName(f)}</span>)}
454
+ <span className="sep" />
455
+ <span><Marker s={{ harness: "claude", profile: undefined, family: "_" }} neutral />no Profile (user setup only)</span>
456
+ <span><span className="quad-key" />Most attractive quadrant</span>
457
+ <span><span className="pareto-key" />Pareto frontier</span>
458
+ {humans.length > 0 && <span><span className="human-key" />User (by hand): your score, no agent cost</span>}
459
+ </div>
460
+ <FrontierChart points={points} yTitle={title} humans={humans} onPick={pickConfig} />
461
+ </> : <ConfigTable rows={tableRows} onPick={pickConfig} />}
462
+ {notice?.(shown === "chart" ? unplotted.map((s) => notPlotted(view, s, sel, suites)) : [])}
463
+ <p className="note">Costs are API-equivalent. Claude Code and Pi report their own; Codex and Antigravity are estimated from tokens × <code>pricing.yaml</code>. On a flat subscription you pay in usage limits, not dollars.</p>
464
+ </section>
465
+
466
+ <div className="grid3">
467
+ <section className="card">
468
+ <h3>{title}</h3>
469
+ <p className="sub">Mean of valid trials · higher is better</p>
470
+ <BarList rows={summary.configs.map((s) => ({ s, v: s.harness === "human" ? humans.find((h) => h.s.configId === s.configId)?.score ?? null : pointFor(view, s, sel)?.score ?? null }))} fmt={(v) => v.toFixed(1)} />
471
+ </section>
472
+ <section className="card">
473
+ <h3>Cost per task</h3>
474
+ <p className="sub">API-equivalent USD · lower is better</p>
475
+ <BarList rows={agents.map((s) => ({ s, v: pointFor(view, s, sel)?.cost ?? null }))} fmt={money} lowerIsBetter />
476
+ </section>
477
+ <section className="card">
478
+ <h3>Time per task</h3>
479
+ <p className="sub">Agent wall-clock · lower is better</p>
480
+ <BarList rows={agents.map((s) => ({ s, v: pointFor(view, s, sel)?.durationMs ?? null }))} fmt={secs} lowerIsBetter />
481
+ </section>
482
+ </div>
483
+
484
+ {taskIds.length > 1 && <Heatmap view={view} suites={suites} />}
485
+ <TaskBreakdown view={view} configs={catalog.configs} />
486
+ <TrialsCard view={view} onTrial={onTrial} />
487
+ </>}
488
+ </>
489
+ );
490
+ }
491
+
492
+ /** GET /api/regrade: the regrade the server is running, and the last one that ended there. */
493
+ interface RegradeStatus { running: RegradeRun | null; last: RegradeEnded | null }
494
+
495
+ const trialsFrom = (c: { trials: number; configs: number }) =>
496
+ `${c.trials} trial${c.trials === 1 ? "" : "s"} from ${c.configs} config${c.configs === 1 ? "" : "s"}`;
497
+
498
+ /**
499
+ * The one alert in the chart card (#82, DEC-023): a button with a short count of what the view does not show, and
500
+ * a click that opens the details under it. The details float over the page (like the Filter menu), so the card
501
+ * is one line high whatever the number of notices. Each item says what is not shown and why, then what to do:
502
+ * - trials graded under another grading hash, or never graded: they come back with a regrade (judge calls only;
503
+ * POST /api/regrade with the view's query). One Regrade button covers both.
504
+ * - trials on another task hash did another job: the link switches the view to their version by setting the
505
+ * grading version filter, and reads by that grading version's name.
506
+ * - configs the chart cannot plot, grouped by reason.
507
+ * Escape or a click outside closes the details. Renders nothing when there is nothing to report.
508
+ */
509
+ function NoticeAlert({ view, scope, titles, search, regrade, error, notPlotted, onRegrade, onVersion, onLink }: {
510
+ view: View; scope: Scope; titles: Record<string, string>; search: string; regrade: RegradeStatus; error: string;
511
+ notPlotted: NotPlotted[]; onRegrade: () => void; onVersion: (gradingHash: string) => void; onLink: (href: string) => void;
512
+ }) {
513
+ const [open, setOpen] = useState(false);
514
+ const root = useRef<HTMLDivElement>(null), button = useRef<HTMLButtonElement>(null);
515
+ useEffect(() => {
516
+ if (!open) return;
517
+ const away = (e: MouseEvent) => { if (!root.current?.contains(e.target as Node)) setOpen(false); };
518
+ const esc = (e: KeyboardEvent) => { if (e.key === "Escape") { setOpen(false); button.current?.focus(); } };
519
+ document.addEventListener("mousedown", away);
520
+ document.addEventListener("keydown", esc);
521
+ return () => { document.removeEventListener("mousedown", away); document.removeEventListener("keydown", esc); };
522
+ }, [open]);
523
+ const { otherGrading, ungraded, otherTask, gradable } = view.leftOut;
524
+ const text = alertText(otherGrading.trials + ungraded.trials + otherTask.trials, notPlotted.length);
525
+ if (!text) return null;
526
+ const current = Object.values(view.tasks).every((t) => t.current);
527
+ const mine = regrade.running?.query === search;
528
+ const lastError = !regrade.running && regrade.last?.error && regrade.last.query === search ? regrade.last.error : "";
529
+ // The grading version each task version's link opens: picked by the view (linkTarget).
530
+ const setupFor = (v: OtherVersion) => v.gradingSetups.find((g) => g.hash === v.link);
531
+ const byTask = new Map<string, typeof otherTask.versions>();
532
+ for (const v of otherTask.versions) byTask.set(v.taskId, [...(byTask.get(v.taskId) ?? []), v]);
533
+ const groups = notPlottedGroups(notPlotted);
534
+ const grading = otherGrading.trials > 0 && gradingItem(otherGrading, { current, ungraded: ungraded.trials });
535
+ const notGraded = ungraded.trials > 0 && ungradedItem(ungraded, { current, otherGrading: otherGrading.trials });
536
+ const regradeBlock = current && (grading || notGraded) && (
537
+ <li className="notice-action">
538
+ <button className="btn btn-quiet" disabled={!!regrade.running} onClick={onRegrade}>
539
+ {mine ? `Regrading… ${regrade.running!.graded} of ${regrade.running!.total}` : regrade.running ? "Another regrade is running" : "Regrade"}
540
+ </button>
541
+ <p className="note">{regradeNote(gradable.length, !!view.coverage?.leftOut.length)}</p>
542
+ {(error || lastError) && <p className="error">The regrade did not finish: {error || lastError}</p>}
543
+ </li>
544
+ );
545
+ const how = howItWorksHref("left-out");
546
+ return (
547
+ <div className="notice pop" ref={root}>
548
+ {/* A live region holds only the count, so a change is announced and opening the details is not. */}
549
+ <div role="status"><button ref={button} type="button" className={`notice-btn${open ? " on" : ""}`} aria-expanded={open} aria-controls="notice-details" onClick={() => setOpen(!open)}>
550
+ <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 15 14H1z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" /><path d="M8 6v4M8 11.8v.4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
551
+ <span>{text}</span>
552
+ <span className="notice-more">{open ? "Hide details" : "Details"}</span>
553
+ </button></div>
554
+ {open && (
555
+ <div className="notice-details" id="notice-details" role="region" aria-label="What is not shown">
556
+ <ul>
557
+ {grading && <li><b>{grading.what}</b> {grading.todo}</li>}
558
+ {notGraded && <li><b>{notGraded.what}</b> {notGraded.todo}</li>}
559
+ {regradeBlock}
560
+ {[...byTask].map(([taskId, versions]) => {
561
+ const n = versions.reduce((a, v) => a + v.trials, 0);
562
+ const which = view.tasks[taskId]?.current ? "an earlier" : "another";
563
+ const what = scope.kind === "task" ? "this task" : titles[taskId] ?? taskId;
564
+ const links = versions.flatMap((v) => { const g = setupFor(v); return g ? [{ v, g }] : []; });
565
+ const tip = (v: OtherVersion, hash: string) => `Task hash ${v.taskHash} · grading hash ${hash}`;
566
+ return (
567
+ <li key={taskId}>
568
+ <b>{n} trial{n === 1 ? "" : "s"} ran on {which} version of {what}.</b> {n === 1 ? "It" : "They"} did a different job, so a regrade cannot bring {n === 1 ? "it" : "them"} in.{" "}
569
+ {links.length === 1 ? <button className="linkish" title={tip(links[0]!.v, links[0]!.g.hash)} onClick={() => onVersion(links[0]!.g.hash)}>Switch to that version: {links[0]!.g.name}</button>
570
+ : links.map(({ v, g }, i) => <span key={v.taskHash}>{i > 0 ? " · " : "Switch to: "}<button className="linkish" title={tip(v, g.hash)} onClick={() => onVersion(g.hash)}>{g.name} · {v.label != null ? `v${v.label}` : `task ${short(v.taskHash)}`} ({v.trials} trial{v.trials === 1 ? "" : "s"})</button></span>)}
571
+ </li>
572
+ );
573
+ })}
574
+ {groups.length > 0 && (
575
+ <li>
576
+ <b>{notPlotted.length} config{notPlotted.length === 1 ? " is" : "s are"} not on the chart.</b> A point needs a valid score on every task in view and a cost.
577
+ <ul className="notice-groups">{groups.map((g) => <li key={g.why}>{g.why}: {g.configs.join(", ")}. <span className="muted">{g.todo}</span></li>)}</ul>
578
+ The Table lists {notPlotted.length === 1 ? "it" : "them"} with the reason.
579
+ </li>
580
+ )}
581
+ </ul>
582
+ <a className="notice-how" href={how} onClick={(e) => { if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; e.preventDefault(); onLink(how); }}>Why trials are left out (How it works)</a>
583
+ </div>
584
+ )}
585
+ </div>
586
+ );
587
+ }
588
+
589
+ /**
590
+ * A task version: its readable label next to its short task hash (the hash is what decides comparability). A task
591
+ * file with no `version` has no label: its short task hash stands alone.
592
+ */
593
+ const VersionLabel = ({ label, hash }: { label: number | null; hash: string }) =>
594
+ <span className="version" title={`Task hash ${hash}`}>{label != null && <>v{label} · </>}task <code>{short(hash)}</code></span>;
595
+
596
+ const heat = (v?: number | null) => (v == null ? {} : { background: `color-mix(in srgb, var(--dataviz-7) ${Math.round(6 + (v / 100) ** 1.5 * 64)}%, var(--color-white))` });
597
+ const sortedConfigs = (view: View, key: (s: ViewConfigSummary) => number | null) => [...view.summary.configs].sort((a, b) => (key(b) ?? -1) - (key(a) ?? -1));
598
+
599
+ function Heatmap({ view, suites }: { view: View; suites: Map<string, Suite> }) {
600
+ const suiteIds = view.filters.suiteIds.filter((id) => suites.has(id));
601
+ const title = (t: string) => view.tasks[t]?.def.title ?? t;
602
+ return (
603
+ <section className="card">
604
+ <h2>Where each config is strong</h2>
605
+ <p className="sub">Score per task, Suite Index and Agent Performance Index (0–100). A blank cell means no valid trials; a config without a score on every task of a suite is partial there.</p>
606
+ <div className="table-wrap"><table>
607
+ <thead><tr><th>Configuration</th>
608
+ {suiteIds.flatMap((sid) => [...suites.get(sid)!.tasks.map((t) => <th key={`${sid}-${t}`}>{title(t)}</th>), <th key={sid} className="strong">{suites.get(sid)!.title} Index</th>])}
609
+ <th className="strong">Agent Performance Index</th></tr></thead>
610
+ <tbody>{sortedConfigs(view, (s) => s.index.mean).map((s) => (
611
+ <tr key={s.configId}>
612
+ <td className="cfg"><Marker s={s} />{s.label}<ConditionsNote c={s.conditions} /></td>
613
+ {suiteIds.flatMap((sid) => {
614
+ const p = s.partial[sid];
615
+ return [...suites.get(sid)!.tasks.map((t) => <td key={`${sid}-${t}`} style={heat(s.tasks[t]?.score.mean)}>{pct(s.tasks[t]?.score.mean)}</td>),
616
+ <td key={sid} className={`strong${p ? " muted" : ""}`} style={heat(s.suites[sid]?.mean)}>{p ? `partial (${p.have} of ${p.of} tasks)` : pct(s.suites[sid]?.mean)}</td>];
617
+ })}
618
+ <td className="strong" style={heat(s.index.mean)}>{pct(s.index.mean, 1)}</td>
619
+ </tr>
620
+ ))}</tbody>
621
+ </table></div>
622
+ </section>
623
+ );
624
+ }
625
+
626
+ type OtherVersion = View["leftOut"]["otherTask"]["versions"][number];
627
+
628
+ /**
629
+ * A menu of the tasks in view, for a per-task card (#81): the card shows one task at a time. With one task in
630
+ * view it has that one task.
631
+ */
632
+ function TaskMenu({ view, value, onPick }: { view: View; value: string; onPick: (id: string) => void }) {
633
+ return (
634
+ <select className="task-menu" aria-label="Task" value={value} onChange={(e) => onPick(e.target.value)}>
635
+ {Object.keys(view.tasks).map((id) => <option key={id} value={id}>{view.tasks[id]!.def.title}</option>)}
636
+ </select>
637
+ );
638
+ }
639
+
640
+ /** "Where the points come from": one card with a menu of the tasks in view, opening on the first (DEC-022). */
641
+ function TaskBreakdown({ view, configs }: { view: View; configs: AgentConfig[] }) {
642
+ const [picked, setPicked] = useState<string | null>(null);
643
+ const [open, setOpen] = useState(false);
644
+ const taskId = shownTask(Object.keys(view.tasks), picked);
645
+ if (!taskId) return null;
646
+ const vt = view.tasks[taskId]!;
647
+ const task = vt.def;
648
+ const inView = new Set(view.summary.configs.map((s) => s.configKey));
649
+ // A human config's note on its file, from the config file (its config key is human:<id>).
650
+ const notes = configs.flatMap((c) => (c.harness === "human" && inView.has(`human:${c.id}`) && c.outputs?.[taskId]?.note ? [{ id: c.id, label: configLabel(c), note: c.outputs[taskId].note! }] : []));
651
+ const rows = sortedConfigs(view, (s) => s.tasks[taskId]?.score.mean ?? null).filter((s) => s.tasks[taskId]?.valid > 0);
652
+ const dead = new Set(view.summary.deadCriteria[taskId] ?? []);
653
+ return (
654
+ <section className="card points-card">
655
+ <div className="card-head">
656
+ <h2>Where the points come from</h2>
657
+ <TaskMenu view={view} value={taskId} onPick={setPicked} />
658
+ </div>
659
+ <div className="kicker"><VersionLabel label={vt.label} hash={vt.taskHash} /></div>
660
+ <p className="sub">Measure score = % of its criteria passed (judge criteria count their share of passing votes). The task score is the mean of the measures.</p>
661
+ <div className="table-wrap"><table>
662
+ <thead><tr><th>Configuration</th>{task.measures.map((m) => <th key={m.id}>{m.label}</th>)}<th className="strong">Task score</th><th>Valid</th></tr></thead>
663
+ <tbody>
664
+ {rows.map((s) => {
665
+ const t = s.tasks[taskId];
666
+ return (
667
+ <tr key={s.configId}>
668
+ <td className="cfg"><Marker s={s} />{s.label}</td>
669
+ {task.measures.map((m) => <td key={m.id} style={heat(t.measures[m.id])}>{pct(t.measures[m.id])}</td>)}
670
+ <td className="strong">{pct(t.score.mean, 1)}{t.score.ci != null && <span className="ci"> ±{t.score.ci.toFixed(0)}</span>}</td>
671
+ <td className="muted">{t.valid}/{t.total}</td>
672
+ </tr>
673
+ );
674
+ })}
675
+ </tbody>
676
+ </table></div>
677
+ {notes.map((n) => <p key={n.id} className="note">{n.label}: {n.note}</p>)}
678
+
679
+ <button className="btn btn-quiet" onClick={() => setOpen((o) => !o)} aria-expanded={open}>{open ? "Hide" : "Show"} every criterion</button>
680
+ {open && (
681
+ <div className="table-wrap"><table className="criteria">
682
+ <thead><tr><th>Criterion</th><th>Level</th><th>Grader</th>{rows.map((s) => <th key={s.configId} title={s.label}><Marker s={s} size={12} /></th>)}</tr></thead>
683
+ <tbody>
684
+ {[{ id: "gates", label: "Gates", criteria: task.gates }, ...task.measures].filter((m) => m.criteria.length).map((m) => [
685
+ <tr key={m.id} className="group"><td colSpan={3 + rows.length}>{m.label}</td></tr>,
686
+ ...m.criteria.map((c) => (
687
+ <tr key={c.id}>
688
+ <td className="q" title={c.question}>{c.id}{dead.has(c.id) && <span className="tag warn" title="Every config passed or every config failed: no signal">dead</span>}</td>
689
+ <td><LevelTag level={c.level} /></td>
690
+ <td className="muted">{c.grader}</td>
691
+ {rows.map((s) => { const r = s.tasks[taskId].criteria[c.id]; return <td key={s.configId} style={heat(r == null ? null : r * 100)}>{r == null ? "—" : Math.round(r * 100)}</td>; })}
692
+ </tr>
693
+ )),
694
+ ])}
695
+ </tbody>
696
+ </table></div>
697
+ )}
698
+ {open && <LevelLegend />}
699
+ </section>
700
+ );
701
+ }
702
+
703
+ const STATUS_TEXT: Record<Trial["status"], string> = { queued: "Queued", running: "Working…", grading: "Grading…", done: "", invalid: "Invalid", failed: "Failed" };
704
+
705
+ /** "Trials": one card with a menu of the tasks in view, opening on the first (DEC-022). */
706
+ function TrialsCard({ view, onTrial }: { view: View; onTrial: (id: string) => void }) {
707
+ const { trials } = view;
708
+ const [picked, setPicked] = useState<string | null>(null);
709
+ const tid = shownTask(Object.keys(view.tasks), picked);
710
+ return (
711
+ <section className="card trials-card">
712
+ <div className="card-head">
713
+ <h2>Trials</h2>
714
+ {tid && <TaskMenu view={view} value={tid} onPick={setPicked} />}
715
+ </div>
716
+ <p className="sub">Every trial of this task in the view, from every batch. Click a finished trial to read each criterion's verdict. Invalid = a grader error (re-grade it); failed = the harness could not run.</p>
717
+ {tid && (
718
+ <div className="trial-task">
719
+ <div className="trial-rows">
720
+ {view.summary.configs.filter((s) => trials.some((t) => t.configKey === s.configKey && t.taskId === tid)).map((s) => (
721
+ <div className="trial-row" key={s.configKey}>
722
+ <div className="cfg"><Marker s={s} />{s.label}<ConditionsNote c={s.pooled[tid]} /></div>
723
+ <div className="trial-cells">
724
+ {trials.filter((t) => t.configKey === s.configKey && t.taskId === tid).map((t) => (
725
+ <button key={t.id} className={`cell ${t.status}`} onClick={() => onTrial(t.id)} title={t.error ?? `Batch ${t.batchId}`}>
726
+ <span className="k">Trial {t.index}</span>
727
+ <span className="v">{t.status === "done" ? t.grades!.score.toFixed(1) : STATUS_TEXT[t.status]}</span>
728
+ {t.status === "done" && <span className="k">{s.harness === "human" ? "by hand" : <>{money(t.result?.costUsd)} · {secs(t.durationMs)}</>}</span>}
729
+ </button>
730
+ ))}
731
+ </div>
732
+ </div>
733
+ ))}
734
+ </div>
735
+ </div>
736
+ )}
737
+ </section>
738
+ );
739
+ }
740
+
741
+ /**
742
+ * When a config's pooled trials ran under more than one user setup or Profile version, e.g. "2 setups, 2 Profile
743
+ * versions". Information, not a warning: run conditions never decide comparability (DEC-3).
744
+ */
745
+ function conditionsText(c: { setups: number; profiles: number } | undefined): string | null {
746
+ if (!c) return null;
747
+ const parts = [c.setups > 1 && `${c.setups} setups`, c.profiles > 1 && `${c.profiles} Profile versions`].filter(Boolean);
748
+ return parts.length ? parts.join(", ") : null;
749
+ }
750
+ const ConditionsNote = ({ c }: { c: { setups: number; profiles: number } | undefined }) => {
751
+ const text = conditionsText(c);
752
+ return text ? <span className="conditions" title="Its pooled trials ran under these run conditions. Shown for information; they do not affect comparability.">{text}</span> : null;
753
+ };
754
+
755
+ const when = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "—");
756
+ const GRADING_STATUS: Record<string, string> = { grading: "Grading…", done: "Done", invalid: "Invalid" };
757
+
758
+ /** The trial's run conditions: shown, never part of comparability. */
759
+ function RunConditions({ trial }: { trial: TrialDetail["trial"] }) {
760
+ const hash = (h: string | null) => (h ? <code title={h}>{short(h)}</code> : <span className="muted">none recorded</span>);
761
+ return (
762
+ <div className="conditions-list">
763
+ <h3>Run conditions</h3>
764
+ <dl>
765
+ <dt>User setup</dt><dd>{hash(trial.setupHash)}</dd>
766
+ <dt>Profile</dt><dd>{hash(trial.profileHash)}</dd>
767
+ <dt>Blocked tools</dt><dd>{trial.blockedTools == null ? <span className="muted">none recorded</span> : trial.blockedTools.length ? trial.blockedTools.join(", ") : "none"}</dd>
768
+ <dt>Harness version</dt><dd>{trial.harnessVersion ?? <span className="muted">none recorded</span>}</dd>
769
+ <dt>Started</dt><dd>{when(trial.startedAt)}</dd>
770
+ </dl>
771
+ </div>
772
+ );
773
+ }
774
+
775
+ /** Every grading of the trial, oldest first; the one the current view counts is marked. */
776
+ function GradingList({ gradings, counted }: { gradings: TrialDetail["gradings"]; counted: number | null }) {
777
+ return (
778
+ <div className="gradings">
779
+ <h3>Gradings</h3>
780
+ {gradings.length === 0 ? <p className="muted">Not graded yet.</p> : (
781
+ <ul>{gradings.map((g) => (
782
+ <li key={g.id} className={g.id === counted ? "counted" : undefined}>
783
+ <span className="g-name">{g.setup?.name ?? "Unknown grading version"}</span>
784
+ <span>{g.setup?.judgeLabel ?? "Unknown judge"}</span>
785
+ <span className="muted">{when(g.gradedAt)}</span>
786
+ <span className="g-score">{g.status === "done" && g.score != null ? g.score.toFixed(1) : "—"}</span>
787
+ <span className={`g-status ${g.status}`}>{GRADING_STATUS[g.status] ?? g.status}</span>
788
+ <code title={`Grading hash ${g.gradingHash}`}>{short(g.gradingHash)}</code>
789
+ {g.id === counted && <span className="g-counted">Counts in this view</span>}
790
+ </li>
791
+ ))}</ul>
792
+ )}
793
+ </div>
794
+ );
795
+ }
796
+
797
+ type DrawerTab = "output" | "verdicts" | "run";
798
+ const DRAWER_TABS: { id: DrawerTab; label: string }[] = [{ id: "output", label: "Output" }, { id: "verdicts", label: "Verdicts" }, { id: "run", label: "Run details" }];
799
+
800
+ /** The dashboard's token values the Markdown frame's styles use (web/output-view.ts), read from this page. */
801
+ const proseTokens = () => {
802
+ const css = getComputedStyle(document.documentElement);
803
+ return Object.fromEntries(PROSE_TOKENS.map((k) => [k, css.getPropertyValue(k).trim()]));
804
+ };
805
+
806
+ function TrialDrawer({ view, trial, tick, onClose }: { view: View; trial: ViewTrial; tick: number; onClose: () => void }) {
807
+ const s = view.summary.configs.find((x) => x.configId === trial.configId)!;
808
+ const config = s;
809
+ const task = view.tasks[trial.taskId].def;
810
+ const [text, setText] = useState("");
811
+ const [kind, setKind] = useState<OutputKind | null>(null); // null until the output has loaded
812
+ const [missing, setMissing] = useState(""); // why there is no output to show
813
+ const [image, setImage] = useState<string | null>(null); // an object URL when the output is an image
814
+ const [detail, setDetail] = useState<TrialDetail | null>(null);
815
+ const [detailError, setDetailError] = useState("");
816
+ const [tab, setTab] = useState<DrawerTab>("output");
817
+ const [view2, setView2] = useState<"rendered" | "source">("rendered");
818
+ // The output never changes once the trial has ended: loaded once per trial. How it shows comes from the server's
819
+ // media type (an image) and the output path it sends (web/output-view.ts). It never goes into this page itself.
820
+ useEffect(() => {
821
+ let live = true, objectUrl: string | null = null;
822
+ setText("");
823
+ setImage(null);
824
+ setKind(null);
825
+ setMissing("");
826
+ fetch(`/api/trials/${encodeURIComponent(trial.id)}/output`)
827
+ .then(async (r) => {
828
+ if (!r.ok) { const why = await r.text(); if (live) setMissing(why); return; }
829
+ const k = outputKind(decodeURIComponent(r.headers.get("x-output-path") ?? ""), r.headers.get("content-type"));
830
+ if (k === "image") {
831
+ const blob = await r.blob();
832
+ if (live) { objectUrl = URL.createObjectURL(blob); setImage(objectUrl); setKind(k); }
833
+ } else {
834
+ const t = await r.text();
835
+ if (live) { setText(t); setKind(k); }
836
+ }
837
+ })
838
+ .catch(() => {});
839
+ return () => { live = false; if (objectUrl) URL.revokeObjectURL(objectUrl); };
840
+ }, [trial.id]);
841
+ // Another trial opens on its output, rendered.
842
+ useEffect(() => { setDetail(null); setDetailError(""); setTab("output"); setView2("rendered"); }, [trial.id]);
843
+ // Its gradings do change (a regrade adds one): loaded again on each database event.
844
+ useEffect(() => {
845
+ let live = true;
846
+ api<TrialDetail>(`/api/trials/${encodeURIComponent(trial.id)}`)
847
+ .then((d) => { if (live) { setDetail(d); setDetailError(""); } })
848
+ .catch((e: Error) => { if (live) setDetailError(e.message); });
849
+ return () => { live = false; };
850
+ }, [trial.id, tick]);
851
+ useEffect(() => {
852
+ const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
853
+ window.addEventListener("keydown", k);
854
+ return () => window.removeEventListener("keydown", k);
855
+ }, []);
856
+ const { fields, body } = useMemo(() => {
857
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
858
+ const field = (k: string) => m?.[1].match(new RegExp(`^${k}:\\s*["']?(.*?)["']?\\s*$`, "m"))?.[1];
859
+ return { fields: { title: field("title"), description: field("description") }, body: m ? text.slice(m[0].length) : text };
860
+ }, [text]);
861
+ // Markdown and other text: rendered into a frame that runs no code. Image placeholders (often `![…](TODO)`) are
862
+ // shown as labels, not loaded.
863
+ const doc = useMemo(() => {
864
+ if (kind !== "markdown" && kind !== "text") return "";
865
+ const html = marked.parse(body.replace(/!\[([^\]]*)\]\([^)]*\)/g, (_, alt) => `<span class="img-ph">Image: ${alt || "untitled"}</span>`), { async: false }) as string;
866
+ return proseDoc(html, proseTokens());
867
+ }, [body, kind]);
868
+ const g = trial.grades;
869
+ const groups = [{ id: "gates", label: "Gates", criteria: [{ id: "output-exists", question: "Did the agent write the output file?", level: "floor", grader: "check" } as Criterion, ...task.gates] }, ...task.measures];
870
+ const frameTitle = `Output of ${s.label} for ${task.title}, trial ${trial.index}`;
871
+
872
+ return (
873
+ <>
874
+ <div className="scrim" onClick={onClose} />
875
+ <div className="drawer" role="dialog" aria-label="Trial detail">
876
+ <div className="drawer-head">
877
+ <div>
878
+ <div className="kicker"><Marker s={s} /> {task.title} · trial {trial.index} · batch {trial.batchId}</div>
879
+ <h2>{s.label}</h2>
880
+ <div className="stats">
881
+ <Stat k="Score" v={g ? g.score.toFixed(1) : "—"} />
882
+ <Stat k="Cost" v={money(trial.result?.costUsd) + (trial.result?.costSource === "estimated" ? " est." : "")} />
883
+ <Stat k="Time" v={secs(trial.durationMs)} />
884
+ <Stat k="Turns" v={String(trial.result?.turns ?? "—")} />
885
+ <Stat k="Model · effort" v={config.harness === "human" ? "by hand" : `${config.model} · ${config.effort ?? "default"}`} />
886
+ <Stat k="Profile" v={config.profile ?? "none"} />
887
+ </div>
888
+ </div>
889
+ <button className="close" onClick={onClose} aria-label="Close">×</button>
890
+ </div>
891
+ <div className="drawer-tabs" role="tablist" aria-label="Trial detail">
892
+ {DRAWER_TABS.map((t) => (
893
+ <button key={t.id} role="tab" id={`drawer-tab-${t.id}`} aria-controls="drawer-panel" aria-selected={tab === t.id} className={`drawer-tab ${tab === t.id ? "on" : ""}`} onClick={() => setTab(t.id)}>{t.label}</button>
894
+ ))}
895
+ </div>
896
+ <div className={`drawer-panel ${tab}`} id="drawer-panel" role="tabpanel" aria-labelledby={`drawer-tab-${tab}`}>
897
+ {tab === "output" && <>
898
+ {trial.error && <p className="error">{trial.error}</p>}
899
+ {kind === null ? <p className="muted">{missing || "Loading…"}</p>
900
+ : kind === "image" ? (image && <img className="output-image" src={image} alt={frameTitle} />) : <>
901
+ <div className="tabs">
902
+ <button className={`tab ${view2 === "rendered" ? "on" : ""}`} aria-pressed={view2 === "rendered"} onClick={() => setView2("rendered")}>Rendered</button>
903
+ <button className={`tab ${view2 === "source" ? "on" : ""}`} aria-pressed={view2 === "source"} onClick={() => setView2("source")}>{sourceLabel(kind)}</button>
904
+ </div>
905
+ {view2 === "source" ? <pre className="raw">{text}</pre>
906
+ : kind === "html" ? <iframe className="output-frame page" title={frameTitle} sandbox={HTML_SANDBOX} referrerPolicy="no-referrer" srcDoc={text} />
907
+ : <>
908
+ {(fields.title || fields.description) && <div className="envelope"><div className="k">Subject</div><div className="subject">{fields.title ?? "—"}</div><div className="k">Preview</div><div>{fields.description ?? "—"}</div></div>}
909
+ <iframe className="output-frame prose-frame" title={frameTitle} sandbox={PROSE_SANDBOX} referrerPolicy="no-referrer" srcDoc={doc} />
910
+ </>}
911
+ </>}
912
+ </>}
913
+ {tab === "verdicts" && <>
914
+ <LevelLegend />
915
+ {groups.map((m) => (
916
+ <div key={m.id} className="verdict-group">
917
+ <div className="crit-head"><b>{m.label}</b>{m.id !== "gates" && g && <span className="crit-score">{pct(g.measures[m.id])}</span>}</div>
918
+ {m.criteria.map((c) => {
919
+ const v = g?.verdicts[c.id];
920
+ const state = v?.score == null ? "err" : v.score === 1 ? "pass" : v.score === 0 ? "fail" : "split";
921
+ return (
922
+ <div className={`crit ${state}`} key={c.id}>
923
+ <div className="crit-top">
924
+ <span className={`badge ${state}`}>{state === "err" ? "Error" : state === "split" ? `${v!.votes.filter((x) => x.pass).length}/${v!.votes.length}` : state === "pass" ? "Pass" : "Fail"}</span>
925
+ <span className="q">{c.question}</span>
926
+ </div>
927
+ <div className="crit-meta"><LevelTag level={c.level} /><span className="muted">{c.grader}</span></div>
928
+ {v?.error && <p className="error">{v.error}</p>}
929
+ {v?.votes.map((x, i) => <p key={i} className="reason"><b>{x.pass ? "Yes" : "No"}.</b> {x.reason}</p>)}
930
+ </div>
931
+ );
932
+ })}
933
+ </div>
934
+ ))}
935
+ </>}
936
+ {tab === "run" && <>
937
+ {detailError && <p className="error">{detailError}</p>}
938
+ {detail ? <><RunConditions trial={detail.trial} /><GradingList gradings={detail.gradings} counted={trial.gradingId} /></> : !detailError && <p className="muted">Loading…</p>}
939
+ </>}
940
+ </div>
941
+ </div>
942
+ </>
943
+ );
944
+ }
945
+
946
+ const Stat = ({ k, v }: { k: string; v: string }) => <div className="stat"><div className="k">{k}</div><div className="v">{v}</div></div>;
947
+
948
+ /** What GET /api/batches returns for each batch (src/core/batches.ts and src/core/batch-state.ts). */
949
+ interface BatchRow {
950
+ batch: { id: string; startedAt: string; endedAt: string | null };
951
+ request: { tasks?: string[]; suites?: string[]; configs?: string[]; trials?: number; judge?: string; votes?: number; taskIds?: string[] };
952
+ counts: Record<"queued" | "running" | "done" | "failed" | "invalid", number>;
953
+ total: number;
954
+ lastMoveAt: string;
955
+ state: "running" | "stalled" | "ended";
956
+ }
957
+ interface BatchTrialRow { id: string; taskId: string; configId: string; n: number; status: string; error: string | null }
958
+ const STATUSES = ["done", "failed", "invalid", "running", "queued"] as const;
959
+ const STATE_TEXT = { running: "Running", stalled: "Not ended", ended: "Ended" } as const;
960
+
961
+ /** The last read of GET /api/batches: rows is null until the first read; error is the last read's failure, if any. */
962
+ interface Batches { rows: BatchRow[] | null; error: string }
963
+
964
+ /**
965
+ * The one reader of GET /api/batches, for the indicator and the Runs page both, so they show the same state. It reads
966
+ * on each /api/events tick, and on a timer, since a batch turns stalled by the clock alone, with no database change
967
+ * and so no event.
968
+ */
969
+ function useBatches(tick: number): Batches {
970
+ const [batches, setBatches] = useState<Batches>({ rows: null, error: "" });
971
+ const [beat, setBeat] = useState(0);
972
+ useEffect(() => {
973
+ const id = setInterval(() => setBeat((n) => n + 1), STATUS_REFRESH_MS);
974
+ return () => clearInterval(id);
975
+ }, []);
976
+ useEffect(() => {
977
+ let live = true;
978
+ api<BatchRow[]>("/api/batches")
979
+ .then((rows) => live && setBatches({ rows, error: "" }))
980
+ .catch((e: Error) => live && setBatches((b) => ({ ...b, error: e.message })));
981
+ return () => { live = false; };
982
+ }, [tick, beat]);
983
+ return batches;
984
+ }
985
+
986
+ /** The run status indicator in the top bar (US-005). A failed read shows as unavailable. A click opens Runs. */
987
+ function RunStatusButton({ batches, onOpen }: { batches: Batches; onOpen: () => void }) {
988
+ const status: RunStatus | null = batches.error ? { kind: "unavailable", error: batches.error } : batches.rows && runStatus(batches.rows);
989
+ if (!status) return null;
990
+ const dot = status.kind === "running" ? "run" : status.kind === "idle" && status.stalled ? "stalled" : status.kind;
991
+ return (
992
+ <button className="run-status" title="Open the Runs page" aria-label={`${statusText(status, when)}. Open the Runs page`} onClick={onOpen}>
993
+ <span className={`dot ${dot}`} aria-hidden="true" />
994
+ <span className="run-status-text">{statusText(status, when)}</span>
995
+ {status.kind === "running" && <>
996
+ <span className="mini" aria-hidden="true"><b style={{ width: `${status.total ? (status.ended / status.total) * 100 : 0}%` }} /></span>
997
+ <span className="run-status-count">{status.ended} of {status.total}</span>
998
+ </>}
999
+ </button>
1000
+ );
1001
+ }
1002
+
1003
+ /** The Runs page: New run, and every batch with its progress. */
1004
+ function RunsPage({ batches, tick, onNew }: { batches: Batches; tick: number; onNew: () => void }) {
1005
+ return (
1006
+ <>
1007
+ <section className="hero page-head">
1008
+ <div>
1009
+ <div className="kicker">Runs</div>
1010
+ <h1>Runs</h1>
1011
+ <p className="sub">Start a run and watch its progress here. Each trial shows on the Results page as soon as it is graded.</p>
1012
+ </div>
1013
+ <button className="btn btn-primary" onClick={onNew}>New run</button>
1014
+ </section>
1015
+ <BatchesPanel batches={batches} tick={tick} onNew={onNew} />
1016
+ </>
1017
+ );
1018
+ }
1019
+
1020
+ /** Every batch: running and not-ended ones first, then the ended ones, newest first. The rows come from useBatches. */
1021
+ function BatchesPanel({ batches: { rows, error }, tick, onNew }: { batches: Batches; tick: number; onNew: () => void }) {
1022
+ const [open, setOpen] = useState<string | null>(null);
1023
+ if (error) return <section className="card batches"><h2>Batches</h2><p className="error">{error}</p></section>;
1024
+ if (!rows) return <section className="card batches"><h2>Batches</h2><p className="muted">Loading…</p></section>;
1025
+ if (!rows.length) return (
1026
+ <div className="empty card batches"><div className="kicker">Batches</div><h1>No runs yet</h1><p>Start one and its progress shows here.</p><button className="btn btn-primary" onClick={onNew}>New run</button></div>
1027
+ );
1028
+ // The route lists newest first; the order holds within each group.
1029
+ const shown = [...rows.filter((b) => b.state !== "ended"), ...rows.filter((b) => b.state === "ended")];
1030
+ return (
1031
+ <section className="card batches" aria-label="Batches">
1032
+ <h2>Batches</h2>
1033
+ <p className="sub">Running and not-ended batches first, then the ones that ended, newest first. Click a batch for each trial's status.</p>
1034
+ <div className="batch-cols" aria-hidden="true"><span>Batch</span><span>Asked for</span><span>Progress</span><span>State</span></div>
1035
+ <ul className="batch-list">
1036
+ {shown.map((b) => <BatchItem key={b.batch.id} b={b} open={open === b.batch.id} onToggle={() => setOpen(open === b.batch.id ? null : b.batch.id)} tick={tick} />)}
1037
+ </ul>
1038
+ </section>
1039
+ );
1040
+ }
1041
+
1042
+ function BatchItem({ b, open, onToggle, tick }: { b: BatchRow; open: boolean; onToggle: () => void; tick: number }) {
1043
+ const [trials, setTrials] = useState<BatchTrialRow[] | null>(null);
1044
+ useEffect(() => {
1045
+ if (!open) return;
1046
+ let live = true;
1047
+ api<{ trials: BatchTrialRow[] }>(`/api/batches/${encodeURIComponent(b.batch.id)}`).then((d) => live && setTrials(d.trials)).catch(() => live && setTrials(null));
1048
+ return () => { live = false; };
1049
+ }, [open, tick, b.batch.id]);
1050
+ const r = b.request;
1051
+ const asked = r.suites?.length ? `Suite${r.suites.length === 1 ? "" : "s"} ${r.suites.join(", ")}` : `Task${(r.tasks?.length ?? 0) === 1 ? "" : "s"} ${(r.tasks ?? r.taskIds ?? []).join(", ")}`;
1052
+ const parts = [`${b.counts.done} of ${b.total} done`, ...STATUSES.filter((s) => s !== "done" && b.counts[s] > 0).map((s) => `${b.counts[s]} ${s}`)];
1053
+ return (
1054
+ <li className={`batch ${b.state}`}>
1055
+ <button className="batch-head" aria-expanded={open} onClick={onToggle}>
1056
+ <span className="batch-id"><b>{when(b.batch.startedAt)}</b><code>{b.batch.id}</code></span>
1057
+ <span className="batch-asked">{asked} · {r.configs?.length ?? 0} config{r.configs?.length === 1 ? "" : "s"} · {b.total} trial{b.total === 1 ? "" : "s"}</span>
1058
+ <span className="batch-progress">
1059
+ <span className="batch-bar" role="img" aria-label={parts.join(", ")}>
1060
+ {STATUSES.map((s) => b.counts[s] > 0 && <span key={s} className={`seg ${s}`} style={{ flexGrow: b.counts[s] }} title={`${b.counts[s]} ${s}`} />)}
1061
+ </span>
1062
+ <span className="batch-line">{parts.join(" · ")}</span>
1063
+ </span>
1064
+ <span className={`batch-state ${b.state}`}>{STATE_TEXT[b.state]}</span>
1065
+ </button>
1066
+ {b.state === "stalled" && <p className="note warn">Not ended. No trial has moved since {when(b.lastMoveAt)}; its process may have stopped.</p>}
1067
+ {open && (trials ? (
1068
+ <table className="batch-trials">
1069
+ <thead><tr><th>Task</th><th>Config</th><th>#</th><th>Status</th></tr></thead>
1070
+ <tbody>{trials.map((t) => <tr key={t.id}><td>{t.taskId}</td><td>{t.configId}</td><td>{t.n}</td><td><span className={`status ${t.status}`}>{t.status}</span>{t.error && <span className="muted" title={t.error}> · {t.error.slice(0, 80)}</span>}</td></tr>)}</tbody>
1071
+ </table>
1072
+ ) : <p className="muted">Loading…</p>)}
1073
+ </li>
1074
+ );
1075
+ }
1076
+
1077
+ /** The New run dialog: tasks or suites, configs, trials, judge and votes. Start sends POST /api/runs. */
1078
+ function NewRun({ onClose, onStarted }: { onClose: () => void; onStarted: (id: string) => void }) {
1079
+ const [configs, setConfigs] = useState<AgentConfig[]>([]);
1080
+ const [suites, setSuites] = useState<Suite[]>([]);
1081
+ const [tasks, setTasks] = useState<{ id: string; title: string }[]>([]);
1082
+ const [judges, setJudges] = useState<JudgeConfig[]>([]);
1083
+ const [by, setBy] = useState<"suites" | "tasks">("suites");
1084
+ const [pickedSuites, setPickedSuites] = useState<Set<string>>(new Set());
1085
+ const [pickedTasks, setPickedTasks] = useState<Set<string>>(new Set());
1086
+ const [picked, setPicked] = useState<Set<string>>(new Set());
1087
+ const [judgeId, setJudgeId] = useState("");
1088
+ const [trials, setTrials] = useState(3);
1089
+ const [votes, setVotes] = useState(3);
1090
+ const [busy, setBusy] = useState(false);
1091
+ const [error, setError] = useState("");
1092
+ useEffect(() => {
1093
+ api<AgentConfig[]>("/api/configs").then((c) => { setConfigs(c); setPicked(new Set(c.filter((x) => x.harness !== "pi").map((x) => x.id))); }).catch((e: Error) => setError(e.message));
1094
+ api<Suite[]>("/api/suites").then((s) => { setSuites(s); setPickedSuites(new Set(s.map((x) => x.id))); }).catch((e: Error) => setError(e.message));
1095
+ api<{ id: string; title: string }[]>("/api/tasks").then(setTasks).catch((e: Error) => setError(e.message));
1096
+ api<JudgeConfig[]>("/api/judges").then((j) => { setJudges(j); setJudgeId(j[0]?.id ?? ""); }).catch((e: Error) => setError(e.message));
1097
+ }, []);
1098
+ const toggle = (set: Set<string>, fn: (s: Set<string>) => void, id: string) => { const n = new Set(set); n.has(id) ? n.delete(id) : n.add(id); fn(n); };
1099
+ const taskIds = by === "suites" ? new Set(suites.filter((s) => pickedSuites.has(s.id)).flatMap((s) => s.tasks)) : pickedTasks;
1100
+ const pickedConfigs = configs.filter((c) => picked.has(c.id));
1101
+ const agentConfigs = pickedConfigs.filter((c) => c.harness !== "human").length;
1102
+ const runs = taskIds.size * agentConfigs * trials;
1103
+ const handOutputs = pickedConfigs.filter((c) => c.harness === "human").reduce((n, c) => n + Object.keys(c.outputs ?? {}).filter((t) => taskIds.has(t)).length, 0);
1104
+ const start = async () => {
1105
+ setBusy(true); setError("");
1106
+ const scope = by === "suites" ? { suites: [...pickedSuites] } : { tasks: [...pickedTasks] };
1107
+ try {
1108
+ const r = await fetch("/api/runs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...scope, configs: [...picked], trials, judge: judgeId, votes }) });
1109
+ const body = await r.json().catch(() => ({ error: `${r.status} ${r.statusText}` }));
1110
+ if (!r.ok) { setError(body.error); setBusy(false); return; }
1111
+ onStarted(body.id);
1112
+ } catch (e) { setError(e instanceof Error ? e.message : String(e)); setBusy(false); }
1113
+ };
1114
+ const markerOf = (c: AgentConfig) => ({ harness: c.harness, profile: c.profile, family: c.family ?? familyOf(c.model) });
1115
+ const scopePicked = by === "suites" ? pickedSuites.size : pickedTasks.size;
1116
+ return (
1117
+ <>
1118
+ <div className="scrim" onClick={onClose} />
1119
+ <div className="modal" role="dialog" aria-label="New run">
1120
+ <h2>New run</h2>
1121
+ <div className="chips" role="tablist" aria-label="Run by">
1122
+ <button role="tab" aria-selected={by === "suites"} className={`chip ${by === "suites" ? "on" : ""}`} onClick={() => setBy("suites")}>Suites</button>
1123
+ <button role="tab" aria-selected={by === "tasks"} className={`chip ${by === "tasks" ? "on" : ""}`} onClick={() => setBy("tasks")}>Tasks</button>
1124
+ </div>
1125
+ {by === "suites" ? (
1126
+ <fieldset><legend>Suites</legend>
1127
+ {suites.map((s) => <label className="opt" key={s.id}><input type="checkbox" checked={pickedSuites.has(s.id)} onChange={() => toggle(pickedSuites, setPickedSuites, s.id)} />{s.title}<span className="muted">{s.tasks.length} task{s.tasks.length > 1 ? "s" : ""}</span></label>)}
1128
+ </fieldset>
1129
+ ) : (
1130
+ <fieldset><legend>Tasks</legend>
1131
+ {tasks.map((t) => <label className="opt" key={t.id}><input type="checkbox" checked={pickedTasks.has(t.id)} onChange={() => toggle(pickedTasks, setPickedTasks, t.id)} />{t.title}<span className="muted">{t.id}</span></label>)}
1132
+ </fieldset>
1133
+ )}
1134
+ <fieldset><legend>Configs</legend>
1135
+ {configs.map((c) => <label className="opt" key={c.id}><input type="checkbox" checked={picked.has(c.id)} onChange={() => toggle(picked, setPicked, c.id)} /><Marker s={markerOf(c)} />{configLabel(c)}</label>)}
1136
+ </fieldset>
1137
+ <fieldset><legend>Judge</legend>
1138
+ <select value={judgeId} onChange={(e) => setJudgeId(e.target.value)} aria-label="Judge">
1139
+ {judges.map((j) => <option key={j.id} value={j.id}>{j.label}</option>)}
1140
+ </select>
1141
+ <p className="note">Runs every judge criterion. Scores from different judges are not comparable.</p>
1142
+ </fieldset>
1143
+ <div className="row">
1144
+ <label>Trials per config <input type="number" min={1} max={10} value={trials} onChange={(e) => setTrials(Number(e.target.value))} /></label>
1145
+ <label>Votes per judge criterion <input type="number" min={1} max={7} value={votes} onChange={(e) => setVotes(Number(e.target.value))} /></label>
1146
+ </div>
1147
+ <p className="note">{runs} agent runs ({taskIds.size} task{taskIds.size === 1 ? "" : "s"} × {agentConfigs} agent configs × {trials} trials){handOutputs > 0 && <> + {handOutputs} file{handOutputs > 1 ? "s" : ""} written by hand</>}, each graded with up to {votes} judge vote{votes > 1 ? "s" : ""} per measure (the last ones run only when the first disagree).</p>
1148
+ {error && <p className="error" role="alert">{error}</p>}
1149
+ <div className="row end">
1150
+ <button className="btn" onClick={onClose}>Cancel</button>
1151
+ <button className="btn btn-primary" disabled={!picked.size || !scopePicked || busy} onClick={start}>Start</button>
1152
+ </div>
1153
+ </div>
1154
+ </>
1155
+ );
1156
+ }
1157
+
1158
+ // Same rule as the server's familyOf (src/core/score.ts), for configs not yet in a summary.
1159
+ const EFFORT = new Set(["low", "medium", "high", "xhigh", "max", "minimal"]);
1160
+ const familyOf = (model: string) => model.toLowerCase().split(/[-_/:]/).filter((p) => p && !/\d/.test(p) && !EFFORT.has(p)).slice(0, 2).join("-") || model;
1161
+
1162
+ createRoot(document.getElementById("root")!).render(<App />);