@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,555 @@
1
+ /**
2
+ * Feedback — the growth loops: the ratings browser with the distill
3
+ * watermark, the few-shot pool, FAQ distill, lessons, the advice feed, and
4
+ * channel reaction state. Plus the fleet-wide rollup screen, which answers
5
+ * "which harnesses have enough feedback to distill".
6
+ *
7
+ * The review QUEUE and its adjudication are the M2 screen (`views/review.js`)
8
+ * and stay there; this is what the ratings turn INTO.
9
+ *
10
+ * Two things this screen refuses to soften:
11
+ * - the watermark travels WITH the unprocessed count. "12 new ratings"
12
+ * without the timestamp it counts from is a guess, and the screen would
13
+ * be inviting a distill run on a number it made up.
14
+ * - the advice feed is ADVISORY. Rendering a proposed SpecPatch is not
15
+ * applying it — apply is a separate gesture that goes back through the
16
+ * spec write path, and a human-owned path routes to `crewhaus propose`.
17
+ *
18
+ * Every node is built with el() — markup strings are banned app-wide, so a
19
+ * user's own rating comment can never inject into this page.
20
+ */
21
+
22
+ import { api } from "../api.js";
23
+ import { clear, collapsible, dot, el, emptyState, mdBlocks, skeleton, toast } from "../dom.js";
24
+ import { fmtCount, fmtPct, fmtRelativeTime } from "../util.js";
25
+
26
+ /** The per-harness Feedback tab. */
27
+ export async function renderFeedback(root, ctx) {
28
+ clear(root).appendChild(skeleton(4));
29
+ const [ratings, fewshot, faq, lessons, advice, reactions] = await Promise.all(
30
+ [api.feedback, api.fewshot, api.faq, api.lessons, api.advice, api.reactions].map((fn) =>
31
+ fn({ id: ctx.id }).catch((err) => ({ ok: false, status: 0, body: { error: String(err) } })),
32
+ ),
33
+ );
34
+ clear(root);
35
+ const reload = () => renderFeedback(root, ctx);
36
+ root.appendChild(ratingsCard(ctx, ratings, reload));
37
+ root.appendChild(fewshotCard(ctx, fewshot, reload));
38
+ root.appendChild(faqCard(ctx, faq, reload));
39
+ root.appendChild(lessonsCard(ctx, lessons, reload));
40
+ root.appendChild(adviceCard(ctx, advice, reload));
41
+ root.appendChild(reactionsCard(reactions));
42
+ }
43
+
44
+ /** The fleet-wide rollup (`#/feedback`). */
45
+ export async function renderFeedbackBoard(root) {
46
+ clear(root).appendChild(skeleton(4));
47
+ const res = await api
48
+ .feedbackFleet({})
49
+ .catch((err) => ({ ok: false, status: 0, body: { error: String(err) } }));
50
+ clear(root);
51
+ root.appendChild(
52
+ panel("Fleet feedback", res, (card, data) => {
53
+ const ready = new Set(data.distillReady ?? []);
54
+ card.appendChild(
55
+ table(
56
+ ["Harness", "Ratings", "Balance", "Unprocessed", "Watermark", "Last rating"],
57
+ (data.harnesses ?? []).map((row) =>
58
+ el("tr", null, [
59
+ el("td", { class: "mono", text: String(row.specName) }),
60
+ el("td", { class: "num", text: fmtCount(row.total) }),
61
+ el("td", null, [
62
+ row.total > 0
63
+ ? dot(
64
+ row.up >= row.down ? "ok" : "warn",
65
+ `${fmtCount(row.up)}↑ ${fmtCount(row.down)}↓`,
66
+ )
67
+ : dot("unknown", "none"),
68
+ ]),
69
+ el("td", null, [
70
+ ready.has(row.id)
71
+ ? dot("warn", `${fmtCount(row.unprocessed)} — distill-ready`)
72
+ : el("span", { class: "num", text: fmtCount(row.unprocessed) }),
73
+ ]),
74
+ el("td", { text: fmtRelativeTime(row.watermarkTs, Date.now()) }),
75
+ el("td", { text: fmtRelativeTime(row.lastRatingTs, Date.now()) }),
76
+ ]),
77
+ ),
78
+ ),
79
+ );
80
+ card.appendChild(el("p", { class: "muted", text: String(data.scope ?? "") }));
81
+ }),
82
+ );
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // shared bits
87
+ // ---------------------------------------------------------------------------
88
+
89
+ function payload(res) {
90
+ return res && typeof res === "object" && res.body && typeof res.body === "object" ? res.body : {};
91
+ }
92
+
93
+ function panel(title, res, draw) {
94
+ const data = payload(res);
95
+ const card = el("div", { class: "card" }, [
96
+ el("h3", { class: "card-title" }, [el("span", { text: title })]),
97
+ ]);
98
+ if (res && res.ok === false && data.present === undefined) {
99
+ card.appendChild(el("p", { class: "muted", text: `unavailable (HTTP ${String(res.status)})` }));
100
+ return card;
101
+ }
102
+ if (data.present === false) {
103
+ card.appendChild(emptyState(String(data.note ?? "Nothing yet"), data.verb ?? null));
104
+ return card;
105
+ }
106
+ if (typeof data.note === "string" && data.note !== "") {
107
+ card.appendChild(el("p", { class: "muted", text: data.note }));
108
+ }
109
+ draw(card, data);
110
+ return card;
111
+ }
112
+
113
+ function table(headers, rows) {
114
+ return el("div", { class: "table-scroll" }, [
115
+ el("table", { class: "fleet" }, [
116
+ el(
117
+ "thead",
118
+ null,
119
+ el(
120
+ "tr",
121
+ null,
122
+ headers.map((h) => el("th", { text: h })),
123
+ ),
124
+ ),
125
+ el("tbody", null, rows),
126
+ ]),
127
+ ]);
128
+ }
129
+
130
+ function stat(label, value) {
131
+ return el("div", { class: "mini" }, [
132
+ el("div", { class: "mini-num", text: value }),
133
+ el("div", { class: "mini-label", text: label }),
134
+ ]);
135
+ }
136
+
137
+ async function call(fn, params, body) {
138
+ return await fn(params, body).catch((err) => ({
139
+ ok: false,
140
+ status: 0,
141
+ body: { error: String(err) },
142
+ }));
143
+ }
144
+
145
+ function refusalText(res) {
146
+ const raw = typeof payload(res).error === "string" ? payload(res).error : "";
147
+ try {
148
+ const parsed = JSON.parse(raw);
149
+ if (parsed && typeof parsed === "object" && typeof parsed.error === "string") {
150
+ return `${parsed.error}${parsed.route ? ` — use ${parsed.route}` : ""}`;
151
+ }
152
+ } catch {
153
+ // not a structured refusal — fall through to the raw message
154
+ }
155
+ return raw || `HTTP ${String(res.status)}`;
156
+ }
157
+
158
+ async function fire(fn, params, body, okMessage, reload) {
159
+ const res = await call(fn, params, body);
160
+ if (!res.ok) {
161
+ toast(refusalText(res), "error");
162
+ return res;
163
+ }
164
+ toast(okMessage, "info");
165
+ if (reload) reload();
166
+ return res;
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // ratings + distill
171
+ // ---------------------------------------------------------------------------
172
+
173
+ function ratingsCard(ctx, res, reload) {
174
+ const card = panel("Ratings", res, (node, data) => {
175
+ const watermark = data.watermark ?? {};
176
+ const balance = data.balance ?? {};
177
+ node.appendChild(
178
+ el("div", { class: "mini-row" }, [
179
+ stat("ratings", fmtCount(balance.total)),
180
+ stat(
181
+ "balance",
182
+ balance.total ? `${fmtCount(balance.up)}↑ ${fmtCount(balance.down)}↓` : "—",
183
+ ),
184
+ stat("unprocessed", fmtCount(watermark.unprocessed)),
185
+ ]),
186
+ );
187
+ // The watermark IS the truth about what distill has consumed. Without it
188
+ // the unprocessed count above would be an assumption, so say which.
189
+ node.appendChild(
190
+ el("p", null, [
191
+ watermark.present
192
+ ? dot(
193
+ "ok",
194
+ `watermark ${fmtRelativeTime(watermark.lastProcessedTs, Date.now())} · ${fmtCount(
195
+ watermark.processedCount,
196
+ )} folded`,
197
+ )
198
+ : dot("warn", "no distill watermark — every rating counts as unprocessed"),
199
+ ]),
200
+ );
201
+ node.appendChild(
202
+ table(
203
+ ["When", "Session · turn", "Rating", "Comment", "Source", ""],
204
+ (data.items ?? []).map((item) =>
205
+ el("tr", null, [
206
+ el("td", { text: fmtRelativeTime(item.ts, Date.now()) }),
207
+ el("td", {
208
+ class: "mono",
209
+ text: `${String(item.sessionId)} · ${String(item.turnNumber)}`,
210
+ }),
211
+ el("td", null, [
212
+ typeof item.score === "number"
213
+ ? dot(item.score > 0.5 ? "ok" : "bad", fmtPct(item.score))
214
+ : dot("unknown", "comment"),
215
+ ]),
216
+ el("td", { class: "muted", text: String(item.comment || item.correction || "") }),
217
+ el("td", { class: "muted", text: String(item.source) }),
218
+ el("td", { class: "cell-caps" }, [
219
+ item.adjudication === true
220
+ ? el("span", { class: "chip chip-group", text: "adjudicated" })
221
+ : null,
222
+ item.unprocessed === true ? el("span", { class: "chip", text: "new" }) : null,
223
+ ]),
224
+ ]),
225
+ ),
226
+ ),
227
+ );
228
+ if (Array.isArray(data.sinks) && data.sinks.length > 0) {
229
+ node.appendChild(el("p", { class: "muted mono", text: data.sinks.join(" · ") }));
230
+ }
231
+ });
232
+
233
+ const judge = el("input", { type: "checkbox", name: "judge" });
234
+ const register = el("input", { type: "checkbox", name: "register" });
235
+ const go = el("button", { class: "btn btn-primary", type: "button", text: "Distill now" });
236
+ go.addEventListener("click", async () => {
237
+ go.disabled = true;
238
+ await fire(
239
+ api.distillRun,
240
+ { id: ctx.id },
241
+ { judge: judge.checked, register: register.checked },
242
+ register.checked ? "Distill queued — result will be registered" : "Distill queued",
243
+ reload,
244
+ );
245
+ go.disabled = false;
246
+ });
247
+ card.appendChild(
248
+ el("div", { class: "editor-actions" }, [
249
+ el("label", { class: "check" }, [judge, el("span", { text: " use the judge" })]),
250
+ el("label", { class: "check" }, [
251
+ register,
252
+ el("span", { text: " promote into the dataset registry (--register)" }),
253
+ ]),
254
+ go,
255
+ ]),
256
+ );
257
+ card.appendChild(
258
+ el("p", {
259
+ class: "muted",
260
+ text: "This runs the distill verb. It does not enable the spec's autoDistill, which the shipped runtime honours only from the CLI.",
261
+ }),
262
+ );
263
+ return card;
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // the growers
268
+ // ---------------------------------------------------------------------------
269
+
270
+ function fewshotCard(ctx, res, reload) {
271
+ const card = panel("Few-shot pool", res, (node, data) => {
272
+ node.appendChild(
273
+ table(
274
+ ["Input", "Golden answer", "Score", "Source", "Pool"],
275
+ (data.entries ?? []).map((entry) =>
276
+ el("tr", null, [
277
+ el("td", { text: String(entry.input) }),
278
+ el("td", { class: "muted", text: String(entry.output) }),
279
+ el("td", {
280
+ class: "num",
281
+ text: typeof entry.score === "number" ? fmtPct(entry.score) : "—",
282
+ }),
283
+ el("td", { class: "muted", text: String(entry.source ?? "") }),
284
+ el("td", { class: "cell-caps" }, [
285
+ entry.inUse === true
286
+ ? el("span", {
287
+ class: "chip chip-group",
288
+ title: "injected by this spec",
289
+ text: "in use",
290
+ })
291
+ : el("span", { class: "chip", text: String(entry.file) }),
292
+ ]),
293
+ ]),
294
+ ),
295
+ ),
296
+ );
297
+ });
298
+ const harvest = el("button", { class: "btn btn-ghost", type: "button", text: "Preview harvest" });
299
+ const holder = el("div");
300
+ harvest.addEventListener("click", async () => {
301
+ harvest.disabled = true;
302
+ const res2 = await call(api.fewshotHarvest, { id: ctx.id }, {});
303
+ harvest.disabled = false;
304
+ clear(holder);
305
+ if (!res2.ok) {
306
+ toast(refusalText(res2), "error");
307
+ return;
308
+ }
309
+ const data = payload(res2);
310
+ holder.appendChild(el("p", { class: "gated-why", text: String(data.note ?? "") }));
311
+ holder.appendChild(el("p", { class: "muted mono", text: (data.writes ?? []).join(" · ") }));
312
+ const run = el("button", { class: "btn btn-danger", type: "button", text: "Harvest now" });
313
+ run.addEventListener("click", async () => {
314
+ run.disabled = true;
315
+ await fire(api.fewshotHarvest, { id: ctx.id }, { confirm: true }, "Harvest queued", reload);
316
+ run.disabled = false;
317
+ });
318
+ holder.appendChild(el("div", { class: "editor-actions" }, [run]));
319
+ });
320
+ card.appendChild(el("div", { class: "editor-actions" }, [harvest]));
321
+ card.appendChild(holder);
322
+ return card;
323
+ }
324
+
325
+ function faqCard(ctx, res, reload) {
326
+ const card = panel("Auto-discovered FAQ skill", res, (node, data) => {
327
+ node.appendChild(el("p", { class: "muted mono", text: String(data.path ?? "") }));
328
+ // Rendered, not summarized: a generated skill the operator cannot read is
329
+ // a behaviour change nobody approved.
330
+ node.appendChild(mdBlocks(String(data.skill ?? "")));
331
+ });
332
+ const distill = el("button", { class: "btn btn-ghost", type: "button", text: "Distill FAQ" });
333
+ distill.addEventListener("click", async () => {
334
+ distill.disabled = true;
335
+ await fire(api.faqDistill, { id: ctx.id }, {}, "FAQ distill queued", reload);
336
+ distill.disabled = false;
337
+ });
338
+ card.appendChild(el("div", { class: "editor-actions" }, [distill]));
339
+ return card;
340
+ }
341
+
342
+ function lessonsCard(ctx, res, reload) {
343
+ const card = panel("Lessons + preferences", res, (node, data) => {
344
+ if (data.lessons) {
345
+ node.appendChild(
346
+ collapsible(
347
+ [
348
+ el("span", { text: "LESSONS.md" }),
349
+ el("span", {
350
+ class: "muted",
351
+ text: ` updated ${fmtRelativeTime(data.lessonsUpdatedAt, Date.now())}`,
352
+ }),
353
+ ],
354
+ [mdBlocks(String(data.lessons))],
355
+ true,
356
+ ),
357
+ );
358
+ }
359
+ for (const pref of data.preferences ?? []) {
360
+ node.appendChild(
361
+ collapsible(
362
+ [el("span", { class: "mono", text: `preferences/${String(pref.user)}` })],
363
+ [mdBlocks(String(pref.body))],
364
+ false,
365
+ ),
366
+ );
367
+ }
368
+ });
369
+ const holder = el("div");
370
+ const update = el("button", { class: "btn btn-ghost", type: "button", text: "Preview update" });
371
+ update.addEventListener("click", async () => {
372
+ update.disabled = true;
373
+ const res2 = await call(api.lessonsUpdate, { id: ctx.id }, {});
374
+ update.disabled = false;
375
+ clear(holder);
376
+ if (!res2.ok) {
377
+ toast(refusalText(res2), "error");
378
+ return;
379
+ }
380
+ holder.appendChild(el("p", { class: "gated-why", text: String(payload(res2).note ?? "") }));
381
+ const run = el("button", { class: "btn btn-danger", type: "button", text: "Update lessons" });
382
+ run.addEventListener("click", async () => {
383
+ run.disabled = true;
384
+ await fire(
385
+ api.lessonsUpdate,
386
+ { id: ctx.id },
387
+ { confirm: true },
388
+ "Lessons update queued",
389
+ reload,
390
+ );
391
+ run.disabled = false;
392
+ });
393
+ holder.appendChild(el("div", { class: "editor-actions" }, [run]));
394
+ });
395
+ card.appendChild(el("div", { class: "editor-actions" }, [update]));
396
+ card.appendChild(holder);
397
+ return card;
398
+ }
399
+
400
+ // ---------------------------------------------------------------------------
401
+ // the advisory feed
402
+ // ---------------------------------------------------------------------------
403
+
404
+ function adviceCard(ctx, res, reload) {
405
+ const card = panel("Advice", res, (node, data) => {
406
+ node.appendChild(
407
+ el("p", {
408
+ class: "muted",
409
+ text: "Advisory only. Applying an auto-tunable proposal writes the spec through the same restriction the optimizer runs under; a human-owned path is refused and routed to crewhaus propose.",
410
+ }),
411
+ );
412
+ for (const proposal of data.proposals ?? []) {
413
+ node.appendChild(proposalRow(ctx, proposal, reload));
414
+ }
415
+ });
416
+ const mine = el("button", {
417
+ class: "btn btn-ghost",
418
+ type: "button",
419
+ text: "Mine sessions for advice",
420
+ });
421
+ mine.addEventListener("click", async () => {
422
+ mine.disabled = true;
423
+ await fire(api.adviceRun, { id: ctx.id }, {}, "Advise queued (proposals only)", reload);
424
+ mine.disabled = false;
425
+ });
426
+ card.appendChild(el("div", { class: "editor-actions" }, [mine]));
427
+ return card;
428
+ }
429
+
430
+ function proposalRow(ctx, proposal, reload) {
431
+ const auto = proposal.tier === "auto-tunable";
432
+ const holder = el("div");
433
+ const previewBtn = el("button", { class: "btn btn-ghost", type: "button", text: "Preview diff" });
434
+ previewBtn.addEventListener("click", async () => {
435
+ previewBtn.disabled = true;
436
+ const res = await call(
437
+ api.adviceApply,
438
+ { id: ctx.id, adviceId: String(proposal.adviceId) },
439
+ {},
440
+ );
441
+ previewBtn.disabled = false;
442
+ clear(holder);
443
+ if (!res.ok) {
444
+ // A human-owned refusal is a FIRST-CLASS state: show the diff it would
445
+ // have made and name the route that can carry it.
446
+ const raw = typeof payload(res).error === "string" ? payload(res).error : "";
447
+ let parsed = null;
448
+ try {
449
+ parsed = JSON.parse(raw);
450
+ } catch {
451
+ parsed = null;
452
+ }
453
+ if (parsed && typeof parsed === "object") {
454
+ holder.appendChild(el("p", { class: "gated-why", text: String(parsed.error) }));
455
+ holder.appendChild(diffList(parsed.diff ?? []));
456
+ holder.appendChild(
457
+ el("p", { class: "muted" }, [
458
+ el("span", { text: "This path is human-owned — route it through " }),
459
+ el("code", { text: String(parsed.route ?? "crewhaus propose") }),
460
+ ]),
461
+ );
462
+ return;
463
+ }
464
+ toast(refusalText(res), "error");
465
+ return;
466
+ }
467
+ const data = payload(res);
468
+ if (data.present === false) {
469
+ holder.appendChild(emptyState(String(data.note), data.verb ?? null));
470
+ return;
471
+ }
472
+ holder.appendChild(diffList(data.diff ?? []));
473
+ const apply = el("button", {
474
+ class: "btn btn-danger",
475
+ type: "button",
476
+ text: "Apply to the spec",
477
+ });
478
+ apply.addEventListener("click", async () => {
479
+ apply.disabled = true;
480
+ await fire(
481
+ api.adviceApply,
482
+ { id: ctx.id, adviceId: String(proposal.adviceId) },
483
+ { confirm: true },
484
+ "Applied to crewhaus.yaml",
485
+ reload,
486
+ );
487
+ apply.disabled = false;
488
+ });
489
+ holder.appendChild(el("div", { class: "editor-actions" }, [apply]));
490
+ });
491
+
492
+ return el("div", { class: "inbox-card" }, [
493
+ el("div", { class: "inbox-head" }, [
494
+ dot(auto ? "ok" : "warn", proposal.tier ?? "unknown"),
495
+ el("span", { class: "mono", text: ` ${(proposal.path ?? []).join(".")}` }),
496
+ el("span", { class: "chip", text: String(proposal.severity ?? "info") }),
497
+ ]),
498
+ el("p", { text: String(proposal.summary ?? "") }),
499
+ proposal.rationale ? el("p", { class: "reason", text: String(proposal.rationale) }) : null,
500
+ el("p", { class: "gated-why", text: String(proposal.tierReason ?? "") }),
501
+ el("div", { class: "inbox-actions" }, [previewBtn]),
502
+ holder,
503
+ ]);
504
+ }
505
+
506
+ function diffList(entries) {
507
+ if (!Array.isArray(entries) || entries.length === 0) {
508
+ return el("p", { class: "muted", text: "no textual change" });
509
+ }
510
+ return el(
511
+ "ul",
512
+ { class: "check-list" },
513
+ entries.map((d) =>
514
+ el("li", { class: "mono" }, [
515
+ el("span", { text: String(d.path ?? "") }),
516
+ el("span", {
517
+ class: "muted",
518
+ text: ` ${String(d.before ?? "—")} → ${String(d.after ?? "—")}`,
519
+ }),
520
+ ]),
521
+ ),
522
+ );
523
+ }
524
+
525
+ // ---------------------------------------------------------------------------
526
+ // channel reactions
527
+ // ---------------------------------------------------------------------------
528
+
529
+ /** The precondition is the point: under a `thread` sessionKey a reaction has
530
+ * no session to attach to and the loop collects NOTHING. A panel showing a
531
+ * bare zero would be indistinguishable from a quiet week. */
532
+ function reactionsCard(res) {
533
+ return panel("Channel reactions", res, (card, data) => {
534
+ card.appendChild(
535
+ el("p", null, [
536
+ data.collecting === true
537
+ ? dot("ok", `collecting (sessionKey: ${String(data.sessionKeyMode)})`)
538
+ : dot("warn", "collecting nothing"),
539
+ ]),
540
+ );
541
+ if (data.caveat) card.appendChild(el("p", { class: "gated-why", text: String(data.caveat) }));
542
+ card.appendChild(
543
+ table(
544
+ ["When", "Session · turn", "Reaction"],
545
+ (data.reactions ?? []).map((r) =>
546
+ el("tr", null, [
547
+ el("td", { text: fmtRelativeTime(r.ts, Date.now()) }),
548
+ el("td", { class: "mono", text: `${String(r.sessionId)} · ${String(r.turnNumber)}` }),
549
+ el("td", null, [dot(r.thumbs === "up" ? "ok" : "bad", String(r.thumbs ?? "—"))]),
550
+ ]),
551
+ ),
552
+ ),
553
+ );
554
+ });
555
+ }