@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,426 @@
1
+ /**
2
+ * Pure helpers shared by every Hangar view. This module is deliberately
3
+ * DOM-free and side-effect-free so the same file runs in the browser (as a
4
+ * plain ES module) and under `bun test` (imported directly by the package's
5
+ * unit tests). Anything time-dependent takes an explicit `nowMs`.
6
+ */
7
+
8
+ const MINUTE_MS = 60_000;
9
+ const HOUR_MS = 3_600_000;
10
+ const DAY_MS = 86_400_000;
11
+
12
+ /** Window for the "Recently active" smart group. */
13
+ export const RECENT_ACTIVITY_MS = 48 * HOUR_MS;
14
+
15
+ /** Default transcript retention used for the TTL countdown column. */
16
+ export const DEFAULT_SESSION_TTL_DAYS = 30;
17
+
18
+ /** Parse an ISO timestamp to epoch ms, or null when absent/invalid. */
19
+ export function parseTs(iso) {
20
+ if (typeof iso !== "string" || iso === "") return null;
21
+ const ms = Date.parse(iso);
22
+ return Number.isNaN(ms) ? null : ms;
23
+ }
24
+
25
+ /** "just now" / "5m ago" / "3h ago" / "6d ago" / "in 2h"; "—" when unknown. */
26
+ export function fmtRelativeTime(iso, nowMs) {
27
+ const ts = typeof iso === "number" ? iso : parseTs(iso);
28
+ if (ts === null) return "—";
29
+ const diff = nowMs - ts;
30
+ const abs = Math.abs(diff);
31
+ let label;
32
+ if (abs < 45_000) return "just now";
33
+ if (abs < HOUR_MS) label = `${Math.round(abs / MINUTE_MS)}m`;
34
+ else if (abs < DAY_MS) label = `${Math.round(abs / HOUR_MS)}h`;
35
+ else label = `${Math.round(abs / DAY_MS)}d`;
36
+ return diff >= 0 ? `${label} ago` : `in ${label}`;
37
+ }
38
+
39
+ /**
40
+ * USD micros → dollars. The server's money unit is integer USD micros
41
+ * everywhere (rollups, cost folds, gutter events); this is the ONE place
42
+ * the client converts. Returns null for anything non-numeric so the result
43
+ * feeds `fmtUsd` (which renders null as "—").
44
+ */
45
+ export function usdFromMicros(micros) {
46
+ if (typeof micros !== "number" || !Number.isFinite(micros)) return null;
47
+ return micros / 1_000_000;
48
+ }
49
+
50
+ /** Format a dollar figure; "—" for null/undefined/NaN. */
51
+ export function fmtUsd(value) {
52
+ if (typeof value !== "number" || Number.isNaN(value)) return "—";
53
+ if (value !== 0 && Math.abs(value) < 0.01) return `$${value.toFixed(4)}`;
54
+ if (Math.abs(value) >= 1000) return `$${Math.round(value).toLocaleString("en-US")}`;
55
+ return `$${value.toFixed(2)}`;
56
+ }
57
+
58
+ /** Format a 0..1 rate as a percentage; "—" when unknown. */
59
+ export function fmtPct(rate) {
60
+ if (typeof rate !== "number" || Number.isNaN(rate)) return "—";
61
+ // one decimal max, and 0.55*100's float dust must still read "55%"
62
+ const pct = Math.round(rate * 1000) / 10;
63
+ return `${Number.isInteger(pct) ? pct : pct.toFixed(1)}%`;
64
+ }
65
+
66
+ /** Format an integer count; "—" when unknown. */
67
+ export function fmtCount(n) {
68
+ return typeof n === "number" && Number.isFinite(n) ? String(n) : "—";
69
+ }
70
+
71
+ /** Last `segments` path components of a directory, for row subtitles. */
72
+ export function dirTail(dir, segments = 2) {
73
+ if (typeof dir !== "string" || dir === "") return "";
74
+ const parts = dir.split(/[\\/]+/).filter((p) => p !== "");
75
+ return parts.slice(-segments).join("/");
76
+ }
77
+
78
+ /** Truncate a string to `max` chars with an ellipsis. */
79
+ export function clampText(s, max = 80) {
80
+ const str = String(s ?? "");
81
+ return str.length <= max ? str : `${str.slice(0, Math.max(0, max - 1))}…`;
82
+ }
83
+
84
+ /**
85
+ * TTL countdown for a session transcript: `updatedAt` + `ttlDays` versus
86
+ * now. Returns `{ expired, msLeft, label }` — label reads "expires in 12d",
87
+ * "expires in 4h", or "expired".
88
+ */
89
+ export function ttlCountdown(updatedAtIso, nowMs, ttlDays = DEFAULT_SESSION_TTL_DAYS) {
90
+ const ts = parseTs(updatedAtIso);
91
+ if (ts === null) return { expired: false, msLeft: null, label: "—" };
92
+ const msLeft = ts + ttlDays * DAY_MS - nowMs;
93
+ if (msLeft <= 0) return { expired: true, msLeft, label: "expired" };
94
+ const label =
95
+ msLeft >= DAY_MS
96
+ ? `expires in ${Math.ceil(msLeft / DAY_MS)}d`
97
+ : msLeft >= HOUR_MS
98
+ ? `expires in ${Math.ceil(msLeft / HOUR_MS)}h`
99
+ : "expires in <1h";
100
+ return { expired: false, msLeft, label };
101
+ }
102
+
103
+ /**
104
+ * Tolerant comparator: null/undefined/"" sort last, numbers numerically,
105
+ * everything else case-insensitively as strings.
106
+ */
107
+ export function compareValues(a, b) {
108
+ const aEmpty = a === null || a === undefined || a === "";
109
+ const bEmpty = b === null || b === undefined || b === "";
110
+ if (aEmpty && bEmpty) return 0;
111
+ if (aEmpty) return 1;
112
+ if (bEmpty) return -1;
113
+ if (typeof a === "number" && typeof b === "number") return a - b;
114
+ const as = String(a).toLowerCase();
115
+ const bs = String(b).toLowerCase();
116
+ return as < bs ? -1 : as > bs ? 1 : 0;
117
+ }
118
+
119
+ /**
120
+ * Stable sort of `rows` by `getter(row)` in direction `dir` ("asc"|"desc").
121
+ * Pinned rows always sort first regardless of key (registry pin semantics).
122
+ */
123
+ export function sortRows(rows, getter, dir = "asc") {
124
+ const sign = dir === "desc" ? -1 : 1;
125
+ return rows
126
+ .map((row, i) => ({ row, i }))
127
+ .sort((x, y) => {
128
+ const pin = (y.row.pinned === true ? 1 : 0) - (x.row.pinned === true ? 1 : 0);
129
+ if (pin !== 0) return pin;
130
+ const c = compareValues(getter(x.row), getter(y.row));
131
+ return c !== 0 ? sign * c : x.i - y.i;
132
+ })
133
+ .map((x) => x.row);
134
+ }
135
+
136
+ /**
137
+ * Normalize one `/api/harnesses` feed into flat row objects the Library can
138
+ * render. The server's row shape is registry fields + flattened
139
+ * `capabilities`/`evalHealthy`/`cachedAt` + a NESTED `rollup`
140
+ * (`sessionCount`, `spend7d` in USD micros, `lastEval`, `cachedAt`) that is
141
+ * null until hydrated. Tolerant by design: the feed may be a bare array or
142
+ * `{ harnesses: [...] }`, and every field falls back rather than throwing.
143
+ */
144
+ export function normalizeRows(feed) {
145
+ const list = Array.isArray(feed)
146
+ ? feed
147
+ : feed && Array.isArray(feed.harnesses)
148
+ ? feed.harnesses
149
+ : [];
150
+ return list.map((r) => {
151
+ const src = r && typeof r === "object" ? r : {};
152
+ const rollup = src.rollup && typeof src.rollup === "object" ? src.rollup : null;
153
+ const caps = Array.isArray(src.capabilities) ? src.capabilities.map(String) : [];
154
+ // Prefer the flat legacy fields when present (tolerance), else the
155
+ // nested rollup; join the row-level evalHealthy flag onto lastEval so
156
+ // `evalHealth` can color the dot.
157
+ const rawEval =
158
+ src.lastEval && typeof src.lastEval === "object"
159
+ ? src.lastEval
160
+ : rollup?.lastEval && typeof rollup.lastEval === "object"
161
+ ? rollup.lastEval
162
+ : null;
163
+ const lastEval =
164
+ rawEval !== null &&
165
+ typeof rawEval.healthy !== "boolean" &&
166
+ typeof src.evalHealthy === "boolean"
167
+ ? { ...rawEval, healthy: src.evalHealthy }
168
+ : rawEval;
169
+ return {
170
+ id: typeof src.id === "string" ? src.id : "",
171
+ dir: typeof src.dir === "string" ? src.dir : "",
172
+ specName:
173
+ typeof src.specName === "string" && src.specName !== ""
174
+ ? src.specName
175
+ : typeof src.name === "string"
176
+ ? src.name
177
+ : "",
178
+ target: typeof src.target === "string" ? src.target : "",
179
+ model: typeof src.model === "string" ? src.model : null,
180
+ groups: Array.isArray(src.groups) ? src.groups.map(String) : [],
181
+ tags: Array.isArray(src.tags) ? src.tags.map(String) : [],
182
+ pinned: src.pinned === true,
183
+ notes: typeof src.notes === "string" ? src.notes : "",
184
+ missingSince: typeof src.missingSince === "string" ? src.missingSince : null,
185
+ lastSeen: typeof src.lastSeen === "string" ? src.lastSeen : null,
186
+ lastActivityAt:
187
+ typeof src.lastActivityAt === "string"
188
+ ? src.lastActivityAt
189
+ : typeof src.lastSeen === "string"
190
+ ? src.lastSeen
191
+ : null,
192
+ sessions:
193
+ typeof src.sessions === "number"
194
+ ? src.sessions
195
+ : typeof src.sessionCount === "number"
196
+ ? src.sessionCount
197
+ : rollup && typeof rollup.sessionCount === "number"
198
+ ? rollup.sessionCount
199
+ : null,
200
+ spend7dUsd:
201
+ typeof src.spend7dUsd === "number"
202
+ ? src.spend7dUsd
203
+ : rollup
204
+ ? usdFromMicros(rollup.spend7d)
205
+ : null,
206
+ lastEval,
207
+ // M2 fleet-feed additions: the supervision state (a cheap runfile
208
+ // read, null when the dir is missing) and the parked-approval count —
209
+ // neither opens a transcript, so the feed stays a fold.
210
+ supervision: typeof src.supervision === "string" ? src.supervision : null,
211
+ pendingApprovals: typeof src.pendingApprovals === "number" ? src.pendingApprovals : 0,
212
+ capabilities: caps,
213
+ budgeted: typeof src.budgeted === "boolean" ? src.budgeted : caps.includes("budget"),
214
+ cachedAt:
215
+ typeof src.cachedAt === "string"
216
+ ? src.cachedAt
217
+ : rollup && typeof rollup.cachedAt === "string"
218
+ ? rollup.cachedAt
219
+ : typeof src.rollupStaleCachedAt === "string"
220
+ ? src.rollupStaleCachedAt
221
+ : null,
222
+ };
223
+ });
224
+ }
225
+
226
+ /**
227
+ * The oldest non-null `cachedAt` across rows — the honest fleet-level as-of
228
+ * time (a rollup line must never claim to be fresher than its stalest row).
229
+ * Null when no row carries one.
230
+ */
231
+ export function oldestCachedAt(rows) {
232
+ let oldest = null;
233
+ let oldestMs = Number.POSITIVE_INFINITY;
234
+ for (const row of Array.isArray(rows) ? rows : []) {
235
+ const ms = parseTs(row && typeof row === "object" ? row.cachedAt : null);
236
+ if (ms !== null && ms < oldestMs) {
237
+ oldestMs = ms;
238
+ oldest = row.cachedAt;
239
+ }
240
+ }
241
+ return oldest;
242
+ }
243
+
244
+ /**
245
+ * Eval-health derivation for the red/green dot. The dot is ALWAYS paired
246
+ * with text (color-blind-safe invariant), so this returns both.
247
+ * state: "pass" | "fail" | "unknown" | "none"
248
+ */
249
+ export function evalHealth(lastEval) {
250
+ if (!lastEval || typeof lastEval !== "object") return { state: "none", label: "no evals" };
251
+ const rate = typeof lastEval.passRate === "number" ? lastEval.passRate : null;
252
+ const pct = rate !== null ? fmtPct(rate) : "—";
253
+ if (lastEval.healthy === true) return { state: "pass", label: pct };
254
+ if (lastEval.healthy === false) return { state: "fail", label: pct };
255
+ return { state: "unknown", label: pct };
256
+ }
257
+
258
+ /** True when a Library row warrants a "needs attention" flag in M1 terms. */
259
+ export function needsAttention(row) {
260
+ if (row.missingSince !== null && row.missingSince !== undefined) return true;
261
+ return evalHealth(row.lastEval).state === "fail";
262
+ }
263
+
264
+ /** The one-sentence fleet rollup, e.g. "12 harnesses · 2 need attention". */
265
+ export function rollupLine(rows) {
266
+ const n = rows.length;
267
+ const parts = [`${n} ${n === 1 ? "harness" : "harnesses"}`];
268
+ const attention = rows.filter((r) => needsAttention(r)).length;
269
+ if (attention > 0) parts.push(`${attention} need${attention === 1 ? "s" : ""} attention`);
270
+ return parts.join(" · ");
271
+ }
272
+
273
+ /** The computed (never stored) smart groups, in display order. */
274
+ export const SMART_GROUPS = [
275
+ { id: "failing-evals", label: "Failing evals" },
276
+ { id: "unbudgeted", label: "Unbudgeted" },
277
+ { id: "has-thredz", label: "Has Thredz" },
278
+ { id: "recently-active", label: "Recently active" },
279
+ { id: "ungrouped", label: "Ungrouped" },
280
+ { id: "missing", label: "Missing" },
281
+ ];
282
+
283
+ /** Membership test for one smart group against one normalized row. */
284
+ export function smartGroupMatch(id, row, nowMs) {
285
+ switch (id) {
286
+ case "failing-evals":
287
+ return evalHealth(row.lastEval).state === "fail";
288
+ case "unbudgeted":
289
+ return row.budgeted !== true;
290
+ case "has-thredz":
291
+ return row.capabilities.includes("thredz");
292
+ case "recently-active": {
293
+ const ts = parseTs(row.lastActivityAt);
294
+ return ts !== null && nowMs - ts <= RECENT_ACTIVITY_MS && nowMs - ts >= 0;
295
+ }
296
+ case "ungrouped":
297
+ return row.groups.length === 0;
298
+ case "missing":
299
+ return row.missingSince !== null && row.missingSince !== undefined;
300
+ default:
301
+ return false;
302
+ }
303
+ }
304
+
305
+ /** All smart groups with their member rows (client-side, from row data). */
306
+ export function deriveSmartGroups(rows, nowMs) {
307
+ return SMART_GROUPS.map((g) => ({
308
+ id: g.id,
309
+ label: g.label,
310
+ rows: rows.filter((r) => smartGroupMatch(g.id, r, nowMs)),
311
+ }));
312
+ }
313
+
314
+ /**
315
+ * Map the server's `HarnessHealth` detail field (registered / pinnedEnvs /
316
+ * evalHealthy / evalNote / openIncidents / hasAudit) into the Overview
317
+ * health checklist: `[{ state, label, reason }]` with dot states matching
318
+ * `dot()` ("ok" | "warn" | "bad" | "off"). Tolerant — absent fields are
319
+ * skipped, junk input yields [] (the card renders its empty state).
320
+ */
321
+ export function healthChecks(health) {
322
+ if (!health || typeof health !== "object") return [];
323
+ const checks = [];
324
+ if (typeof health.registered === "boolean") {
325
+ const pins = Array.isArray(health.pinnedEnvs) ? health.pinnedEnvs.map(String) : [];
326
+ checks.push({
327
+ // Unregistered WITH env pins = a pin pointing at nothing — bad.
328
+ state: health.registered ? "ok" : pins.length > 0 ? "bad" : "warn",
329
+ label: health.registered ? "spec registered" : "spec not registered",
330
+ reason: pins.length > 0 ? `pinned envs: ${pins.join(", ")}` : "",
331
+ });
332
+ }
333
+ if (typeof health.evalHealthy === "boolean") {
334
+ checks.push({
335
+ state: health.evalHealthy ? "ok" : "bad",
336
+ label: health.evalHealthy ? "evals healthy" : "eval regression",
337
+ reason: typeof health.evalNote === "string" ? health.evalNote : "",
338
+ });
339
+ }
340
+ if (typeof health.openIncidents === "number") {
341
+ const n = health.openIncidents;
342
+ checks.push({
343
+ state: n === 0 ? "ok" : "bad",
344
+ label: n === 0 ? "no open incidents" : `${n} open incident${n === 1 ? "" : "s"}`,
345
+ reason: "",
346
+ });
347
+ }
348
+ if (typeof health.hasAudit === "boolean") {
349
+ checks.push({
350
+ state: health.hasAudit ? "ok" : "off",
351
+ label: health.hasAudit ? "audit trail present" : "no audit trail",
352
+ reason: "",
353
+ });
354
+ }
355
+ return checks;
356
+ }
357
+
358
+ /**
359
+ * Interleave a server transcript view (`{ turns, tools, gutter }`, each item
360
+ * carrying a 0-based `line` anchor) into one render-ordered list of
361
+ * `{ type: "turn"|"tool"|"gutter", ...item }`. Ordering: by line, then
362
+ * turn < tool < gutter at the same line (a tool_use is emitted at its
363
+ * assistant turn's line), then source order. Pure + tolerant: junk input
364
+ * yields [].
365
+ */
366
+ export function interleaveTranscript(view) {
367
+ const src = view && typeof view === "object" ? view : {};
368
+ const staged = [];
369
+ const stage = (type, items, rank) => {
370
+ if (!Array.isArray(items)) return;
371
+ for (const item of items) {
372
+ if (!item || typeof item !== "object") continue;
373
+ const line = typeof item.line === "number" ? item.line : 0;
374
+ staged.push({ type, line, rank, seq: staged.length, item });
375
+ }
376
+ };
377
+ stage("turn", src.turns, 0);
378
+ stage("tool", src.tools, 1);
379
+ stage("gutter", src.gutter, 2);
380
+ staged.sort((a, b) => a.line - b.line || a.rank - b.rank || a.seq - b.seq);
381
+ return staged.map((s) => ({ type: s.type, ...s.item }));
382
+ }
383
+
384
+ /**
385
+ * SVG path string for a mini trend line ("M x y L x y …"). Returns "" for
386
+ * fewer than two finite points. Pure string math — the caller puts it in a
387
+ * `<path d>` attribute, so nothing here can inject markup.
388
+ */
389
+ export function sparklinePath(values, width = 120, height = 28, pad = 2) {
390
+ const pts = (Array.isArray(values) ? values : []).filter(
391
+ (v) => typeof v === "number" && Number.isFinite(v),
392
+ );
393
+ if (pts.length < 2) return "";
394
+ const min = Math.min(...pts);
395
+ const max = Math.max(...pts);
396
+ const range = max - min;
397
+ const innerW = width - 2 * pad;
398
+ const innerH = height - 2 * pad;
399
+ const step = innerW / (pts.length - 1);
400
+ const coords = pts.map((v, i) => {
401
+ const x = pad + i * step;
402
+ const y = range === 0 ? height / 2 : pad + (1 - (v - min) / range) * innerH;
403
+ return `${Math.round(x * 100) / 100} ${Math.round(y * 100) / 100}`;
404
+ });
405
+ return `M${coords[0]} L${coords.slice(1).join(" L")}`;
406
+ }
407
+
408
+ /**
409
+ * Rectangles for a mini bar chart (7-day spend). Returns
410
+ * `[{ x, y, w, h }]` in SVG user units; zero values keep a 0.5px baseline
411
+ * sliver so "no spend" days are visibly present, not missing data.
412
+ */
413
+ export function barRects(values, width = 140, height = 36, gap = 3) {
414
+ const vals = (Array.isArray(values) ? values : []).map((v) =>
415
+ typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0,
416
+ );
417
+ const n = vals.length;
418
+ if (n === 0) return [];
419
+ const barW = (width - gap * (n - 1)) / n;
420
+ const max = Math.max(...vals);
421
+ return vals.map((v, i) => {
422
+ const h = max > 0 && v > 0 ? Math.max(1, (v / max) * (height - 1)) : 0.5;
423
+ const round = (x) => Math.round(x * 100) / 100;
424
+ return { x: round(i * (barW + gap)), y: round(height - h), w: round(barW), h: round(h) };
425
+ });
426
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The activity digest — "what changed since yesterday", across the fleet,
3
+ * grouped by kind.
4
+ *
5
+ * Every item is a POINTER, never a payload copy (the server builds the
6
+ * digest from mtimes and small capped folds — no transcript is ever opened
7
+ * for it), so every row's job is to be a good link: each one deep-links into
8
+ * the screen that owns the thing that changed.
9
+ */
10
+
11
+ import { api } from "../api.js";
12
+ import { clear, dot, el, emptyState, skeleton } from "../dom.js";
13
+ import { hrefHarness } from "../router.js";
14
+ import { activityGroups, activityHref } from "../supervision.js";
15
+ import { fmtRelativeTime } from "../util.js";
16
+
17
+ /** The windows the digest offers; `since` is the server's own grammar. */
18
+ const WINDOWS = [
19
+ { since: "1d", label: "24 hours" },
20
+ { since: "7d", label: "7 days" },
21
+ { since: "30d", label: "30 days" },
22
+ ];
23
+
24
+ export async function renderActivity(root) {
25
+ clear(root).appendChild(skeleton(6));
26
+ await draw(root, "1d", null);
27
+ }
28
+
29
+ async function draw(host, since, enabled) {
30
+ let payload;
31
+ try {
32
+ payload = await api.activity(since);
33
+ } catch (err) {
34
+ clear(host).appendChild(
35
+ el("div", { class: "card error-card" }, [
36
+ el("p", { class: "muted", text: err instanceof Error ? err.message : String(err) }),
37
+ ]),
38
+ );
39
+ return;
40
+ }
41
+ clear(host);
42
+ const nowMs = Date.now();
43
+ const items = payload && Array.isArray(payload.items) ? payload.items : [];
44
+ const groups = activityGroups(items, enabled);
45
+
46
+ host.appendChild(
47
+ el(
48
+ "div",
49
+ { class: "filter-bar", role: "group", "aria-label": "digest window" },
50
+ WINDOWS.map((w) =>
51
+ el("button", {
52
+ class: `btn btn-ghost${w.since === since ? " active" : ""}`,
53
+ type: "button",
54
+ text: w.label,
55
+ onClick: () => draw(host, w.since, enabled),
56
+ }),
57
+ ),
58
+ ),
59
+ );
60
+ host.appendChild(
61
+ el("div", { class: "rollup" }, [
62
+ el("span", {
63
+ text: `${items.length} change${items.length === 1 ? "" : "s"} since ${
64
+ payload && typeof payload.since === "string"
65
+ ? fmtRelativeTime(payload.since, nowMs)
66
+ : since
67
+ }`,
68
+ }),
69
+ payload && payload.truncated === true
70
+ ? el("span", { class: "chip chip-warn", text: "capped — older items not shown" })
71
+ : null,
72
+ ]),
73
+ );
74
+
75
+ if (items.length === 0) {
76
+ host.appendChild(
77
+ emptyState("Nothing has changed in this window", "crewhaus run (or start a daemon)"),
78
+ );
79
+ return;
80
+ }
81
+
82
+ // Per-group filters: a switched-off kind still shows its count, so the
83
+ // digest never hides how much it is hiding.
84
+ host.appendChild(
85
+ el(
86
+ "div",
87
+ { class: "filter-bar", role: "group", "aria-label": "activity kinds" },
88
+ groups.map((g) =>
89
+ el("button", {
90
+ class: `btn btn-ghost${g.shown ? " active" : ""}`,
91
+ type: "button",
92
+ text: `${g.label} ${g.count}`,
93
+ onClick: () => {
94
+ const next = new Set(
95
+ enabled === null ? groups.filter((x) => x.shown).map((x) => x.kind) : enabled,
96
+ );
97
+ if (next.has(g.kind)) next.delete(g.kind);
98
+ else next.add(g.kind);
99
+ draw(host, since, next);
100
+ },
101
+ }),
102
+ ),
103
+ ),
104
+ );
105
+
106
+ const visible = groups.filter((g) => g.shown);
107
+ if (visible.length === 0) {
108
+ host.appendChild(emptyState("Every kind is filtered out — re-enable one above"));
109
+ return;
110
+ }
111
+ for (const group of visible) {
112
+ host.appendChild(
113
+ el("section", { class: "card" }, [
114
+ el("h3", { class: "card-title" }, [
115
+ el("span", { text: group.label }),
116
+ el("span", { class: "muted card-sub", text: `${group.count}` }),
117
+ ]),
118
+ el(
119
+ "ul",
120
+ { class: "check-list" },
121
+ group.items.map((item) => {
122
+ const href = activityHref(item);
123
+ const label = typeof item.label === "string" ? item.label : group.label;
124
+ return el("li", { class: "activity-row" }, [
125
+ dot("off", fmtRelativeTime(item.at ?? null, nowMs)),
126
+ href !== null
127
+ ? el("a", { class: "name-link", href, text: label })
128
+ : el("span", { text: label }),
129
+ el("a", {
130
+ class: "muted sub",
131
+ href: hrefHarness(String(item.harnessId ?? "")),
132
+ text: String(item.specName ?? item.harnessId ?? ""),
133
+ }),
134
+ ]);
135
+ }),
136
+ ),
137
+ ]),
138
+ );
139
+ }
140
+ }