@prismatic-io/lux 0.0.2-preview.4 → 0.0.2-preview.7

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 (138) hide show
  1. package/lib/assertions/authoring.d.ts +2 -2
  2. package/lib/assertions/authoring.d.ts.map +1 -1
  3. package/lib/assertions/authoring.js.map +1 -1
  4. package/lib/assertions/core/index.d.ts.map +1 -1
  5. package/lib/assertions/core/index.js +2 -1
  6. package/lib/assertions/core/index.js.map +1 -1
  7. package/lib/assertions/core/resource-checks.d.ts +19 -0
  8. package/lib/assertions/core/resource-checks.d.ts.map +1 -1
  9. package/lib/assertions/core/resource-checks.js +32 -0
  10. package/lib/assertions/core/resource-checks.js.map +1 -1
  11. package/lib/cli/program.d.ts.map +1 -1
  12. package/lib/cli/program.js +6 -65
  13. package/lib/cli/program.js.map +1 -1
  14. package/lib/cli/view.d.ts +13 -0
  15. package/lib/cli/view.d.ts.map +1 -0
  16. package/lib/cli/view.js +126 -0
  17. package/lib/cli/view.js.map +1 -0
  18. package/lib/core/action-events.d.ts +29 -0
  19. package/lib/core/action-events.d.ts.map +1 -0
  20. package/lib/core/action-events.js +51 -0
  21. package/lib/core/action-events.js.map +1 -0
  22. package/lib/core/agent-activity.d.ts +49 -0
  23. package/lib/core/agent-activity.d.ts.map +1 -0
  24. package/lib/core/agent-activity.js +206 -0
  25. package/lib/core/agent-activity.js.map +1 -0
  26. package/lib/core/agent-events.d.ts +26 -0
  27. package/lib/core/agent-events.d.ts.map +1 -0
  28. package/lib/core/agent-events.js +13 -0
  29. package/lib/core/agent-events.js.map +1 -0
  30. package/lib/core/driver-metrics.d.ts +98 -0
  31. package/lib/core/driver-metrics.d.ts.map +1 -0
  32. package/lib/core/driver-metrics.js +36 -0
  33. package/lib/core/driver-metrics.js.map +1 -0
  34. package/lib/core/events.d.ts +34 -0
  35. package/lib/core/events.d.ts.map +1 -1
  36. package/lib/core/events.js +11 -2
  37. package/lib/core/events.js.map +1 -1
  38. package/lib/core/experiment.d.ts +19 -0
  39. package/lib/core/experiment.d.ts.map +1 -1
  40. package/lib/core/experiment.js +2 -0
  41. package/lib/core/experiment.js.map +1 -1
  42. package/lib/core/index.d.ts +4 -0
  43. package/lib/core/index.d.ts.map +1 -1
  44. package/lib/core/index.js +4 -0
  45. package/lib/core/index.js.map +1 -1
  46. package/lib/core/run.d.ts +149 -0
  47. package/lib/core/run.d.ts.map +1 -1
  48. package/lib/core/run.js +9 -0
  49. package/lib/core/run.js.map +1 -1
  50. package/lib/core/wire.d.ts +11 -0
  51. package/lib/core/wire.d.ts.map +1 -1
  52. package/lib/drivers/claude-code/parse-events.d.ts.map +1 -1
  53. package/lib/drivers/claude-code/parse-events.js +73 -8
  54. package/lib/drivers/claude-code/parse-events.js.map +1 -1
  55. package/lib/drivers/codex/app-events.d.ts +9 -2
  56. package/lib/drivers/codex/app-events.d.ts.map +1 -1
  57. package/lib/drivers/codex/app-events.js +93 -12
  58. package/lib/drivers/codex/app-events.js.map +1 -1
  59. package/lib/drivers/codex/index.d.ts.map +1 -1
  60. package/lib/drivers/codex/index.js +10 -14
  61. package/lib/drivers/codex/index.js.map +1 -1
  62. package/lib/index.d.ts +6 -2
  63. package/lib/index.d.ts.map +1 -1
  64. package/lib/index.js +6 -2
  65. package/lib/index.js.map +1 -1
  66. package/lib/orchestrator/compare.d.ts +13 -1
  67. package/lib/orchestrator/compare.d.ts.map +1 -1
  68. package/lib/orchestrator/compare.js +12 -0
  69. package/lib/orchestrator/compare.js.map +1 -1
  70. package/lib/orchestrator/experiment-contracts.d.ts +19 -0
  71. package/lib/orchestrator/experiment-contracts.d.ts.map +1 -1
  72. package/lib/orchestrator/experiment-evaluation.d.ts.map +1 -1
  73. package/lib/orchestrator/experiment-evaluation.js +17 -0
  74. package/lib/orchestrator/experiment-evaluation.js.map +1 -1
  75. package/lib/orchestrator/experiment-report.d.ts +19 -0
  76. package/lib/orchestrator/experiment-report.d.ts.map +1 -1
  77. package/lib/orchestrator/experiment-runs.d.ts +38 -0
  78. package/lib/orchestrator/experiment-runs.d.ts.map +1 -1
  79. package/lib/orchestrator/index.d.ts +1 -1
  80. package/lib/orchestrator/index.d.ts.map +1 -1
  81. package/lib/orchestrator/index.js +1 -1
  82. package/lib/orchestrator/index.js.map +1 -1
  83. package/lib/orchestrator/list-runs.d.ts +7 -1
  84. package/lib/orchestrator/list-runs.d.ts.map +1 -1
  85. package/lib/orchestrator/list-runs.js +50 -6
  86. package/lib/orchestrator/list-runs.js.map +1 -1
  87. package/lib/orchestrator/run-execution.d.ts.map +1 -1
  88. package/lib/orchestrator/run-execution.js +17 -3
  89. package/lib/orchestrator/run-execution.js.map +1 -1
  90. package/lib/orchestrator/suite-compare.d.ts +34 -0
  91. package/lib/orchestrator/suite-compare.d.ts.map +1 -1
  92. package/lib/orchestrator/suite-summary.d.ts +68 -0
  93. package/lib/orchestrator/suite-summary.d.ts.map +1 -1
  94. package/lib/orchestrator/suite-summary.js +45 -7
  95. package/lib/orchestrator/suite-summary.js.map +1 -1
  96. package/lib/previewer/artifacts.d.ts +25 -0
  97. package/lib/previewer/artifacts.d.ts.map +1 -0
  98. package/lib/previewer/artifacts.js +180 -0
  99. package/lib/previewer/artifacts.js.map +1 -0
  100. package/lib/previewer/server.d.ts +105 -0
  101. package/lib/previewer/server.d.ts.map +1 -0
  102. package/lib/previewer/server.js +542 -0
  103. package/lib/previewer/server.js.map +1 -0
  104. package/package.json +2 -1
  105. package/skills/lux/references/eval-authoring.md +4 -2
  106. package/skills/lux-answerer/SKILL.md +1 -1
  107. package/src/assertions/authoring.ts +2 -0
  108. package/src/assertions/core/index.ts +2 -0
  109. package/src/assertions/core/resource-checks.ts +35 -0
  110. package/src/cli/program.ts +5 -91
  111. package/src/cli/view.ts +168 -0
  112. package/src/core/action-events.ts +59 -0
  113. package/src/core/agent-activity.ts +236 -0
  114. package/src/core/agent-events.ts +17 -0
  115. package/src/core/driver-metrics.ts +51 -0
  116. package/src/core/events.ts +13 -2
  117. package/src/core/experiment.ts +2 -0
  118. package/src/core/index.ts +4 -0
  119. package/src/core/run.ts +9 -0
  120. package/src/drivers/claude-code/parse-events.ts +98 -12
  121. package/src/drivers/codex/app-events.ts +116 -16
  122. package/src/drivers/codex/index.ts +16 -14
  123. package/src/index.ts +40 -0
  124. package/src/orchestrator/compare.ts +18 -0
  125. package/src/orchestrator/experiment-evaluation.ts +23 -0
  126. package/src/orchestrator/index.ts +1 -0
  127. package/src/orchestrator/list-runs.ts +66 -5
  128. package/src/orchestrator/run-execution.ts +19 -2
  129. package/src/orchestrator/suite-summary.ts +101 -8
  130. package/src/previewer/artifacts.ts +221 -0
  131. package/src/previewer/server.ts +765 -0
  132. package/viewer/app.css +2075 -0
  133. package/viewer/app.js +606 -0
  134. package/viewer/artifact-view.js +48 -0
  135. package/viewer/charts.js +86 -0
  136. package/viewer/detail.js +1214 -0
  137. package/viewer/format.js +129 -0
  138. package/viewer/index.html +151 -0
