@crewhaus/hangar-ui 0.5.0

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 (51) hide show
  1. package/README.md +309 -0
  2. package/assets/hangar.css +1726 -0
  3. package/assets/index.html +21 -0
  4. package/assets/js/api.js +368 -0
  5. package/assets/js/app.js +734 -0
  6. package/assets/js/dom.js +228 -0
  7. package/assets/js/keys.js +159 -0
  8. package/assets/js/markdown.js +131 -0
  9. package/assets/js/notify.js +152 -0
  10. package/assets/js/omnibox.js +307 -0
  11. package/assets/js/router.js +280 -0
  12. package/assets/js/routes.js +793 -0
  13. package/assets/js/shapes.js +36 -0
  14. package/assets/js/supervision.js +1334 -0
  15. package/assets/js/util.js +426 -0
  16. package/assets/js/views/activity.js +140 -0
  17. package/assets/js/views/approvals.js +155 -0
  18. package/assets/js/views/channels.js +455 -0
  19. package/assets/js/views/control.js +253 -0
  20. package/assets/js/views/costs.js +103 -0
  21. package/assets/js/views/creds.js +610 -0
  22. package/assets/js/views/data.js +456 -0
  23. package/assets/js/views/deploy.js +106 -0
  24. package/assets/js/views/evals-lab.js +1029 -0
  25. package/assets/js/views/evals.js +342 -0
  26. package/assets/js/views/feedback.js +555 -0
  27. package/assets/js/views/health.js +176 -0
  28. package/assets/js/views/inbox.js +103 -0
  29. package/assets/js/views/inspect.js +751 -0
  30. package/assets/js/views/jobs.js +110 -0
  31. package/assets/js/views/library.js +605 -0
  32. package/assets/js/views/memory-fabric.js +2026 -0
  33. package/assets/js/views/memory.js +228 -0
  34. package/assets/js/views/onboarding.js +253 -0
  35. package/assets/js/views/overview.js +196 -0
  36. package/assets/js/views/panes.js +132 -0
  37. package/assets/js/views/proc.js +280 -0
  38. package/assets/js/views/review.js +164 -0
  39. package/assets/js/views/runs.js +452 -0
  40. package/assets/js/views/runtime.js +450 -0
  41. package/assets/js/views/schedulers.js +133 -0
  42. package/assets/js/views/security.js +925 -0
  43. package/assets/js/views/sessions.js +265 -0
  44. package/assets/js/views/settings.js +489 -0
  45. package/assets/js/views/spec-edit.js +1289 -0
  46. package/assets/js/views/spec.js +66 -0
  47. package/assets/js/views/thredz.js +1853 -0
  48. package/assets/js/views/token.js +49 -0
  49. package/dist/index.d.ts +15 -0
  50. package/dist/index.js +145 -0
  51. package/package.json +36 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * HM-179 — plugin panes, rendered.