package/viewer/app.js ADDED
@@ -0,0 +1,606 @@
1
+ import {
2
+ detailEmpty,
3
+ renderArtifactComparison,
4
+ renderArtifactPreview,
5
+ renderExperimentDetail,
6
+ renderRunComparison,
7
+ renderRunDetail,
8
+ renderSuiteComparison,
9
+ renderSuiteDetail,
10
+ } from "./detail.js";
11
+ import {
12
+ detailError,
13
+ detailLoading,
14
+ escapeHtml,
15
+ fullDate,
16
+ percentage,
17
+ relativeTime,
18
+ statusLabel,
19
+ } from "./format.js";
20
+
21
+ const state = {
22
+ snapshot: null,
23
+ initialized: false,
24
+ tab: "runs",
25
+ status: "all",
26
+ query: "",
27
+ selectedId: null,
28
+ detail: null,
29
+ loadingDetail: false,
30
+ detailRequest: 0,
31
+ compareMode: false,
32
+ compareIds: [],
33
+ comparison: null,
34
+ loadingComparison: false,
35
+ comparisonRequest: 0,
36
+ candidateTargetId: null,
37
+ overlay: null,
38
+ };
39
+
40
+ const elements = {
41
+ list: document.querySelector("#collection-list"),
42
+ inspector: document.querySelector("#inspector"),
43
+ filters: document.querySelector("#status-filters"),
44
+ title: document.querySelector("#collection-title"),
45
+ eyebrow: document.querySelector("#collection-eyebrow"),
46
+ count: document.querySelector("#result-count"),
47
+ search: document.querySelector("#search-input"),
48
+ refresh: document.querySelector("#refresh-button"),
49
+ compare: document.querySelector("#compare-button"),
50
+ compareTray: document.querySelector("#compare-tray"),
51
+ connection: document.querySelector("#connection-state"),
52
+ };
53
+
54
+ const currentItems = () => state.snapshot?.[state.tab] ?? [];
55
+
56
+ const searchableText = (item) =>
57
+ [
58
+ item.caseId,
59
+ item.driver,
60
+ item.agentId,
61
+ item.suiteId,
62
+ item.profileId,
63
+ item.experimentId,
64
+ item.campaignId,
65
+ item.championCandidateId,
66
+ item.path,
67
+ ]
68
+ .filter(Boolean)
69
+ .join(" ")
70
+ .toLowerCase();
71
+
72
+ const visibleItems = () => {
73
+ const query = state.query.trim().toLowerCase();
74
+ return currentItems()
75
+ .filter((item) => state.status === "all" || item.status === state.status)
76
+ .filter((item) => !query || searchableText(item).includes(query))
77
+ .sort((left, right) =>
78
+ String(right.startedAt ?? right.experimentId ?? right.suiteId).localeCompare(
79
+ String(left.startedAt ?? left.experimentId ?? left.suiteId),
80
+ ),
81
+ );
82
+ };
83
+
84
+ const rowTitle = (item) => item.caseId ?? item.campaignId ?? item.suiteId;
85
+
86
+ const rowMetric = (item) => {
87
+ if (item.kind === "run") {
88
+ return `<strong>${escapeHtml(percentage(item.score))}</strong><small>${escapeHtml(
89
+ `${item.assertions.passed}/${item.assertions.total} checks`,
90
+ )}</small>`;
91
+ }
92
+ if (item.kind === "suite") {
93
+ const complete = item.items.filter((entry) => entry.status === "completed").length;
94
+ return `<strong>${complete}/${item.items.length}</strong><small>complete</small>`;
95
+ }
96
+ return `<strong>${item.candidateCount}</strong><small>candidates</small>`;
97
+ };
98
+
99
+ const rowMeta = (item) => {
100
+ if (item.kind === "run") {
101
+ return [
102
+ item.driver,
103
+ item.agentId,
104
+ item.subagentCount > 0 ? `1 + ${item.subagentCount} agents` : "1 agent",
105
+ relativeTime(item.startedAt),
106
+ ].filter(Boolean);
107
+ }
108
+ if (item.kind === "suite") {
109
+ return [item.profileId ?? "default profile", fullDate(item.startedAt), `j=${item.concurrency}`];
110
+ }
111
+ return [item.mode, fullDate(item.startedAt), item.championCandidateId ?? "no champion"];
112
+ };
113
+
114
+ const compareSlot = (id) => {
115
+ const index = state.compareIds.indexOf(id);
116
+ return index < 0 ? "" : `<span class="compare-slot">${index === 0 ? "A" : "B"}</span>`;
117
+ };
118
+
119
+ const renderRows = () => {
120
+ const items = visibleItems();
121
+ elements.count.textContent = `${items.length} ${items.length === 1 ? "record" : "records"}`;
122
+ if (items.length === 0) {
123
+ elements.list.innerHTML = `
124
+ <div class="empty-list">
125
+ <strong>No matching ${escapeHtml(state.tab)}</strong>
126
+ <p>Try another status or search term.</p>
127
+ </div>`;
128
+ return;
129
+ }
130
+ elements.list.innerHTML = items
131
+ .map(
132
+ (item) => `
133
+ <button
134
+ class="evidence-row${state.compareMode ? " is-compare-mode" : ""}${state.selectedId === item.id && !state.compareMode ? " is-selected" : ""}${
135
+ state.compareIds.includes(item.id) ? " is-compared" : ""
136
+ }"
137
+ type="button"
138
+ data-select="${escapeHtml(item.id)}"
139
+ >
140
+ ${state.compareMode ? compareSlot(item.id) || '<span class="compare-slot empty">+</span>' : ""}
141
+ <span class="status-pin ${escapeHtml(item.status)}" aria-label="${escapeHtml(
142
+ statusLabel(item.status),
143
+ )}"></span>
144
+ <span class="row-main">
145
+ <span class="row-title">${escapeHtml(rowTitle(item))}</span>
146
+ <span class="row-meta">
147
+ ${rowMeta(item)
148
+ .map((value) => `<span>${escapeHtml(value)}</span>`)
149
+ .join("<b>·</b>")}
150
+ </span>
151
+ </span>
152
+ <span class="row-metric">${rowMetric(item)}</span>
153
+ </button>`,
154
+ )
155
+ .join("");
156
+ };
157
+
158
+ const renderFilters = () => {
159
+ const items = currentItems();
160
+ const statuses = ["all", "passed", "failed", "error", "running", "aborted", "ungraded"].filter(
161
+ (status) => status === "all" || items.some((item) => item.status === status),
162
+ );
163
+ elements.filters.innerHTML = statuses
164
+ .map((status) => {
165
+ const count =
166
+ status === "all" ? items.length : items.filter((item) => item.status === status).length;
167
+ return `<button
168
+ class="filter-chip${state.status === status ? " is-active" : ""}"
169
+ type="button"
170
+ data-status="${escapeHtml(status)}"
171
+ >${escapeHtml(status === "all" ? "All" : statusLabel(status))} · ${count}</button>`;
172
+ })
173
+ .join("");
174
+ };
175
+
176
+ const renderCompareControls = () => {
177
+ const available = state.tab === "runs" || state.tab === "suites";
178
+ elements.compare.hidden = !available;
179
+ elements.compare.classList.toggle("is-active", state.compareMode);
180
+ elements.compare.textContent = state.compareMode ? "Cancel compare" : "Compare";
181
+ if (!state.compareMode) {
182
+ elements.compareTray.hidden = true;
183
+ return;
184
+ }
185
+ const selected = state.compareIds.map((id) => currentItems().find((item) => item.id === id));
186
+ elements.compareTray.hidden = false;
187
+ elements.compareTray.innerHTML = `
188
+ <span><b>A</b>${escapeHtml(selected[0] ? rowTitle(selected[0]) : "Choose baseline")}</span>
189
+ <i>→</i>
190
+ <span><b>B</b>${escapeHtml(selected[1] ? rowTitle(selected[1]) : "Choose candidate")}</span>
191
+ <small>${state.compareIds.length < 2 ? "Select two rows" : "Exact persisted evidence"}</small>`;
192
+ };
193
+
194
+ const comparisonLabels = () => {
195
+ const [beforeId, afterId] = state.compareIds;
196
+ return {
197
+ before: rowTitle(currentItems().find((item) => item.id === beforeId) ?? {}) ?? "A",
198
+ after: rowTitle(currentItems().find((item) => item.id === afterId) ?? {}) ?? "B",
199
+ };
200
+ };
201
+
202
+ const renderInspector = () => {
203
+ if (state.overlay?.loading) {
204
+ elements.inspector.innerHTML = detailLoading("Reading artifact…");
205
+ return;
206
+ }
207
+ if (state.overlay?.error) {
208
+ elements.inspector.innerHTML = detailError(state.overlay.error);
209
+ return;
210
+ }
211
+ if (state.overlay?.type === "artifact") {
212
+ elements.inspector.innerHTML = renderArtifactPreview(state.overlay.data);
213
+ return;
214
+ }
215
+ if (state.overlay?.type === "artifact-comparison") {
216
+ elements.inspector.innerHTML = renderArtifactComparison(state.overlay.data);
217
+ return;
218
+ }
219
+ if (state.compareMode) {
220
+ if (state.loadingComparison) {
221
+ elements.inspector.innerHTML = detailLoading("Comparing persisted evidence…");
222
+ return;
223
+ }
224
+ if (state.comparison) {
225
+ elements.inspector.innerHTML =
226
+ state.tab === "runs"
227
+ ? renderRunComparison(state.comparison, comparisonLabels())
228
+ : renderSuiteComparison(state.comparison, comparisonLabels());
229
+ return;
230
+ }
231
+ elements.inspector.innerHTML = `
232
+ <div class="inspector-empty compare-empty">
233
+ <span class="empty-glyph" aria-hidden="true">A/B</span>
234
+ <h2>Choose a baseline and candidate</h2>
235
+ <p>Lux will compare identity, outcomes, judgments, resources, and artifacts from the exact persisted evidence.</p>
236
+ </div>`;
237
+ return;
238
+ }
239
+ const item = currentItems().find((entry) => entry.id === state.selectedId);
240
+ if (!item) {
241
+ elements.inspector.innerHTML = detailEmpty();
242
+ return;
243
+ }
244
+ if (state.loadingDetail) {
245
+ elements.inspector.innerHTML = detailLoading(`Loading ${item.kind} evidence…`);
246
+ return;
247
+ }
248
+ if (!state.detail) {
249
+ elements.inspector.innerHTML = detailEmpty();
250
+ return;
251
+ }
252
+ if (item.kind === "run") {
253
+ elements.inspector.innerHTML = renderRunDetail(item, state.detail);
254
+ } else if (item.kind === "suite") {
255
+ elements.inspector.innerHTML = renderSuiteDetail(item, state.detail);
256
+ } else {
257
+ elements.inspector.innerHTML = renderExperimentDetail(
258
+ item,
259
+ state.detail,
260
+ state.candidateTargetId,
261
+ );
262
+ }
263
+ };
264
+
265
+ const renderMetrics = () => {
266
+ const runs = state.snapshot?.runs ?? [];
267
+ const graded = runs.filter((run) => run.status === "passed" || run.status === "failed");
268
+ const passed = graded.filter((run) => run.status === "passed").length;
269
+ const failures = runs.filter((run) => run.status === "failed" || run.status === "error").length;
270
+ const experiments = state.snapshot?.experiments ?? [];
271
+ const running = experiments.filter((experiment) => experiment.status === "running").length;
272
+ const latest = runs
273
+ .map((run) => run.startedAt)
274
+ .filter(Boolean)
275
+ .sort()
276
+ .at(-1);
277
+ document.querySelector("#metric-pass-rate").textContent =
278
+ graded.length > 0 ? percentage(passed / graded.length) : "—";
279
+ document.querySelector("#metric-pass-note").textContent =
280
+ graded.length > 0 ? `${passed} of ${graded.length} graded runs passed` : "No graded runs";
281
+ document.querySelector("#metric-failures").textContent = String(failures);
282
+ document.querySelector("#metric-experiments").textContent = String(experiments.length);
283
+ let experimentNote = "No campaigns yet";
284
+ if (running) experimentNote = `${running} campaign${running === 1 ? "" : "s"} running`;
285
+ else if (experiments.length) experimentNote = "All campaigns settled";
286
+ document.querySelector("#metric-experiment-note").textContent = experimentNote;
287
+ document.querySelector("#metric-recency").textContent = latest ? relativeTime(latest) : "—";
288
+ document.querySelector("#metric-refresh").textContent = `Refreshed ${relativeTime(
289
+ state.snapshot?.generatedAt,
290
+ )} ago`;
291
+ };
292
+
293
+ const renderChrome = () => {
294
+ const copy = {
295
+ runs: ["Individual evidence", "Runs"],
296
+ suites: ["Invocation groups", "Suites"],
297
+ experiments: ["Controlled change", "Experiments"],
298
+ }[state.tab];
299
+ elements.eyebrow.textContent = copy[0];
300
+ elements.title.textContent = copy[1];
301
+ document.querySelectorAll("[data-tab]").forEach((button) => {
302
+ button.classList.toggle("is-active", button.dataset.tab === state.tab);
303
+ });
304
+ for (const tab of ["runs", "suites", "experiments"]) {
305
+ document.querySelector(`[data-count="${tab}"]`).textContent = String(
306
+ state.snapshot?.[tab]?.length ?? 0,
307
+ );
308
+ }
309
+ document.querySelector("#runs-root").textContent =
310
+ state.snapshot?.runsRoot ?? "Local Lux run store";
311
+ renderMetrics();
312
+ renderFilters();
313
+ renderCompareControls();
314
+ renderRows();
315
+ renderInspector();
316
+ };
317
+
318
+ const setConnection = (connected, message) => {
319
+ elements.connection.classList.toggle("is-offline", !connected);
320
+ elements.connection.lastChild.textContent = ` ${message}`;
321
+ };
322
+
323
+ const updateUrl = () => {
324
+ if (!state.initialized) return;
325
+ const params = new URLSearchParams();
326
+ if (state.tab !== "runs") params.set("tab", state.tab);
327
+ if (state.status !== "all") params.set("status", state.status);
328
+ if (state.query) params.set("q", state.query);
329
+ if (state.selectedId && !state.compareMode) params.set("item", state.selectedId);
330
+ if (state.compareIds[0]) params.set("before", state.compareIds[0]);
331
+ if (state.compareIds[1]) params.set("after", state.compareIds[1]);
332
+ if (state.candidateTargetId) params.set("target", state.candidateTargetId);
333
+ const query = params.toString();
334
+ history.replaceState(null, "", query ? `?${query}` : location.pathname);
335
+ };
336
+
337
+ const fetchJson = async (url) => {
338
+ const response = await fetch(url, { cache: "no-store" });
339
+ const payload = await response.json();
340
+ if (!response.ok) throw new Error(payload.error ?? `Request failed (${response.status})`);
341
+ return payload;
342
+ };
343
+
344
+ const fetchDetail = async (id, { silent = false } = {}) => {
345
+ const request = ++state.detailRequest;
346
+ if (!silent) {
347
+ state.loadingDetail = true;
348
+ state.detail = null;
349
+ renderInspector();
350
+ }
351
+ try {
352
+ const payload = await fetchJson(`/api/${state.tab}/${encodeURIComponent(id)}`);
353
+ if (request !== state.detailRequest || id !== state.selectedId) return;
354
+ state.detail = payload;
355
+ state.loadingDetail = false;
356
+ renderInspector();
357
+ } catch (error) {
358
+ if (request !== state.detailRequest) return;
359
+ state.loadingDetail = false;
360
+ elements.inspector.innerHTML = detailError(error.message);
361
+ }
362
+ };
363
+
364
+ const fetchComparison = async ({ silent = false } = {}) => {
365
+ const [before, after] = state.compareIds;
366
+ if (!before || !after) return;
367
+ const request = ++state.comparisonRequest;
368
+ if (!silent) {
369
+ state.loadingComparison = true;
370
+ state.comparison = null;
371
+ renderInspector();
372
+ }
373
+ try {
374
+ const params = new URLSearchParams({ before, after });
375
+ const payload = await fetchJson(`/api/compare/${state.tab}?${params}`);
376
+ if (request !== state.comparisonRequest) return;
377
+ state.comparison = payload;
378
+ state.loadingComparison = false;
379
+ renderInspector();
380
+ } catch (error) {
381
+ if (request !== state.comparisonRequest) return;
382
+ state.loadingComparison = false;
383
+ elements.inspector.innerHTML = detailError(error.message);
384
+ }
385
+ };
386
+
387
+ const selectItem = (id) => {
388
+ state.selectedId = id;
389
+ state.detail = null;
390
+ state.overlay = null;
391
+ state.candidateTargetId = null;
392
+ updateUrl();
393
+ renderRows();
394
+ void fetchDetail(id);
395
+ };
396
+
397
+ const selectComparisonItem = (id) => {
398
+ const currentIndex = state.compareIds.indexOf(id);
399
+ if (currentIndex >= 0) state.compareIds.splice(currentIndex, 1);
400
+ else if (state.compareIds.length < 2) state.compareIds.push(id);
401
+ else state.compareIds[1] = id;
402
+ state.comparison = null;
403
+ state.overlay = null;
404
+ renderChrome();
405
+ updateUrl();
406
+ if (state.compareIds.length === 2) void fetchComparison();
407
+ };
408
+
409
+ const setCompareMode = (enabled) => {
410
+ state.compareMode = enabled;
411
+ state.compareIds = [];
412
+ state.comparison = null;
413
+ state.overlay = null;
414
+ state.comparisonRequest++;
415
+ renderChrome();
416
+ updateUrl();
417
+ };
418
+
419
+ const applyInitialUrl = (snapshot) => {
420
+ const params = new URLSearchParams(location.search);
421
+ const requestedTab = params.get("tab");
422
+ const validTabs = new Set(["runs", "suites", "experiments"]);
423
+ if (requestedTab && validTabs.has(requestedTab)) state.tab = requestedTab;
424
+ else if (snapshot.initial) state.tab = `${snapshot.initial.kind}s`;
425
+ state.status = params.get("status") ?? "all";
426
+ state.query = params.get("q") ?? "";
427
+ elements.search.value = state.query;
428
+ state.selectedId = params.get("item") ?? snapshot.initial?.id ?? null;
429
+ state.candidateTargetId = params.get("target");
430
+ const pair = [params.get("before"), params.get("after")].filter(Boolean);
431
+ if (state.tab !== "experiments" && pair.length) {
432
+ state.compareMode = true;
433
+ state.compareIds = pair
434
+ .filter((id) => currentItems().some((item) => item.id === id))
435
+ .slice(0, 2);
436
+ }
437
+ state.initialized = true;
438
+ };
439
+
440
+ const refresh = async ({ first = false } = {}) => {
441
+ elements.refresh.classList.add("is-spinning");
442
+ try {
443
+ state.snapshot = await fetchJson("/api/snapshot");
444
+ if (first && !state.initialized) applyInitialUrl(state.snapshot);
445
+ const selectedExists = currentItems().some((entry) => entry.id === state.selectedId);
446
+ if (!selectedExists && !state.compareMode) state.selectedId = visibleItems()[0]?.id ?? null;
447
+ state.compareIds = state.compareIds.filter((id) =>
448
+ currentItems().some((item) => item.id === id),
449
+ );
450
+ setConnection(true, "Connected");
451
+ renderChrome();
452
+ updateUrl();
453
+ if (state.compareMode && state.compareIds.length === 2) {
454
+ void fetchComparison({ silent: !first });
455
+ } else if (state.selectedId) {
456
+ void fetchDetail(state.selectedId, { silent: !first });
457
+ }
458
+ } catch (error) {
459
+ setConnection(false, "Evidence unavailable");
460
+ if (first) elements.list.innerHTML = detailError(error.message);
461
+ } finally {
462
+ elements.refresh.classList.remove("is-spinning");
463
+ }
464
+ };
465
+
466
+ const showArtifact = async (path, comparison = false) => {
467
+ state.overlay = { type: comparison ? "artifact-comparison" : "artifact", loading: true };
468
+ renderInspector();
469
+ try {
470
+ const params = comparison
471
+ ? new URLSearchParams({
472
+ before: state.compareIds[0],
473
+ after: state.compareIds[1],
474
+ path,
475
+ })
476
+ : new URLSearchParams({ run: state.selectedId, path });
477
+ state.overlay = {
478
+ type: comparison ? "artifact-comparison" : "artifact",
479
+ loading: false,
480
+ data: await fetchJson(
481
+ comparison ? `/api/compare/artifacts?${params}` : `/api/artifact?${params}`,
482
+ ),
483
+ };
484
+ } catch (error) {
485
+ state.overlay = { type: comparison ? "artifact-comparison" : "artifact", error: error.message };
486
+ }
487
+ renderInspector();
488
+ };
489
+
490
+ document.addEventListener("click", (event) => {
491
+ const tab = event.target.closest("[data-tab]");
492
+ if (tab) {
493
+ state.tab = tab.dataset.tab;
494
+ state.status = "all";
495
+ state.selectedId = null;
496
+ state.detail = null;
497
+ state.candidateTargetId = null;
498
+ setCompareMode(false);
499
+ return;
500
+ }
501
+ const filter = event.target.closest("[data-status]");
502
+ if (filter) {
503
+ state.status = filter.dataset.status;
504
+ renderFilters();
505
+ renderRows();
506
+ updateUrl();
507
+ return;
508
+ }
509
+ if (event.target.closest("#compare-button")) {
510
+ setCompareMode(!state.compareMode);
511
+ return;
512
+ }
513
+ if (event.target.closest("[data-close-compare]")) {
514
+ setCompareMode(false);
515
+ return;
516
+ }
517
+ const artifact = event.target.closest("[data-artifact]");
518
+ if (artifact) {
519
+ void showArtifact(artifact.dataset.artifact);
520
+ return;
521
+ }
522
+ const comparedArtifact = event.target.closest("[data-compare-artifact]");
523
+ if (comparedArtifact) {
524
+ void showArtifact(comparedArtifact.dataset.compareArtifact, true);
525
+ return;
526
+ }
527
+ if (event.target.closest("[data-back-detail], [data-back-comparison]")) {
528
+ state.overlay = null;
529
+ renderInspector();
530
+ return;
531
+ }
532
+ const openRun = event.target.closest("[data-open-run]");
533
+ if (openRun) {
534
+ state.tab = "runs";
535
+ state.status = "all";
536
+ selectItem(openRun.dataset.openRun);
537
+ renderChrome();
538
+ return;
539
+ }
540
+ const row = event.target.closest("[data-select]");
541
+ if (row) {
542
+ if (state.compareMode) selectComparisonItem(row.dataset.select);
543
+ else selectItem(row.dataset.select);
544
+ }
545
+ });
546
+
547
+ document.addEventListener("change", (event) => {
548
+ const target = event.target.closest("[data-candidate-target]");
549
+ if (!target) return;
550
+ state.candidateTargetId = target.value;
551
+ updateUrl();
552
+ renderInspector();
553
+ });
554
+
555
+ elements.search.addEventListener("input", () => {
556
+ state.query = elements.search.value;
557
+ renderRows();
558
+ updateUrl();
559
+ });
560
+
561
+ elements.refresh.addEventListener("click", () => void refresh());
562
+
563
+ document.addEventListener("keydown", (event) => {
564
+ const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName);
565
+ if (event.key === "/" && !typing) {
566
+ event.preventDefault();
567
+ elements.search.focus();
568
+ return;
569
+ }
570
+ if (event.key === "Escape") {
571
+ if (typing) elements.search.blur();
572
+ else if (state.overlay) {
573
+ state.overlay = null;
574
+ renderInspector();
575
+ } else if (state.compareMode) setCompareMode(false);
576
+ return;
577
+ }
578
+ if (!typing && event.key.toLowerCase() === "r") {
579
+ event.preventDefault();
580
+ void refresh();
581
+ return;
582
+ }
583
+ if (!typing && ["j", "k"].includes(event.key.toLowerCase())) {
584
+ event.preventDefault();
585
+ const items = visibleItems();
586
+ if (!items.length) return;
587
+ const referenceId = state.compareMode ? state.compareIds.at(-1) : state.selectedId;
588
+ const current = items.findIndex((item) => item.id === referenceId);
589
+ const offset = event.key.toLowerCase() === "j" ? 1 : -1;
590
+ const next = current < 0 ? 0 : Math.max(0, Math.min(items.length - 1, current + offset));
591
+ if (state.compareMode) selectComparisonItem(items[next].id);
592
+ else selectItem(items[next].id);
593
+ document.querySelector(`[data-select="${CSS.escape(items[next].id)}"]`)?.scrollIntoView({
594
+ block: "nearest",
595
+ });
596
+ }
597
+ });
598
+
599
+ document.addEventListener("visibilitychange", () => {
600
+ if (document.visibilityState === "visible") void refresh();
601
+ });
602
+
603
+ void refresh({ first: true });
604
+ setInterval(() => {
605
+ if (document.visibilityState === "visible") void refresh();
606
+ }, 5_000);
@@ -0,0 +1,48 @@
1
+ import { escapeHtml } from "./format.js";
2
+
3
+ export const renderArtifactPreview = (preview) => `
4
+ <header class="detail-header compact-header">
5
+ <button class="back-button" type="button" data-back-detail>← Evidence</button>
6
+ <div class="detail-title-row"><div><h2>${escapeHtml(
7
+ preview.path,
8
+ )}</h2><p class="detail-subtitle">${escapeHtml(
9
+ [preview.contentType, preview.size === null ? null : `${preview.size} bytes`]
10
+ .filter(Boolean)
11
+ .join(" · "),
12
+ )}</p></div></div>
13
+ </header>
14
+ <div class="detail-body">
15
+ ${
16
+ preview.viewable
17
+ ? `<pre class="artifact-preview">${escapeHtml(preview.text)}</pre>`
18
+ : `<div class="attention-banner">${escapeHtml(
19
+ preview.reason ?? "Artifact cannot be previewed.",
20
+ )}</div>`
21
+ }
22
+ </div>`;
23
+
24
+ export const renderArtifactComparison = (comparison) => `
25
+ <header class="detail-header compact-header">
26
+ <button class="back-button" type="button" data-back-comparison>← Comparison</button>
27
+ <div class="detail-title-row"><div><h2>${escapeHtml(
28
+ comparison.path,
29
+ )}</h2><p class="detail-subtitle">Unified artifact diff · A → B</p></div></div>
30
+ </header>
31
+ <div class="detail-body">
32
+ ${
33
+ comparison.operations
34
+ ? `<pre class="artifact-diff">${comparison.operations
35
+ .flatMap((operation) =>
36
+ operation.lines.map(
37
+ (line) =>
38
+ `<span class="diff-${escapeHtml(operation.kind)}"><b>${
39
+ { added: "+", removed: "−", same: " " }[operation.kind]
40
+ }</b>${escapeHtml(line)}</span>`,
41
+ ),
42
+ )
43
+ .join("\n")}</pre>`
44
+ : `<div class="attention-banner">${escapeHtml(
45
+ comparison.reason ?? "Artifact diff is unavailable.",
46
+ )}</div>`
47
+ }
48
+ </div>`;