3
+ *
4
+ * A pane is third-party markup, so it is never injected into this document.
5
+ * It goes into an `<iframe>` whose `sandbox` grants `allow-scripts` and
6
+ * NOTHING else: without `allow-same-origin` the frame has an opaque origin,
7
+ * so its script cannot reach this page's DOM, its sessionStorage (where the
8
+ * bearer token lives) or its history. The Content-Security-Policy the server
9
+ * computed from the plugin's own `net` allow-list rides along inside the
10
+ * document as a `<meta http-equiv>` line — fail-closed, so a plugin with no
11
+ * declared network permission gets `connect-src 'none'`.
12
+ *
13
+ * The frame is built with `srcdoc`, which is the ONE place in this console
14
+ * where a markup string reaches the DOM — and it is the one place where that
15
+ * is the safe option rather than the dangerous one: the string never becomes
16
+ * part of this document, only of a sandboxed one. Everything else on this
17
+ * screen is built with createElement like the rest of the app.
18
+ */
19
+
20
+ import { api } from "../api.js";
21
+ import { clear, dot, el, emptyState, skeleton } from "../dom.js";
22
+
23
+ /**
24
+ * Assemble the sandboxed document: the plugin's own markup with the policy
25
+ * prepended. Pure, so the containment can be unit-tested — the assertions
26
+ * that matter are that the CSP is present and that the sandbox list never
27
+ * grows `allow-same-origin`.
28
+ */
29
+ export function paneSrcdoc(doc) {
30
+ const csp = String(doc?.csp ?? "default-src 'none'");
31
+ const body = String(doc?.doc ?? "");
32
+ const meta = `<meta http-equiv="Content-Security-Policy" content="${csp.replace(/"/g, "&quot;")}">`;
33
+ return `<!doctype html><html><head><meta charset="utf-8">${meta}</head><body>${body}</body></html>`;
34
+ }
35
+
36
+ /** The sandbox token list for a pane frame. Never `allow-same-origin`. */
37
+ export function paneSandbox(doc) {
38
+ const declared = String(doc?.sandbox ?? "allow-scripts");
39
+ const tokens = declared.split(/\s+/).filter((t) => t !== "" && t !== "allow-same-origin");
40
+ return tokens.length > 0 ? tokens.join(" ") : "allow-scripts";
41
+ }
42
+
43
+ export async function renderPanes(root, ctx) {
44
+ clear(root).appendChild(skeleton(4));
45
+ let payload = null;
46
+ try {
47
+ payload = await api.panes(ctx.id);
48
+ } catch (err) {
49
+ clear(root).appendChild(
50
+ el("div", { class: "card error-card" }, [
51
+ el("h2", { text: "Panes unavailable" }),
52
+ el("p", { class: "muted", text: err instanceof Error ? err.message : String(err) }),
53
+ ]),
54
+ );
55
+ return;
56
+ }
57
+ clear(root);
58
+ const panes = Array.isArray(payload?.panes) ? payload.panes : [];
59
+ const observers = Array.isArray(payload?.traceObservers) ? payload.traceObservers : [];
60
+
61
+ root.appendChild(
62
+ el("section", { class: "card" }, [
63
+ el("h3", { class: "card-title", text: "Trace observers" }),
64
+ observers.length === 0
65
+ ? emptyState(
66
+ "No plugin observes this harness's trace events",
67
+ "crewhaus plugins install <name>",
68
+ )
69
+ : el(
70
+ "div",
71
+ { class: "safety-strip" },
72
+ observers.map((name) => el("span", { class: "chip", text: String(name) })),
73
+ ),
74
+ el("p", {
75
+ class: "muted small",
76
+ text: "A plugin observes a harness only when its filesystem read permission covers that harness's directory — the same fail-closed evaluator the loader uses.",
77
+ }),
78
+ ]),
79
+ );
80
+
81
+ if (panes.length === 0) {
82
+ root.appendChild(
83
+ emptyState("No plugin pane applies to this harness", "crewhaus plugins install <name>"),
84
+ );
85
+ }
86
+ for (const pane of panes) {
87
+ root.appendChild(await paneCard(pane));
88
+ }
89
+ }
90
+
91
+ async function paneCard(pane) {
92
+ const card = el("section", { class: "card" }, [
93
+ el("div", { class: "rollup" }, [
94
+ el("h3", { class: "card-title", text: String(pane?.title ?? pane?.id ?? "pane") }),
95
+ el("span", { class: "chip", text: String(pane?.plugin ?? "") }),
96
+ ]),
97
+ ]);
98
+ let doc = null;
99
+ try {
100
+ doc = await api.pluginPane(String(pane?.plugin ?? ""), String(pane?.id ?? ""));
101
+ } catch (err) {
102
+ card.appendChild(
103
+ el("p", { class: "muted", text: err instanceof Error ? err.message : String(err) }),
104
+ );
105
+ return card;
106
+ }
107
+ if (doc === null) {
108
+ card.appendChild(emptyState("This pane's document is missing", "crewhaus plugins list"));
109
+ return card;
110
+ }
111
+ const frame = el("iframe", {
112
+ class: "pane-frame",
113
+ title: String(pane?.title ?? "plugin pane"),
114
+ sandbox: paneSandbox(doc),
115
+ referrerpolicy: "no-referrer",
116
+ loading: "lazy",
117
+ });
118
+ // `srcdoc` — see the module docblock: the string becomes the SANDBOXED
119
+ // document, never part of this one.
120
+ frame.setAttribute("srcdoc", paneSrcdoc(doc));
121
+ card.appendChild(frame);
122
+ card.appendChild(
123
+ el("div", { class: "muted small" }, [
124
+ dot("ok", "sandboxed (opaque origin)"),
125
+ el("code", { class: "mono", text: String(doc.csp ?? "") }),
126
+ ]),
127
+ );
128
+ if (doc.truncated === true) {
129
+ card.appendChild(dot("warn", "the pane document was capped — it is shown truncated"));
130
+ }
131
+ return card;
132
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * The process card and the Start / Stop / Restart / Drain action bar — the
3
+ * console's first real verbs.
4
+ *
5
+ * The whole screen is built around one idea: a refusal is information. A
6
+ * start that preflight blocks opens a modal listing every finding with its
7
+ * remediation (and offers "Start anyway" only when every finding is
8
+ * acknowledgeable); a spawn plan that cannot be built renders its remedy as
9
+ * a BUTTON; a bundle says whether its freshness verdict was hash-exact or a
10
+ * file-time approximation. Nothing here is a bare error toast.
11
+ */
12
+
13
+ import { api } from "../api.js";
14
+ import { clear, collapsible, dot, el, jsonPre, toast } from "../dom.js";
15
+ import { hrefHarness } from "../router.js";
16
+ import { procRow, procWriteOutcome, refusalModel } from "../supervision.js";
17
+ import { actionTwin, cliTwin, gatedBtn, refusalModal, runAction } from "./control.js";
18
+
19
+ /**
20
+ * Report what a Stop / Drain actually did, then reload.
21
+ *
22
+ * A 200 is not the same as a stop. The supervisor answers `not-adopted` when
23
+ * it held no pid while a live runfile existed — a daemon IS running, this
24
+ * manager just never adopted it, and nothing was signalled. Reloading
25
+ * silently there would paint the row from a snapshot that agrees the daemon
26
+ * is "stopped", which is precisely the lie this reports instead.
27
+ */
28
+ function reportProcWrite(res, label, reload) {
29
+ const outcome = procWriteOutcome(res, label);
30
+ if (outcome.message !== null) toast(outcome.message, outcome.tone);
31
+ reload();
32
+ }
33
+
34
+ /**
35
+ * Start (or restart) a harness, routing the typed 409 to the refusal modal.
36
+ * `opts` carries `{ force, acknowledge }` on the second pass.
37
+ */
38
+ export async function startProc(id, dir, opts, restart, reload) {
39
+ let res;
40
+ try {
41
+ res = restart ? await api.procRestart(id, opts) : await api.procStart(id, opts);
42
+ } catch (err) {
43
+ toast(`Start failed: ${err instanceof Error ? err.message : String(err)}`);
44
+ return;
45
+ }
46
+ if (res.ok) {
47
+ reload();
48
+ return;
49
+ }
50
+ if (res.status !== 409) {
51
+ toast(`Start failed: ${res.body?.message ?? `HTTP ${res.status}`}`);
52
+ return;
53
+ }
54
+ const model = refusalModel(res.body);
55
+ refusalModal(model, {
56
+ dir,
57
+ onForce: (acknowledge) => {
58
+ // `force` waves every FORCEABLE finding through; the id list keeps the
59
+ // audit trail honest about which ones an operator accepted.
60
+ startProc(id, dir, { force: true, acknowledge }, restart, reload);
61
+ },
62
+ onRemedy: async (kind) => {
63
+ try {
64
+ await api.submitJob(id, kind);
65
+ toast(`Queued ${kind} — watch it in the job queue`, "info");
66
+ reload();
67
+ } catch (err) {
68
+ toast(`Could not queue ${kind}: ${err instanceof Error ? err.message : String(err)}`);
69
+ }
70
+ },
71
+ });
72
+ }
73
+
74
+ /** Start / Stop / Restart / Drain, each disabled with its reason. */
75
+ export function procActionBar(id, dir, row, reload) {
76
+ const bar = el("div", { class: "proc-actions" });
77
+ const twinHost = el("div", { class: "proc-twin" });
78
+ const showTwin = (action) => {
79
+ clear(twinHost).appendChild(actionTwin(action, { dir }) ?? el("span"));
80
+ };
81
+
82
+ const startBtn = gatedBtn(
83
+ "Start",
84
+ row.actions.start,
85
+ () => startProc(id, dir, {}, false, reload),
86
+ "btn btn-primary",
87
+ );
88
+ const stopBtn = gatedBtn("Stop", row.actions.stop, () => {
89
+ runAction(
90
+ stopBtn,
91
+ "Stop",
92
+ () => api.procStop(id),
93
+ (res) => reportProcWrite(res, "Stop", reload),
94
+ );
95
+ });
96
+ const restartBtn = gatedBtn("Restart", row.actions.restart, () =>
97
+ startProc(id, dir, {}, true, reload),
98
+ );
99
+ const drainBtn = gatedBtn("Drain", row.actions.drain, () => {
100
+ // A drain has three endings — drained, drained by SIGTERM because no
101
+ // control plane answered, and nothing-was-signalled — and only the first
102
+ // deserves silence.
103
+ runAction(
104
+ drainBtn,
105
+ "Drain",
106
+ () => api.procDrain(id),
107
+ (res) => reportProcWrite(res, "Drain", reload),
108
+ );
109
+ });
110
+
111
+ for (const [btn, action] of [
112
+ [startBtn, "start"],
113
+ [stopBtn, "stop"],
114
+ [restartBtn, "restart"],
115
+ [drainBtn, "drain"],
116
+ ]) {
117
+ btn.addEventListener("mouseenter", () => showTwin(action));
118
+ btn.addEventListener("focus", () => showTwin(action));
119
+ bar.appendChild(btn);
120
+ }
121
+ showTwin(row.state === "running" ? "stop" : "start");
122
+
123
+ // A plan that cannot be built has an obvious next step — offer it.
124
+ const planError = row.launch.error;
125
+ if (planError !== null && planError.action !== null) {
126
+ const remedy = el("button", {
127
+ class: "btn",
128
+ type: "button",
129
+ text: planError.action.label,
130
+ title: planError.action.hint,
131
+ });
132
+ if (planError.action.jobKind === null) {
133
+ remedy.disabled = true;
134
+ } else {
135
+ remedy.addEventListener("click", () => {
136
+ runAction(
137
+ remedy,
138
+ planError.action.label,
139
+ () => api.submitJob(id, planError.action.jobKind),
140
+ () => {
141
+ toast(`Queued ${planError.action.jobKind}`, "info");
142
+ reload();
143
+ },
144
+ );
145
+ });
146
+ }
147
+ bar.appendChild(remedy);
148
+ }
149
+
150
+ const reasons = el("div", { class: "proc-reasons" });
151
+ for (const [label, gate] of Object.entries(row.actions)) {
152
+ if (gate.enabled || !gate.reason) continue;
153
+ reasons.appendChild(el("div", { class: "muted gated-why", text: `${label}: ${gate.reason}` }));
154
+ }
155
+ return el("div", { class: "proc-bar" }, [bar, twinHost, reasons]);
156
+ }
157
+
158
+ /** The full process card: state, identity, restarts, last exit, launch plan. */
159
+ export function procCard(payload, ctx, reload) {
160
+ const nowMs = Date.now();
161
+ const row = procRow(payload, nowMs);
162
+ const dir = ctx.dir ?? "";
163
+ const kv = (k, v) =>
164
+ el("div", { class: "kv" }, [
165
+ el("span", { class: "kv-k", text: k }),
166
+ typeof v === "string"
167
+ ? el("span", { class: "kv-v", text: v })
168
+ : el("span", { class: "kv-v" }, [v]),
169
+ ]);
170
+
171
+ const facts = el("div", { class: "proc-facts" }, [
172
+ kv("state", el("span", null, [dot(row.pill.dot, row.pill.label)])),
173
+ row.pill.note !== "" ? kv("", el("span", { class: "muted", text: row.pill.note })) : null,
174
+ kv("pid", row.pid === null ? "—" : String(row.pid)),
175
+ kv("uptime", row.uptime),
176
+ kv("run", row.runId === null ? "—" : row.runId),
177
+ kv("run class", row.runClass || "—"),
178
+ kv(
179
+ "restarts",
180
+ el("span", null, [
181
+ el("span", { text: String(row.restartsInWindow) }),
182
+ row.restart !== null
183
+ ? el("span", { class: "chip chip-warn", text: row.restart.label })
184
+ : null,
185
+ ]),
186
+ ),
187
+ kv(
188
+ "control",
189
+ el("span", null, [
190
+ dot(row.control.dot, row.control.label),
191
+ row.control.reason !== null
192
+ ? el("div", { class: "muted reason", text: row.control.reason })
193
+ : null,
194
+ ]),
195
+ ),
196
+ kv(
197
+ "bundle",
198
+ el("span", null, [
199
+ dot(row.bundle.dot, row.bundle.label),
200
+ el("div", { class: "muted reason", text: row.bundle.precision }),
201
+ row.bundle.compiledWith !== null
202
+ ? el("div", { class: "muted reason", text: `compiled with ${row.bundle.compiledWith}` })
203
+ : null,
204
+ ]),
205
+ ),
206
+ ]);
207
+
208
+ const children = [facts, procActionBar(ctx.id, dir, row, reload)];
209
+
210
+ if (row.lastExit !== null) {
211
+ children.push(
212
+ el("div", { class: "exit-banner" }, [
213
+ dot(row.lastExit.dot, row.lastExit.title),
214
+ row.lastExit.classLabel !== null
215
+ ? el("span", { class: "chip chip-warn", text: row.lastExit.classLabel })
216
+ : null,
217
+ row.lastExit.exitCode !== null
218
+ ? el("span", { class: "chip", text: `exit ${row.lastExit.exitCode}` })
219
+ : null,
220
+ row.lastExit.fromLedger ? el("span", { class: "chip", text: "from the run ledger" }) : null,
221
+ row.lastExit.remediation !== null
222
+ ? el("div", { class: "muted reason", text: row.lastExit.remediation })
223
+ : null,
224
+ ]),
225
+ );
226
+ }
227
+ if (row.forensics !== null) {
228
+ const tail = Array.isArray(row.forensics.tail) ? row.forensics.tail : [];
229
+ children.push(
230
+ collapsible(
231
+ [el("span", { class: "muted", text: "forensics from the last failure" })],
232
+ [
233
+ row.forensics.lastRunFailed
234
+ ? jsonPre(row.forensics.lastRunFailed)
235
+ : el("pre", { class: "prose-pre", text: tail.join("\n") || "no captured tail" }),
236
+ ],
237
+ ),
238
+ );
239
+ }
240
+
241
+ // What Start would actually spawn — the plan preview, never the env (which
242
+ // carries the control token).
243
+ if (row.launch.cliTwin !== null) {
244
+ children.push(
245
+ collapsible(
246
+ [
247
+ el("span", { class: "muted", text: `launch: ${row.launch.mode ?? "?"}` }),
248
+ row.launch.canResume ? el("span", { class: "chip", text: "resumable" }) : null,
249
+ row.launch.detached ? el("span", { class: "chip", text: "detached" }) : null,
250
+ ],
251
+ [
252
+ cliTwin(row.launch.cliTwin),
253
+ row.launch.envFiles.length > 0
254
+ ? el("div", {
255
+ class: "muted reason",
256
+ text: `env chain: ${row.launch.envFiles.join(", ")}`,
257
+ })
258
+ : null,
259
+ ],
260
+ ),
261
+ );
262
+ } else if (row.launch.error !== null) {
263
+ children.push(el("p", { class: "muted", text: row.launch.error.message }));
264
+ }
265
+
266
+ children.push(
267
+ el("div", { class: "proc-links" }, [
268
+ el("a", { href: hrefHarness(ctx.id, "runs"), text: "run history →" }),
269
+ el("a", { href: hrefHarness(ctx.id, "schedulers"), text: "schedulers →" }),
270
+ ]),
271
+ );
272
+
273
+ return el("section", { class: "card ov-card ov-wide" }, [
274
+ el("h3", { class: "card-title" }, [
275
+ el("span", { text: "Process" }),
276
+ el("span", { class: "muted card-sub", text: row.target }),
277
+ ]),
278
+ ...children,
279
+ ]);
280
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The review queue — `.crewhaus/review/queue.jsonl` folded across the fleet,
3
+ * with the same triage ergonomics as the approvals inbox (j/k, one-key
4
+ * verdicts, `.` row menu, `?` bindings).
5
+ *
6
+ * THE VERDICTS DIFFER BY ITEM. An item that points at a session TURN can be
7
+ * ADJUDICATED — up/down take the same path `crewhaus rate --adjudicate`
8
+ * takes, settling the disagreement at its source so the next distill does
9
+ * not re-open it. An item with no turn (an eval abstention, a quarantine
10
+ * pointer) can only record pass/fail on the item itself, so the thumbs are
11
+ * not offered there: the server would 409, and a button that always fails is
12
+ * worse than one that is not there.
13
+ */
14
+
15
+ import { api } from "../api.js";
16
+ import { clear, dot, el, emptyState, jsonPre, skeleton, toast } from "../dom.js";
17
+ import { hrefHarness } from "../router.js";
18
+ import { REVIEW_KEYS, reviewRow } from "../supervision.js";
19
+ import { actionTwin } from "./control.js";
20
+ import { mountTriage } from "./inbox.js";
21
+
22
+ export async function renderReview(root) {
23
+ clear(root).appendChild(skeleton(6));
24
+ await draw(root, false);
25
+ }
26
+
27
+ async function draw(host, showResolved) {
28
+ let payload;
29
+ try {
30
+ payload = await api.review(showResolved);
31
+ } catch (err) {
32
+ clear(host).appendChild(
33
+ el("div", { class: "card error-card" }, [
34
+ el("p", { class: "muted", text: err instanceof Error ? err.message : String(err) }),
35
+ ]),
36
+ );
37
+ return;
38
+ }
39
+ clear(host);
40
+ const nowMs = Date.now();
41
+ const rows = (payload && Array.isArray(payload.items) ? payload.items : []).map((r) =>
42
+ reviewRow(r, nowMs),
43
+ );
44
+ const open = payload && typeof payload.open === "number" ? payload.open : 0;
45
+ const reload = () => draw(host, showResolved);
46
+
47
+ host.appendChild(
48
+ el("div", { class: "toolbar" }, [
49
+ el("span", { class: "rollup", text: `${open} open · ${rows.length} shown` }),
50
+ el("span", { class: "spacer" }),
51
+ el("button", {
52
+ class: `btn btn-ghost${showResolved ? " active" : ""}`,
53
+ type: "button",
54
+ text: showResolved ? "Showing resolved too" : "Show resolved",
55
+ onClick: () => draw(host, !showResolved),
56
+ }),
57
+ ]),
58
+ );
59
+ if (rows.length === 0) {
60
+ host.appendChild(
61
+ emptyState(
62
+ showResolved ? "No review items on this fleet" : "Nothing waiting for review",
63
+ "crewhaus review list",
64
+ ),
65
+ );
66
+ return;
67
+ }
68
+ mountTriage(host, rows, {
69
+ keys: REVIEW_KEYS,
70
+ reload,
71
+ render: reviewCard,
72
+ perform: adjudicate,
73
+ });
74
+ }
75
+
76
+ async function adjudicate(row, verdict, reload) {
77
+ if (!row.verdicts.includes(verdict)) {
78
+ toast(`${verdict} does not apply here — ${row.verdictNote}`, "info");
79
+ return;
80
+ }
81
+ let result;
82
+ try {
83
+ result = await api.adjudicateReview(row.harnessId, row.id, verdict);
84
+ } catch (err) {
85
+ toast(`Verdict failed: ${err instanceof Error ? err.message : String(err)}`);
86
+ return;
87
+ }
88
+ toast(
89
+ result && result.adjudicated === true
90
+ ? `Adjudicated the rated turn (${verdict})`
91
+ : `Recorded ${verdict} on the item`,
92
+ "info",
93
+ );
94
+ reload();
95
+ }
96
+
97
+ function reviewCard(row, index, state, redraw, reload) {
98
+ const selected = state.index === index;
99
+ const verdictBtn = (verdict) => {
100
+ const btn = el("button", {
101
+ class: `btn${verdict === "up" || verdict === "pass" ? " btn-primary" : ""}`,
102
+ type: "button",
103
+ text: verdict,
104
+ title: row.open ? `record ${verdict}` : `already ${row.status}`,
105
+ });
106
+ if (!row.open) btn.disabled = true;
107
+ else btn.addEventListener("click", () => adjudicate(row, verdict, reload));
108
+ return btn;
109
+ };
110
+ return el(
111
+ "section",
112
+ {
113
+ class: `card inbox-card${selected ? " selected" : ""}`,
114
+ "aria-current": selected ? "true" : null,
115
+ onClick: () => {
116
+ if (!selected) redraw({ ...state, index });
117
+ },
118
+ },
119
+ [
120
+ el("div", { class: "inbox-head" }, [
121
+ dot(row.dot, row.status),
122
+ el("span", { class: "chip", text: row.kind }),
123
+ el("a", { class: "name-link", href: hrefHarness(row.harnessId), text: row.specName }),
124
+ row.adjudicable
125
+ ? el("span", { class: "chip chip-group", text: "adjudicable" })
126
+ : el("span", { class: "chip", text: "item verdict only" }),
127
+ el("span", { class: "muted", text: row.age }),
128
+ ]),
129
+ row.context !== "" ? el("pre", { class: "prose-pre", text: row.context }) : null,
130
+ row.sessionId !== null
131
+ ? el("div", { class: "muted reason" }, [
132
+ el("span", { text: "turn " }),
133
+ el("span", { class: "mono", text: String(row.turn ?? "?") }),
134
+ el("span", { text: " of " }),
135
+ el("a", {
136
+ class: "mono",
137
+ href: hrefHarness(row.harnessId, "sessions", row.sessionId),
138
+ text: row.sessionId,
139
+ }),
140
+ ])
141
+ : null,
142
+ el("div", { class: "muted reason", text: row.verdictNote }),
143
+ el("div", { class: "inbox-actions" }, row.verdicts.map(verdictBtn)),
144
+ el("div", { class: "inbox-twins" }, [
145
+ row.adjudicable
146
+ ? actionTwin("adjudicate", {
147
+ sessionId: row.sessionId,
148
+ turn: row.turn ?? 0,
149
+ verdict: "up",
150
+ })
151
+ : null,
152
+ actionTwin("review", { id: row.id }),
153
+ ]),
154
+ Object.keys(row.sourceRef).length > 0
155
+ ? el("details", { class: "fold" }, [
156
+ el("summary", { class: "fold-summary" }, [
157
+ el("span", { class: "muted", text: "source reference" }),
158
+ ]),
159
+ el("div", { class: "fold-body" }, [jsonPre(row.sourceRef)]),
160
+ ])
161
+ : null,
162
+ ],
163
+ );
164
+ }