bugpatrol 0.2.1 → 0.4.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.
package/src/ui/app.js CHANGED
@@ -8,11 +8,47 @@ const view = document.getElementById('view');
8
8
  const tabs = document.getElementById('tabs');
9
9
  const liveEl = document.getElementById('live');
10
10
  const projectEl = document.getElementById('project-name');
11
- const platformEl = document.getElementById('platform');
11
+ const surfacesEl = document.getElementById('surfaces');
12
12
  const patrolEl = document.getElementById('patrol');
13
+ const themeEl = document.getElementById('theme');
14
+
15
+ // ---------- theme ----------
16
+ // The choice cycles system → light → dark. "System" follows the OS setting, also when it changes.
17
+ const THEMES = ['system', 'light', 'dark'];
18
+ const lightQuery = matchMedia('(prefers-color-scheme: light)');
19
+ let themeChoice = THEMES.includes(localStorage.getItem('bugpatrol-theme'))
20
+ ? localStorage.getItem('bugpatrol-theme')
21
+ : 'system';
22
+ // Line icons for the top bar. The theme button and the surfaces show icons, and their labels hold the words.
23
+ const MONITOR_ICON = '<rect x="2" y="3" width="12" height="8" rx="1"/><path d="M6 14h4M8 11v3"/>';
24
+ const PHONE_ICON = '<rect x="4.5" y="1.5" width="7" height="13" rx="1.5"/><path d="M7 12.5h2"/>';
25
+ const themeIcons = {
26
+ system: MONITOR_ICON,
27
+ light:
28
+ '<circle cx="8" cy="8" r="3"/><path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1"/>',
29
+ dark: '<path d="M13 9.5A5.5 5.5 0 0 1 6.5 3a5.5 5.5 0 1 0 6.5 6.5Z"/>',
30
+ };
31
+ function applyTheme() {
32
+ const theme = themeChoice === 'system' ? (lightQuery.matches ? 'light' : 'dark') : themeChoice;
33
+ document.documentElement.dataset.theme = theme;
34
+ const next = THEMES[(THEMES.indexOf(themeChoice) + 1) % THEMES.length];
35
+ const label = { system: 'System', light: 'Light', dark: 'Dark' };
36
+ themeEl.innerHTML = iconSvg(themeIcons[themeChoice]);
37
+ themeEl.setAttribute('aria-label', `Theme: ${label[themeChoice]}. Change the theme to ${label[next]}`);
38
+ themeEl.title = `Theme: ${label[themeChoice]}. Click for ${label[next]}.`;
39
+ }
40
+ themeEl.addEventListener('click', () => {
41
+ themeChoice = THEMES[(THEMES.indexOf(themeChoice) + 1) % THEMES.length];
42
+ if (themeChoice === 'system') localStorage.removeItem('bugpatrol-theme');
43
+ else localStorage.setItem('bugpatrol-theme', themeChoice);
44
+ applyTheme();
45
+ });
46
+ lightQuery.addEventListener('change', applyTheme);
47
+ applyTheme();
13
48
 
14
49
  const state = {
15
50
  view: 'overview',
51
+ summary: null,
16
52
  overview: null,
17
53
  issues: [],
18
54
  issueFilter: 'open',
@@ -26,6 +62,9 @@ const state = {
26
62
  selectedScreenId: null,
27
63
  routines: [],
28
64
  memory: { lessons: [] },
65
+ coverage: null,
66
+ coverageFilter: { failedOnly: false, hideSkipped: false, query: '' },
67
+ selectedCell: null,
29
68
  runs: [],
30
69
  selectedRunId: null,
31
70
  reviews: [],
@@ -44,7 +83,8 @@ try {
44
83
  }
45
84
  const graphCamera = { box: null, key: '' };
46
85
  let showBackLinks = false;
47
- let renderedReviews = '';
86
+ let renderedState = '';
87
+ let renderedPage = '';
48
88
 
49
89
  // ---------------------------------------------------------------- data
50
90
 
@@ -62,14 +102,20 @@ async function getJson(url) {
62
102
 
63
103
  const artifact = (path) => `/api/artifact?path=${encodeURIComponent(path)}`;
64
104
 
105
+ async function fetchText(path) {
106
+ const response = await fetch(artifact(path));
107
+ if (!response.ok) throw new Error(String(response.status));
108
+ return response.text();
109
+ }
110
+
65
111
  async function refresh({ keepSelection = true } = {}) {
66
112
  const snapshot = await getJson('/api/state');
67
113
  state.runs = snapshot.runs;
68
114
  state.live = snapshot.live;
69
115
  state.root = snapshot.root;
116
+ state.summary = await getJson('/api/summary');
70
117
  state.overview = await getJson('/api/overview');
71
- projectEl.textContent = state.overview.project.name;
72
- platformEl.textContent = state.overview.project.platform;
118
+ renderHeader();
73
119
  const patrol = state.overview.patrol;
74
120
  patrolEl.textContent = patrol.state === 'running' ? `Patrol running · cycle ${patrol.cycle}` : 'Patrol stopped';
75
121
  patrolEl.classList.toggle('running', patrol.state === 'running');
@@ -98,7 +144,7 @@ async function refresh({ keepSelection = true } = {}) {
98
144
  state.issueDetail = null;
99
145
  }
100
146
  }
101
- if (state.view === 'activity') {
147
+ if (state.view === 'activity' || state.view === 'flow') {
102
148
  state.sessions = await getJson('/api/sessions');
103
149
  if (!state.sessions.some((session) => session.id === state.selectedSessionId)) {
104
150
  state.selectedSessionId = state.sessions[0]?.id ?? null;
@@ -110,10 +156,6 @@ async function refresh({ keepSelection = true } = {}) {
110
156
  }
111
157
  }
112
158
  if (state.view === 'flow') {
113
- state.sessions = await getJson('/api/sessions');
114
- if (!state.sessions.some((session) => session.id === state.selectedSessionId)) {
115
- state.selectedSessionId = state.sessions[0]?.id ?? null;
116
- }
117
159
  state.flow = state.selectedSessionId
118
160
  ? await getJson(`/api/flow/${encodeURIComponent(state.selectedSessionId)}`).catch(() => null)
119
161
  : null;
@@ -128,19 +170,27 @@ async function refresh({ keepSelection = true } = {}) {
128
170
  }
129
171
  }
130
172
  if (state.view === 'memory') state.memory = await getJson('/api/memory');
173
+ if (state.view === 'coverage') {
174
+ state.coverage = state.summary.coverage.api ? await getJson('/api/coverage') : null;
175
+ if (!state.coverage?.cells[state.selectedCell]?.exchange) state.selectedCell = null;
176
+ if (state.summary.coverage.screens) {
177
+ state.appmap = await getJson('/api/appmap');
178
+ state.issues = await getJson('/api/issues');
179
+ }
180
+ }
131
181
  if (state.view === 'reviews') {
132
182
  const reviews = await getJson('/api/reviews');
133
183
  if (!reviews.some((review) => review.pr.number === state.selectedReview)) {
134
184
  state.selectedReview = reviews[0]?.pr.number ?? null;
135
185
  }
136
186
  const detail = state.selectedReview ? await getJson(`/api/reviews/${state.selectedReview}`) : null;
137
- // A rebuilt page restarts a playing video, so skip the render when the review did not change.
138
- const key = JSON.stringify([reviews, detail]);
139
- if (key === renderedReviews && view.querySelector('.review-page')) return;
140
- renderedReviews = key;
141
187
  state.reviews = reviews;
142
188
  state.reviewDetail = detail;
143
189
  }
190
+ // A rebuilt page closes what the reader opened and restarts a playing video: skip it when nothing changed.
191
+ const key = JSON.stringify(state);
192
+ if (key === renderedState && view.childElementCount) return;
193
+ renderedState = key;
144
194
  render();
145
195
  }
146
196
 
@@ -162,6 +212,24 @@ function render() {
162
212
  button.classList.toggle('active', button.dataset.view === state.view);
163
213
  if (button.dataset.view === 'checks') button.hidden = !hasChecks;
164
214
  }
215
+ // The same page, drawn again with new data, keeps what the reader opened and where they were.
216
+ const page = JSON.stringify([
217
+ state.view,
218
+ state.selectedIssueId,
219
+ state.selectedSessionId,
220
+ state.selectedReview,
221
+ state.selectedRunId,
222
+ state.selectedScreenId,
223
+ state.selectedCell,
224
+ ]);
225
+ const opened = page === renderedPage ? detailsOpen() : undefined;
226
+ const scroll = window.scrollY;
227
+ // A scroll box, such as the coverage matrix, keeps its place too.
228
+ const boxes = opened
229
+ ? [...view.querySelectorAll('[data-keep-scroll]')].map((box) => [box.scrollLeft, box.scrollTop])
230
+ : [];
231
+ const typing = opened && document.activeElement?.matches('input[type="search"]');
232
+ renderedPage = page;
165
233
  view.innerHTML = '';
166
234
  if (state.view === 'checks') {
167
235
  const banner = renderLiveBanner();
@@ -175,9 +243,32 @@ function render() {
175
243
  screens: renderScreens,
176
244
  reviews: renderReviews,
177
245
  memory: renderMemory,
246
+ coverage: renderCoverage,
178
247
  checks: renderRuns,
179
248
  };
180
249
  view.append(renderers[state.view]());
250
+ if (opened) {
251
+ for (const [key, details] of detailsByKey()) if (opened.has(key)) details.open = opened.get(key);
252
+ for (const [index, box] of [...view.querySelectorAll('[data-keep-scroll]')].entries())
253
+ if (boxes[index]) [box.scrollLeft, box.scrollTop] = boxes[index];
254
+ if (typing) view.querySelector('input[type="search"]')?.focus();
255
+ window.scrollTo(0, scroll);
256
+ }
257
+ }
258
+
259
+ /** Each <details> on the page by its summary and its place among the ones with the same summary. */
260
+ function detailsByKey() {
261
+ const seen = new Map();
262
+ return [...view.querySelectorAll('details')].map((details) => {
263
+ const summary = details.querySelector('summary')?.textContent ?? '';
264
+ const nth = (seen.get(summary) ?? 0) + 1;
265
+ seen.set(summary, nth);
266
+ return [`${summary}#${nth}`, details];
267
+ });
268
+ }
269
+
270
+ function detailsOpen() {
271
+ return new Map(detailsByKey().map(([key, details]) => [key, details.open]));
181
272
  }
182
273
 
183
274
  function el(tag, props = {}, children = []) {
@@ -212,7 +303,7 @@ function renderLiveBanner() {
212
303
  const total = live.plannedCaptures || done;
213
304
  return el('div', { class: 'runbanner' }, [
214
305
  el('span', { class: 'spinner' }),
215
- el('span', { text: `Run in progress — ${done}/${total} captures` }),
306
+ el('span', { text: `Run in progress: ${done}/${total} captures` }),
216
307
  live.currentStep ? el('span', { class: 'muted', text: live.currentStep }) : null,
217
308
  ]);
218
309
  }
@@ -329,7 +420,7 @@ function renderDetail() {
329
420
  ...trace.suppressed.map((s) =>
330
421
  el('div', { class: 'finding' }, [
331
422
  el('div', { text: `${s.ruleId} on ${s.screenId}` }),
332
- el('div', { class: 'why', text: `"${s.reason}" — ${s.decidedBy}` }),
423
+ el('div', { class: 'why', text: `"${s.reason}": ${s.decidedBy}` }),
333
424
  ]),
334
425
  ),
335
426
  ]),
@@ -462,7 +553,7 @@ function renderReasoning(screen) {
462
553
  'Pixel diff',
463
554
  d.identical
464
555
  ? `identical (${d.engine}, ${Math.round(d.durationMs)}ms)`
465
- : `${d.changedPixels} px across ${d.regionCount} region(s) — ` +
556
+ : `${d.changedPixels} px across ${d.regionCount} region(s): ` +
466
557
  `${(d.changedFraction * 100).toFixed(3)}% of compared area (${d.engine})`,
467
558
  );
468
559
  add('Masked', `${(d.maskedFraction * 100).toFixed(1)}% of the screen, ` + `${d.maskedRegionCount} region(s)`);
@@ -485,7 +576,7 @@ function renderReasoning(screen) {
485
576
  const d = screen.decision;
486
577
  add(
487
578
  'Decision layer',
488
- d.decider === 'none' ? `not consulted — ${d.skippedReason}` : `${d.decider}, $${(d.costUsd ?? 0).toFixed(6)}`,
579
+ d.decider === 'none' ? `not consulted: ${d.skippedReason}` : `${d.decider}, $${(d.costUsd ?? 0).toFixed(6)}`,
489
580
  );
490
581
  if (d.stateChars) add('State digest', `${d.stateChars} chars (hash ${String(d.stateHash ?? '').slice(0, 12)})`);
491
582
  for (const [key, answer] of Object.entries(d.answers ?? {})) {
@@ -523,16 +614,21 @@ const roleIcons = {
523
614
  decider: '<path d="m3 8 3 3 7-7"/>',
524
615
  };
525
616
 
617
+ /** A 16×16 line icon from trusted path markup in this file. */
618
+ function iconSvg(paths) {
619
+ return (
620
+ `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" ` +
621
+ `stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`
622
+ );
623
+ }
624
+
526
625
  function roleIcon(role) {
527
626
  const icon = roleIcons[role] ?? roleIcons.system;
528
- const svg =
529
- `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" ` +
530
- `stroke-width="1.4" aria-hidden="true">${icon}</svg>`;
531
- return el('span', { class: 'role-icon', title: role, 'aria-label': role, html: svg });
627
+ return el('span', { class: 'role-icon', title: role, 'aria-label': role, html: iconSvg(icon) });
532
628
  }
533
629
 
534
630
  function relativeTime(value) {
535
- if (!value) return '—';
631
+ if (!value) return '-';
536
632
  const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000));
537
633
  if (!Number.isFinite(seconds)) return value;
538
634
  if (seconds < 60) return 'just now';
@@ -590,10 +686,10 @@ function renderUsage(usage) {
590
686
  el('td', { text: row.model }),
591
687
  el('td', { text: String(row.sessions) }),
592
688
  el('td', { text: count(row.tokens.input) }),
593
- el('td', { text: row.tokens.cacheRead ? count(row.tokens.cacheRead) : '—' }),
689
+ el('td', { text: row.tokens.cacheRead ? count(row.tokens.cacheRead) : '-' }),
594
690
  el('td', { text: count(row.tokens.output) }),
595
691
  el('td', { text: count(Math.round((row.tokens.input + row.tokens.output) / Math.max(1, row.sessions))) }),
596
- el('td', { text: row.tokens.listCostUsd ? money(row.tokens.listCostUsd) : '—' }),
692
+ el('td', { text: row.tokens.listCostUsd ? money(row.tokens.listCostUsd) : '-' }),
597
693
  ]),
598
694
  ),
599
695
  ])
@@ -604,8 +700,26 @@ function renderUsage(usage) {
604
700
  ]);
605
701
  }
606
702
 
607
- function image(path, label, className = '') {
703
+ /** A capture file: a screenshot, or the exchange file of an API, which shows as text. */
704
+ function captureFile(path, label, className = '') {
608
705
  if (!path) return el('div', { class: `image-empty ${className}`, text: 'No capture' });
706
+ if (path.endsWith('.exchange.json')) {
707
+ const small = isThumb(className);
708
+ const view = small
709
+ ? httpThumb(undefined, label, className)
710
+ : el('pre', { class: `http ${className}`, title: label, text: 'Loading the exchange…' });
711
+ // The server formats the exchange, so the dashboard shows the same text as the models and issues.
712
+ fetch(`/api/exchange?path=${encodeURIComponent(path)}`)
713
+ .then((response) => (response.ok ? response.text() : Promise.reject(new Error(String(response.status)))))
714
+ .then((text) => {
715
+ if (small) view.textContent = text.match(/^HTTP \d{3}/m)?.[0] ?? 'HTTP';
716
+ else view.textContent = text;
717
+ })
718
+ .catch(() => {
719
+ if (!small) view.textContent = 'The exchange is not available.';
720
+ });
721
+ return view;
722
+ }
609
723
  return el('img', {
610
724
  class: className,
611
725
  src: artifact(path),
@@ -615,6 +729,20 @@ function image(path, label, className = '') {
615
729
  });
616
730
  }
617
731
 
732
+ /** The capture of an issue: its screenshot, or the HTTP exchange of an API as the server formatted it. */
733
+ function captureView(capture, label, className = '') {
734
+ if (capture?.kind !== 'http') return captureFile(capture?.screenshot, label, className);
735
+ if (isThumb(className)) return httpThumb(capture.exchange?.response?.status, label, className);
736
+ return el('pre', { class: `http ${className}`, title: label, text: capture.text });
737
+ }
738
+
739
+ const isThumb = (className) => /\b(?:thumb|event-thumb)\b/.test(className);
740
+
741
+ /** A thumbnail is too small for the exchange text, so it shows the status only. */
742
+ function httpThumb(status, label, className) {
743
+ return el('div', { class: `http-thumb ${className}`, title: label, text: status ? `HTTP ${status}` : 'HTTP' });
744
+ }
745
+
618
746
  function severityChip(value) {
619
747
  return el('span', { class: `severity severity-${value}`, text: value });
620
748
  }
@@ -626,6 +754,190 @@ function title(text, aside) {
626
754
  ]);
627
755
  }
628
756
 
757
+ // ---------------------------------------------------------------- shared blocks
758
+
759
+ const DOCS = 'https://github.com/agent-labs-dev/bugpatrol/blob/main/docs';
760
+
761
+ const PLATFORM_WORDS = {
762
+ api: 'API',
763
+ web: 'Web app',
764
+ electron: 'Electron app',
765
+ desktop: 'Desktop app',
766
+ ios: 'iOS app',
767
+ android: 'Android app',
768
+ cli: 'CLI app',
769
+ };
770
+
771
+ const PLATFORM_ICONS = {
772
+ api: '<path d="M5.5 3C4 3 4 4 4 5.5S3 8 2.5 8C3 8 4 8.5 4 10.5S4 13 5.5 13M10.5 3C12 3 12 4 12 5.5S13 8 13.5 8C13 8 12 8.5 12 10.5S12 13 10.5 13"/>',
773
+ web: '<circle cx="8" cy="8" r="6"/><path d="M2 8h12M8 2c2 2 2 10 0 12M8 2c-2 2-2 10 0 12"/>',
774
+ electron: MONITOR_ICON,
775
+ desktop: MONITOR_ICON,
776
+ ios: PHONE_ICON,
777
+ android: PHONE_ICON,
778
+ cli: '<rect x="1.5" y="2.5" width="13" height="11" rx="1"/><path d="m4.5 6 2 2-2 2M8 10h3.5"/>',
779
+ };
780
+
781
+ const surfaceName = (surface) => PLATFORM_WORDS[surface.platform] ?? surface.platform;
782
+ const fileName = (path) => path?.split('/').pop() ?? '';
783
+
784
+ function surfacesOf(kind) {
785
+ return (state.summary?.surfaces ?? []).filter((surface) => surface.kind === kind);
786
+ }
787
+
788
+ /** The project's surfaces in the top bar. The surface of the config that the dashboard reads is marked. */
789
+ function renderHeader() {
790
+ const summary = state.summary;
791
+ projectEl.textContent = summary.name;
792
+ surfacesEl.replaceChildren(
793
+ ...summary.surfaces.map((surface) => {
794
+ const configs = surface.configs.length
795
+ ? `${surface.active ? 'The dashboard reads' : 'Config'}: ${surface.configs.join(', ')}`
796
+ : 'From the data. No config file tests it now.';
797
+ const name = surfaceName(surface);
798
+ return el('span', {
799
+ class: `surface${surface.active ? ' active' : ''}`,
800
+ role: 'img',
801
+ 'aria-label': `${name}. ${configs}`,
802
+ title: `${name}. ${configs}`,
803
+ html: iconSvg(PLATFORM_ICONS[surface.platform] ?? PLATFORM_ICONS.web),
804
+ });
805
+ }),
806
+ );
807
+ }
808
+
809
+ /** A command for the config that the dashboard reads, or for the config of another surface. */
810
+ function cli(args, surface) {
811
+ const config =
812
+ surface && !surface.active && surface.configs[0]
813
+ ? ` --config ${surface.configs[0]}`
814
+ : (state.summary?.configArg ?? '');
815
+ return `bugpatrol ${args}${config}`;
816
+ }
817
+
818
+ /** A command with a copy button. */
819
+ function commandBox(text) {
820
+ const button = el('button', {
821
+ type: 'button',
822
+ class: 'copy',
823
+ text: 'Copy',
824
+ 'aria-label': `Copy the command ${text}`,
825
+ });
826
+ button.addEventListener('click', async () => {
827
+ try {
828
+ await navigator.clipboard.writeText(text);
829
+ button.textContent = 'Copied';
830
+ } catch {
831
+ button.textContent = 'Copy failed';
832
+ }
833
+ setTimeout(() => {
834
+ button.textContent = 'Copy';
835
+ }, 1500);
836
+ });
837
+ return el('div', { class: 'command' }, [el('code', { text }), button]);
838
+ }
839
+
840
+ /**
841
+ * The one empty state of the dashboard: what the block shows, why it is
842
+ * empty now, and the one next step, as a command or a button.
843
+ */
844
+ function emptyState({ heading, why = [], command, action, docs, compact = false }) {
845
+ return el('div', { class: `empty-state${compact ? ' compact' : ''}` }, [
846
+ el('h3', { text: heading }),
847
+ ...[].concat(why).map((text) => el('p', { text })),
848
+ command ? commandBox(command) : null,
849
+ action ? el('button', { type: 'button', class: 'primary', text: action.label, onclick: action.onclick }) : null,
850
+ docs
851
+ ? el('a', { class: 'docs', href: docs, target: '_blank', rel: 'noopener noreferrer', text: 'Read the docs' })
852
+ : null,
853
+ ]);
854
+ }
855
+
856
+ /** A note with one command, for a block that has data but also a next step. */
857
+ function callout(text, command) {
858
+ return el('div', { class: 'callout' }, [el('p', { text }), commandBox(command)]);
859
+ }
860
+
861
+ const number = (value) => Number(value ?? 0).toLocaleString('en-US');
862
+
863
+ function percent(part, whole) {
864
+ if (!whole) return '0%';
865
+ const value = (part / whole) * 100;
866
+ return value > 0 && value < 1 ? '<1%' : `${Math.round(value)}%`;
867
+ }
868
+
869
+ /** A row of numbers. A stat with an action is a button. */
870
+ function statBand(stats, className = '') {
871
+ return el(
872
+ 'div',
873
+ { class: `band ${className}` },
874
+ stats.filter(Boolean).map(({ label, value, note, tone, bar, go }) =>
875
+ el(
876
+ go ? 'button' : 'div',
877
+ {
878
+ class: `stat${tone ? ` tone-${tone}` : ''}`,
879
+ ...(go ? { type: 'button', onclick: go } : {}),
880
+ },
881
+ [
882
+ el('span', { class: 'stat-label', text: label }),
883
+ el('strong', { class: 'stat-value', text: String(value) }),
884
+ bar ?? null,
885
+ note ? el('span', { class: 'stat-note', title: note, text: note }) : null,
886
+ ],
887
+ ),
888
+ ),
889
+ );
890
+ }
891
+
892
+ const CELL_STATES = ['pass', 'fail', 'blocked', 'skip', 'untested'];
893
+
894
+ /** The cases of each state as one stacked bar. */
895
+ function caseBar(cases) {
896
+ const states = CELL_STATES.filter((name) => cases[name]);
897
+ const total = states.reduce((sum, name) => sum + cases[name], 0);
898
+ return el(
899
+ 'div',
900
+ { class: 'bar', role: 'img', 'aria-label': states.map((name) => `${number(cases[name])} ${name}`).join(', ') },
901
+ states.map((name) =>
902
+ el('span', {
903
+ class: `bar-${name}`,
904
+ style: `flex-grow: ${cases[name] / total}`,
905
+ title: `${number(cases[name])} ${name}`,
906
+ }),
907
+ ),
908
+ );
909
+ }
910
+
911
+ function caseLegend(cases) {
912
+ return el(
913
+ 'div',
914
+ { class: 'bar-legend' },
915
+ CELL_STATES.filter((name) => cases[name] !== undefined).map((name) =>
916
+ el('span', {}, [el('i', { class: `bar-${name}` }), `${number(cases[name])} ${name}`]),
917
+ ),
918
+ );
919
+ }
920
+
921
+ async function go(view) {
922
+ state.view = view;
923
+ if (view === 'screens') state.selectedScreenId = null;
924
+ await refresh();
925
+ }
926
+
927
+ async function openSession(id) {
928
+ state.view = 'activity';
929
+ state.selectedSessionId = id;
930
+ await refresh();
931
+ }
932
+
933
+ function formatDuration(ms) {
934
+ if (ms === undefined) return '-';
935
+ const seconds = Math.round(ms / 1000);
936
+ if (seconds < 60) return `${seconds}s`;
937
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
938
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
939
+ }
940
+
629
941
  const CI_WORDS = { pending: 'CI running', passed: 'CI green', failed: 'CI failed, fixing', 'gave-up': 'CI red' };
630
942
 
631
943
  function githubChip({ kind, number, url, state, draft, ci }) {
@@ -651,7 +963,8 @@ function issueChips(issue) {
651
963
 
652
964
  function renderOverview() {
653
965
  const data = state.overview;
654
- if (!data) return el('div', { class: 'empty', text: 'Loading overview…' });
966
+ const summary = state.summary;
967
+ if (!data || !summary) return el('div', { class: 'empty', text: 'Loading overview…' });
655
968
  const agents = el(
656
969
  'div',
657
970
  { class: 'agent-grid' },
@@ -680,77 +993,271 @@ function renderOverview() {
680
993
  ]),
681
994
  ),
682
995
  );
683
- const attention = el('section', { class: 'card panel' }, [
684
- title('Needs attention', `${data.counts.issuesOpen} open`),
685
- data.attention.length
686
- ? el(
687
- 'div',
688
- { class: 'stack' },
689
- data.attention.map((issue) =>
690
- el('button', { class: 'attention-row', onclick: () => openIssue(issue.id) }, [
691
- image(issue.evidence?.screenshot, issue.title, 'thumb'),
692
- el('span', { class: 'grow' }, [
693
- el('strong', { text: issue.title }),
694
- el('span', { class: 'row-meta' }, [
695
- el('small', { class: 'muted', text: `${screenName(issue.screenId)} · ×${issue.occurrences}` }),
696
- ...issueChips(issue),
697
- ]),
698
- ]),
699
- severityChip(issue.severity),
700
- el('span', { class: 'muted', text: issueStatus(issue, issue.fix) }),
701
- ]),
702
- ),
703
- )
704
- : el('p', { class: 'empty', text: 'No open issues. The agents found nothing wrong yet.' }),
996
+ return el('div', {}, [
997
+ renderConfidence(summary),
998
+ el('div', { class: 'overview-grid' }, [
999
+ renderAttention(data, summary),
1000
+ el('div', { class: 'stack-gap' }, [data.live ? renderLive(data.live) : null, renderRecentSessions(data)]),
1001
+ ]),
1002
+ renderSurfaceCoverage(data, summary),
1003
+ agents,
1004
+ renderUsage(data.usage),
705
1005
  ]);
706
- const live = data.live;
707
- const latest = data.recentSessions[0];
708
- const livePanel = el('section', { class: 'card panel' }, [
709
- title('Live', live ? 'Session running' : 'Quiet now'),
710
- live
711
- ? el('div', {}, [
712
- image(live.screenshot, 'Newest session capture', 'live-shot'),
713
- el('div', { class: 'event-list' }, live.events.map(renderEventRow)),
714
- ])
715
- : el('div', { class: 'quiet' }, [
716
- el('p', {
717
- text: latest
718
- ? `Last: the ${latest.role}, ${relativeTime(latest.startedAt)}. ${firstSentence(latest.summary)}`
719
- : 'No sessions yet.',
720
- }),
721
- data.patrol?.nextAt
722
- ? el('p', {
723
- class: 'muted',
724
- text: `Next patrol at ${new Date(data.patrol.nextAt).toLocaleTimeString([], {
725
- hour: '2-digit',
726
- minute: '2-digit',
727
- })}${data.patrol.commit ? ` · last tested commit ${data.patrol.commit.slice(0, 7)}` : ''}`,
1006
+ }
1007
+
1008
+ /** The numbers that say if the project works: the verdict, the open problems, the coverage, and the last session. */
1009
+ function renderConfidence(summary) {
1010
+ const { issues, waiting, coverage, lastSession, verdict } = summary;
1011
+ const severe = issues.bySeverity.critical + issues.bySeverity.major;
1012
+ const api = coverage.api;
1013
+ return statBand(
1014
+ [
1015
+ { label: 'Status', value: verdict.text, tone: verdict.tone },
1016
+ {
1017
+ label: 'Open issues',
1018
+ value: number(issues.open),
1019
+ note: severe ? `${severe} critical or major` : issues.open ? 'None is critical or major' : 'None',
1020
+ tone: severe ? 'fail' : undefined,
1021
+ go: () => go('issues'),
1022
+ },
1023
+ {
1024
+ label: 'Wait for the judge',
1025
+ value: number(waiting.total),
1026
+ note: waiting.repeat
1027
+ ? `${number(waiting.repeat)} failed two times`
1028
+ : waiting.total
1029
+ ? 'The judge did not read them yet'
1030
+ : 'None',
1031
+ tone: waiting.total ? 'warn' : undefined,
1032
+ go: () => go('activity'),
1033
+ },
1034
+ surfacesOf('api').length
1035
+ ? {
1036
+ label: 'API coverage',
1037
+ value: api ? percent(api.tested, api.endpoints) : '-',
1038
+ note: api ? `${number(api.tested)} of ${number(api.endpoints)} endpoints tested` : 'No sweep yet',
1039
+ go: () => go('coverage'),
1040
+ }
1041
+ : null,
1042
+ surfacesOf('client').length
1043
+ ? {
1044
+ label: 'Screens',
1045
+ value: number(coverage.screens?.found),
1046
+ note: coverage.screens?.found ? `${coverage.screens.withIssues} with open issues` : 'No screens yet',
1047
+ go: () => go('coverage'),
1048
+ }
1049
+ : null,
1050
+ {
1051
+ label: 'Last session',
1052
+ value: lastSession ? (lastSession.status === 'running' ? 'Running' : relativeTime(lastSession.startedAt)) : '-',
1053
+ note: lastSession
1054
+ ? firstSentence(lastSession.summary) || `${capital(lastSession.role)} session`
1055
+ : 'No session yet',
1056
+ go: lastSession ? () => openSession(lastSession.id) : undefined,
1057
+ },
1058
+ ],
1059
+ 'confidence',
1060
+ );
1061
+ }
1062
+
1063
+ /** The open issues, then the findings that wait for the judge. */
1064
+ function renderAttention(data, summary) {
1065
+ const { issues, waiting } = summary;
1066
+ const tested = summary.verdict.tone !== 'info';
1067
+ const issueRows = data.attention.map((issue) =>
1068
+ el('button', { class: 'attention-row', onclick: () => openIssue(issue.id) }, [
1069
+ captureView(issue.evidence?.capture, issue.title, 'thumb'),
1070
+ el('span', { class: 'grow' }, [
1071
+ el('strong', { text: issue.title }),
1072
+ el('span', { class: 'row-meta' }, [
1073
+ el('small', { class: 'muted', text: `${issueWhere(issue)} · ×${issue.occurrences}` }),
1074
+ ...issueChips(issue),
1075
+ ]),
1076
+ ]),
1077
+ severityChip(issue.severity),
1078
+ el('span', { class: 'muted', text: issueStatus(issue, issue.fix) }),
1079
+ ]),
1080
+ );
1081
+ const waitingRows = data.waiting.slice(0, 5).map((candidate) =>
1082
+ el('button', { class: 'attention-row', onclick: () => openSession(candidate.sessionId) }, [
1083
+ captureView(candidate.capture, candidate.summary, 'thumb'),
1084
+ el('span', { class: 'grow' }, [
1085
+ el('strong', { class: 'truncate', text: candidate.summary }),
1086
+ el('small', {
1087
+ class: 'muted',
1088
+ text: `${candidate.blocking ? 'Failed two times' : capital(candidate.source)} · ${relativeTime(candidate.createdAt)}`,
1089
+ }),
1090
+ ]),
1091
+ severityChip(candidate.severity),
1092
+ ]),
1093
+ );
1094
+ return el('section', { class: 'card panel' }, [
1095
+ title('Needs attention', `${number(issues.open)} open · ${number(waiting.total)} wait for the judge`),
1096
+ issueRows.length || waitingRows.length
1097
+ ? null
1098
+ : emptyState({
1099
+ heading: tested ? 'Nothing needs attention' : 'Not tested yet',
1100
+ why: tested
1101
+ ? 'No issue is open, and no finding waits for the judge.'
1102
+ : 'A patrol tests the project. The explorer reports findings, and the judge files the real problems as issues.',
1103
+ command: cli('patrol --once'),
1104
+ compact: true,
1105
+ }),
1106
+ issueRows.length ? el('div', { class: 'stack' }, issueRows) : null,
1107
+ waitingRows.length
1108
+ ? el('div', { class: 'subsection' }, [
1109
+ el('h3', { text: `Findings that wait for the judge (${number(waiting.total)})` }),
1110
+ callout(
1111
+ 'The judge reads each finding and files the real problems as issues. Run the judge now:',
1112
+ cli('patrol --once'),
1113
+ ),
1114
+ el('div', { class: 'stack' }, waitingRows),
1115
+ waiting.total > waitingRows.length
1116
+ ? el('button', {
1117
+ type: 'button',
1118
+ class: 'link',
1119
+ text: `Show all ${number(waiting.total)} findings in Activity`,
1120
+ onclick: () => go('activity'),
728
1121
  })
729
1122
  : null,
730
- ]),
1123
+ ])
1124
+ : null,
731
1125
  ]);
732
- const coverage = el('section', { class: 'card panel coverage' }, [
733
- title('Coverage', `${data.counts.screens} screens found`),
1126
+ }
1127
+
1128
+ function renderLive(live) {
1129
+ return el('section', { class: 'card panel' }, [
1130
+ title('Live', 'Session running'),
1131
+ captureFile(live.capture, 'Newest session capture', 'live-shot'),
1132
+ el('div', { class: 'event-list' }, live.events.map(renderEventRow)),
1133
+ ]);
1134
+ }
1135
+
1136
+ /** One coverage block for each surface: the endpoints of an API, the screens of a client app. */
1137
+ function renderSurfaceCoverage(data, summary) {
1138
+ const blocks = summary.surfaces.map((surface) =>
1139
+ el('div', { class: 'surface-block' }, [
1140
+ el('div', { class: 'surface-head' }, [
1141
+ el('h3', { text: surfaceName(surface) }),
1142
+ surface.configs[0] ? el('span', { class: 'muted mono', text: fileName(surface.configs[0]) }) : null,
1143
+ ]),
1144
+ surface.kind === 'api'
1145
+ ? apiCoverageBlock(summary.coverage.api, surface)
1146
+ : screenCoverageBlock(data, summary, surface),
1147
+ ]),
1148
+ );
1149
+ return el('section', { class: 'card panel block' }, [
1150
+ title('Coverage', summary.surfaces.length > 1 ? 'For each surface' : ''),
734
1151
  data.github ? el('div', { class: 'muted github-summary', text: `GitHub · ${data.github}` }) : null,
1152
+ blocks.length
1153
+ ? el('div', { class: 'surface-list' }, blocks)
1154
+ : emptyState({
1155
+ heading: 'No config yet',
1156
+ why: 'Bugpatrol reads the config in .bugpatrol/ to know what to test.',
1157
+ command: 'bugpatrol init',
1158
+ compact: true,
1159
+ }),
1160
+ ]);
1161
+ }
1162
+
1163
+ function apiCoverageBlock(api, surface) {
1164
+ if (!api)
1165
+ return emptyState({
1166
+ heading: 'No sweep yet',
1167
+ why: 'The sweep sends each case of each endpoint and checks the answer against the spec.',
1168
+ command: cli('sweep', surface),
1169
+ compact: true,
1170
+ });
1171
+ return el('button', { type: 'button', class: 'surface-numbers', onclick: () => go('coverage') }, [
1172
+ el('div', { class: 'surface-line' }, [
1173
+ el('strong', { class: 'big', text: percent(api.tested, api.endpoints) }),
1174
+ el('span', { text: `of the endpoints tested: ${number(api.tested)} of ${number(api.endpoints)}` }),
1175
+ ]),
1176
+ caseBar(api.cases),
1177
+ caseLegend(api.cases),
1178
+ api.remaining
1179
+ ? el('small', { class: 'muted', text: `${number(api.remaining)} cases wait for the next sweep.` })
1180
+ : null,
1181
+ ]);
1182
+ }
1183
+
1184
+ function screenCoverageBlock(data, summary, surface) {
1185
+ const screens = summary.coverage.screens;
1186
+ if (!screens?.found)
1187
+ return emptyState({
1188
+ heading: 'No screens yet',
1189
+ why: surface.configs[0]
1190
+ ? `${fileName(surface.configs[0])} tests the ${surfaceName(surface).toLowerCase()}. The explorer finds its screens when it runs.`
1191
+ : 'The explorer finds the screens when it runs.',
1192
+ command: cli('explore', surface),
1193
+ compact: true,
1194
+ });
1195
+ return el('div', {}, [
1196
+ el('div', { class: 'surface-line' }, [
1197
+ el('strong', { class: 'big', text: number(screens.found) }),
1198
+ el('span', {
1199
+ text: `screens found · ${number(screens.replayable)} with a routine · ${number(screens.withIssues)} with open issues`,
1200
+ }),
1201
+ ]),
735
1202
  el(
736
1203
  'div',
737
1204
  { class: 'coverage-row' },
738
1205
  data.screens.map((screen) =>
739
1206
  el('button', { class: 'coverage-item', onclick: () => openScreen(screen.id) }, [
740
- image(screen.lastScreenshot, screen.name),
1207
+ captureFile(screen.lastCapture, screen.name),
741
1208
  el('span', { text: screen.name }),
742
1209
  ]),
743
1210
  ),
744
1211
  ),
745
1212
  ]);
746
- return el('div', {}, [
747
- agents,
748
- el('div', { class: 'overview-grid' }, [attention, livePanel]),
749
- coverage,
750
- renderUsage(data.usage),
1213
+ }
1214
+
1215
+ function renderRecentSessions(data) {
1216
+ const next = data.patrol?.nextAt
1217
+ ? `Next patrol at ${new Date(data.patrol.nextAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
1218
+ : data.live
1219
+ ? ''
1220
+ : 'Quiet now';
1221
+ return el('section', { class: 'card panel' }, [
1222
+ title('Recent sessions', next),
1223
+ data.recentSessions.length
1224
+ ? el(
1225
+ 'div',
1226
+ { class: 'stack' },
1227
+ data.recentSessions.map((session) =>
1228
+ el('button', { class: 'list-row', onclick: () => openSession(session.id) }, [
1229
+ roleIcon(session.role),
1230
+ el('span', { class: 'grow' }, [
1231
+ el('strong', {
1232
+ class: 'truncate',
1233
+ text: firstSentence(session.summary) || `${capital(session.role)} session`,
1234
+ }),
1235
+ el('small', {
1236
+ class: 'muted',
1237
+ text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)}`,
1238
+ }),
1239
+ ]),
1240
+ el('span', { class: `badge ${SESSION_BADGE[session.status] ?? 'info'}`, text: session.status }),
1241
+ ]),
1242
+ ),
1243
+ )
1244
+ : emptyState({
1245
+ heading: 'No sessions yet',
1246
+ why: 'Each run of an agent is a session.',
1247
+ command: cli('patrol --once'),
1248
+ compact: true,
1249
+ }),
751
1250
  ]);
752
1251
  }
753
1252
 
1253
+ const SESSION_BADGE = { running: 'warn', failed: 'fail', finished: 'info' };
1254
+
1255
+ /** Where an issue shows: its screen, or for an API issue, its request. */
1256
+ function issueWhere(issue) {
1257
+ const request = issue.evidence?.capture?.kind === 'http' ? issue.evidence.capture.exchange?.request : undefined;
1258
+ return !issue.screenId && request ? `${request.method} ${request.path}` : screenName(issue.screenId);
1259
+ }
1260
+
754
1261
  function screenName(id) {
755
1262
  return (
756
1263
  state.appmap?.screens.find((screen) => screen.id === id)?.name ||
@@ -771,6 +1278,25 @@ async function openIssue(id) {
771
1278
  }
772
1279
 
773
1280
  function renderIssues() {
1281
+ if (!state.issues.length) {
1282
+ const { waiting, verdict } = state.summary;
1283
+ return el('section', { class: 'card panel' }, [
1284
+ title('Issues'),
1285
+ emptyState({
1286
+ heading: 'No issues yet',
1287
+ why: [
1288
+ 'An issue is a problem that the judge confirmed, with its evidence, its steps, and its fix.',
1289
+ waiting.total
1290
+ ? `${number(waiting.total)} findings wait for the judge now. The judge files the real problems as issues.`
1291
+ : verdict.tone === 'info'
1292
+ ? 'Bugpatrol did not test this project yet.'
1293
+ : 'The judge found no real problem yet.',
1294
+ ],
1295
+ command: cli('patrol --once'),
1296
+ action: waiting.total ? { label: 'Show the findings', onclick: () => go('activity') } : undefined,
1297
+ }),
1298
+ ]);
1299
+ }
774
1300
  const filters = ['open', 'fixed', 'dismissed', 'all'];
775
1301
  const filtered = visibleIssues();
776
1302
  const list = el('div', { class: 'card panel' }, [
@@ -789,6 +1315,9 @@ function renderIssues() {
789
1315
  }),
790
1316
  ),
791
1317
  ),
1318
+ filtered.length
1319
+ ? null
1320
+ : el('p', { class: 'muted', text: `No issue is ${state.issueFilter}. Select a different filter.` }),
792
1321
  ...filtered.map((issue) =>
793
1322
  el(
794
1323
  'button',
@@ -800,7 +1329,7 @@ function renderIssues() {
800
1329
  el('span', { class: 'row-meta' }, [
801
1330
  el('small', {
802
1331
  class: 'muted',
803
- text: `${screenName(issue.screenId)} · ${relativeTime(issue.lastSeenAt)}`,
1332
+ text: `${issueWhere(issue)} · ${relativeTime(issue.lastSeenAt)}`,
804
1333
  }),
805
1334
  ...issueChips(issue),
806
1335
  ]),
@@ -826,15 +1355,24 @@ function visibleIssues() {
826
1355
  function renderIssueDetail() {
827
1356
  const detail = state.issueDetail;
828
1357
  if (!detail) return el('section', { class: 'card panel empty', text: 'Select an issue.' });
829
- const { issue, fix, candidates } = detail;
1358
+ const { issue, fix, candidates, gaveUpAfter, attemptDiffs = {} } = detail;
830
1359
  const evidence = issue.evidence ?? {};
831
- const shots = [
832
- ['Screenshot', evidence.screenshot],
833
- ['Baseline', evidence.baseline],
834
- ['Diff', evidence.diff],
835
- ]
836
- .filter(([, path]) => path)
837
- .map(([label, path]) => el('figure', {}, [image(path, label), el('figcaption', { text: label })]));
1360
+ const capture = evidence.capture;
1361
+ const shots =
1362
+ capture?.kind === 'http'
1363
+ ? [
1364
+ el('figure', { class: 'http-evidence' }, [
1365
+ captureView(capture, 'HTTP exchange'),
1366
+ el('figcaption', { text: 'HTTP exchange' }),
1367
+ ]),
1368
+ ]
1369
+ : [
1370
+ ['Screenshot', capture?.screenshot],
1371
+ ['Baseline', capture?.baseline],
1372
+ ['Diff', capture?.diff],
1373
+ ]
1374
+ .filter(([, path]) => path)
1375
+ .map(([label, path]) => el('figure', {}, [captureFile(path, label), el('figcaption', { text: label })]));
838
1376
  const steps = evidence.steps ?? detail.routine?.steps ?? [];
839
1377
  return el('article', { class: 'card panel issue-detail' }, [
840
1378
  el('div', { class: 'detail-heading' }, [el('h1', { text: issue.title }), severityChip(issue.severity)]),
@@ -854,7 +1392,7 @@ function renderIssueDetail() {
854
1392
  : null,
855
1393
  el('div', { class: 'kv' }, [
856
1394
  el('span', { text: issueStatus(issue, fix) }),
857
- el('span', { text: screenName(issue.screenId) }),
1395
+ el('span', { text: issueWhere(issue) }),
858
1396
  el('span', { text: `×${issue.occurrences}` }),
859
1397
  el('span', { text: `First ${relativeTime(issue.firstSeenAt)}` }),
860
1398
  el('span', { text: `Last ${relativeTime(issue.lastSeenAt)}` }),
@@ -888,21 +1426,26 @@ function renderIssueDetail() {
888
1426
  fix
889
1427
  ? el('section', { class: 'fix' }, [
890
1428
  el('div', { class: 'section-title' }, [el('h2', { text: 'Proposed fix' }), fixBadge(fix)]),
1429
+ gaveUpAfter
1430
+ ? el('p', { class: 'gave-up', text: `The fixer stopped after ${gaveUpAfter} fix attempts.` })
1431
+ : null,
891
1432
  markdown(fix.summary || 'No summary yet.'),
892
- ...(fix.retests?.length
893
- ? [
894
- retestBlock(fix.retests.at(-1), issue),
895
- fix.retests.length > 1
896
- ? el('details', { class: 'earlier-retests' }, [
897
- el('summary', { text: `Earlier attempts (${fix.retests.length - 1})` }),
898
- ...fix.retests
899
- .slice(0, -1)
900
- .reverse()
901
- .map((retest) => retestBlock(retest, issue)),
902
- ])
903
- : null,
904
- ]
905
- : []),
1433
+ ...(fix.attempts?.length
1434
+ ? []
1435
+ : fix.retests?.length
1436
+ ? [
1437
+ retestBlock(fix.retests.at(-1), issue),
1438
+ fix.retests.length > 1
1439
+ ? el('details', { class: 'earlier-retests' }, [
1440
+ el('summary', { text: `Earlier retests (${fix.retests.length - 1})` }),
1441
+ ...fix.retests
1442
+ .slice(0, -1)
1443
+ .reverse()
1444
+ .map((retest) => retestBlock(retest, issue)),
1445
+ ])
1446
+ : null,
1447
+ ]
1448
+ : []),
906
1449
  title('Code change'),
907
1450
  fix.diffStat ? el('pre', { text: fix.diffStat }) : null,
908
1451
  // Collapsed: the verdict and the screenshots come first; the diff is
@@ -919,6 +1462,9 @@ function renderIssueDetail() {
919
1462
  ? el('span', { text: `Worktree removed ${new Date(fix.worktreeRemovedAt).toLocaleString()}` })
920
1463
  : el('code', { text: fix.worktree }),
921
1464
  ]),
1465
+ ...(fix.attempts?.length
1466
+ ? [title('Fix attempts', 'oldest first'), attemptTimeline(fix, issue, attemptDiffs)]
1467
+ : []),
922
1468
  ])
923
1469
  : null,
924
1470
  ]);
@@ -939,6 +1485,7 @@ function firstSentence(text) {
939
1485
  function issueStatus(issue, fix) {
940
1486
  if (issue.status === 'dismissed') return issue.closedBy ? `Dismissed by ${issue.closedBy.by}` : 'Dismissed';
941
1487
  if (issue.status === 'fixed') return issue.closedBy ? `Fixed: ${issue.closedBy.reason}` : 'Fixed';
1488
+ if (issue.status === 'waits-for-deploy') return 'Merged, waits for a deploy';
942
1489
  if (fix?.status === 'rejected' || issue.fixRejected) return 'Fix rejected by the team';
943
1490
  if (issue.status === 'new' && issue.regression) return 'Regression';
944
1491
  if (fix?.status === 'verified') return 'Fix verified in the app';
@@ -960,6 +1507,7 @@ function stepWords(step) {
960
1507
  if (step.kind === 'open') return `Open ${step.url}`;
961
1508
  if (step.kind === 'wait') return `Wait ${step.ms} ms`;
962
1509
  if (step.kind === 'window') return `Switch to window "${step.match}"`;
1510
+ if (step.kind === 'upload') return `Upload a sample ${step.file} file to "${target}"`;
963
1511
  return step.kind === 'back' ? 'Go back' : step.kind;
964
1512
  }
965
1513
 
@@ -1015,17 +1563,93 @@ function fixBadge(fix) {
1015
1563
  return el('span', { class: `badge ${kind}`, text: label });
1016
1564
  }
1017
1565
 
1566
+ const ATTEMPT_KINDS = { first: 'First fix', rerun: 'Rerun', refix: 'Refix', ci: 'CI fix' };
1567
+ const ATTEMPT_OUTCOMES = {
1568
+ proposed: ['Proposed', 'pass'],
1569
+ 'no-change': ['No change', 'warn'],
1570
+ declined: ['Declined', 'warn'],
1571
+ 'verify-failed': ['Verify failed', 'fail'],
1572
+ error: ['Error', 'fail'],
1573
+ timeout: ['Timed out', 'fail'],
1574
+ abandoned: ['Abandoned', 'fail'],
1575
+ };
1576
+
1577
+ /** Each fix attempt, oldest first, with the retests that judged it. */
1578
+ function attemptTimeline(fix, issue, attemptDiffs) {
1579
+ const last = fix.attempts.at(-1).n;
1580
+ const judged = (n) =>
1581
+ (fix.retests ?? []).filter((retest) =>
1582
+ // A retest from before attempts were recorded, or one naming an unknown attempt, judged the last one.
1583
+ fix.attempts.some((attempt) => attempt.n === retest.fixAttempt) ? retest.fixAttempt === n : n === last,
1584
+ );
1585
+ return el(
1586
+ 'ol',
1587
+ { class: 'attempt-timeline' },
1588
+ fix.attempts.map((attempt) => {
1589
+ const [outcome, kind] = attempt.outcome
1590
+ ? (ATTEMPT_OUTCOMES[attempt.outcome] ?? [attempt.outcome, 'info'])
1591
+ : ['Running…', 'info'];
1592
+ return el('li', { class: 'attempt' }, [
1593
+ el('div', { class: 'detail-heading' }, [
1594
+ el('h3', { text: `Attempt ${attempt.n}: ${ATTEMPT_KINDS[attempt.kind] ?? attempt.kind}` }),
1595
+ el('span', { class: `badge ${kind}`, text: outcome }),
1596
+ ]),
1597
+ el('div', { class: 'kv' }, [
1598
+ el('span', { text: relativeTime(attempt.startedAt) }),
1599
+ attempt.costUsd !== undefined ? el('span', { text: money(attempt.costUsd) }) : null,
1600
+ ]),
1601
+ attempt.reason ? markdown(attempt.reason) : null,
1602
+ attempt.diffStat ? el('pre', { text: attempt.diffStat }) : null,
1603
+ attemptDiffs[attempt.n] ? lazyDiff(attemptDiffs[attempt.n]) : null,
1604
+ attempt.verifyOutput
1605
+ ? el('details', { class: 'diff-details' }, [
1606
+ el('summary', { text: 'Show the verify output' }),
1607
+ el('pre', { text: attempt.verifyOutput }),
1608
+ ])
1609
+ : null,
1610
+ ...judged(attempt.n).map((retest) => retestBlock(retest, issue)),
1611
+ ]);
1612
+ }),
1613
+ );
1614
+ }
1615
+
1616
+ /** A diff file that loads the first time the reader opens it. */
1617
+ function lazyDiff(path) {
1618
+ const body = el('div', { class: 'muted', text: 'Loading…' });
1619
+ let loaded = false;
1620
+ return el(
1621
+ 'details',
1622
+ {
1623
+ class: 'diff-details',
1624
+ ontoggle: (event) => {
1625
+ if (!event.target.open || loaded) return;
1626
+ loaded = true;
1627
+ fetchText(path)
1628
+ .then((diff) => body.replaceWith(diffBlock(diff)))
1629
+ .catch((error) => {
1630
+ body.textContent = `Could not read the diff: ${error.message}`;
1631
+ });
1632
+ },
1633
+ },
1634
+ [el('summary', { text: 'Show the diff' }), body],
1635
+ );
1636
+ }
1637
+
1018
1638
  function retestBlock(retest, issue) {
1019
- const before = retest.before ?? issue.evidence?.screenshot;
1639
+ const capture = issue.evidence?.capture;
1640
+ const before = retest.before ?? capture?.screenshot;
1641
+ // An API issue has no before file: the reported exchange is the before.
1642
+ const beforeView = (path) =>
1643
+ !path && capture?.kind === 'http' ? captureView(capture, 'Before') : captureFile(path, 'Before');
1020
1644
  const pair = (shot) =>
1021
1645
  el('div', { class: 'retest-shot-pair' }, [
1022
- retest.shots?.length ? el('h4', { text: screenName(shot.screenId) }) : null,
1646
+ retest.captures?.length ? el('h4', { text: screenName(shot.screenId) }) : null,
1023
1647
  shot.reached === false ? el('span', { class: 'muted', text: 'Not reached' }) : null,
1024
1648
  shot.note ? el('p', { class: 'muted', text: shot.note }) : null,
1025
1649
  el('div', { class: 'retest-shots' }, [
1026
- el('figure', {}, [image(shot.before, 'Before'), el('figcaption', { text: 'Before' })]),
1650
+ el('figure', {}, [beforeView(shot.before), el('figcaption', { text: 'Before' })]),
1027
1651
  el('figure', {}, [
1028
- image(shot.after, 'After (with the fix)'),
1652
+ captureFile(shot.after, 'After (with the fix)'),
1029
1653
  el('figcaption', { text: 'After (with the fix)' }),
1030
1654
  ]),
1031
1655
  ]),
@@ -1033,8 +1657,8 @@ function retestBlock(retest, issue) {
1033
1657
  return el('div', { class: 'retest-block' }, [
1034
1658
  el('h3', { text: `Retest ${retest.attempt}: ${retest.outcome}` }),
1035
1659
  el('p', { text: retest.reason }),
1036
- retest.shots?.length
1037
- ? retest.shots.map((shot) => pair(shot))
1660
+ retest.captures?.length
1661
+ ? retest.captures.map((shot) => pair(shot))
1038
1662
  : pair({ before, after: retest.after, note: retest.note }),
1039
1663
  ]);
1040
1664
  }
@@ -1087,9 +1711,9 @@ function inlineMarkdown(line) {
1087
1711
  return parts;
1088
1712
  }
1089
1713
 
1090
- function renderActivity() {
1091
- const list = el('section', { class: 'card panel' }, [
1092
- title('Activity'),
1714
+ function sessionList(heading) {
1715
+ return el('section', { class: 'card panel' }, [
1716
+ title(heading, `${state.sessions.length}`),
1093
1717
  ...state.sessions.map((session) =>
1094
1718
  el(
1095
1719
  'button',
@@ -1106,59 +1730,228 @@ function renderActivity() {
1106
1730
  el('strong', { text: firstSentence(session.summary) || `${capital(session.role)} session` }),
1107
1731
  el('small', {
1108
1732
  class: 'muted',
1109
- text:
1110
- `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)} · ${session.steps} steps · ` +
1111
- `${session.tokens ? `${tokens(session.tokens)} · ` : ''}API ${money(session.costUsd)} · ` +
1112
- `${session.screensFound.length} screens · ${session.issues.length} issues`,
1733
+ text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)}`,
1113
1734
  }),
1114
1735
  ]),
1736
+ session.status === 'finished'
1737
+ ? null
1738
+ : el('span', { class: `badge ${SESSION_BADGE[session.status]}`, text: session.status }),
1115
1739
  ],
1116
1740
  ),
1117
1741
  ),
1118
1742
  ]);
1743
+ }
1744
+
1745
+ function noSessions(page) {
1746
+ return el('section', { class: 'card panel' }, [
1747
+ title(page),
1748
+ emptyState({
1749
+ heading: 'No sessions yet',
1750
+ why: [
1751
+ page === 'Flow'
1752
+ ? 'Flow puts the actions of a session, the requests that failed, and the backend logs in one timeline.'
1753
+ : 'Activity shows each agent session: what it tested, what it found, and what the judge decided.',
1754
+ 'No agent ran in this project yet.',
1755
+ ],
1756
+ command: cli('patrol --once'),
1757
+ }),
1758
+ ]);
1759
+ }
1760
+
1761
+ function renderActivity() {
1762
+ if (!state.sessions.length) return noSessions('Activity');
1119
1763
  const detail = state.sessionDetail;
1120
- const timeline = detail
1121
- ? el('section', { class: 'card panel' }, [
1122
- title('Timeline', `${capital(detail.session.role)} · ${relativeTime(detail.session.startedAt)}`),
1123
- detail.session.summary ? el('div', { class: 'session-summary' }, [markdown(detail.session.summary)]) : null,
1124
- el(
1125
- 'div',
1126
- { class: 'muted session-usage' },
1127
- detail.session.tokensByModel
1128
- ? Object.entries(detail.session.tokensByModel).map(([model, usage]) =>
1129
- el('div', { title: tokenDetail(usage), text: `${model}: ${tokens(usage)}` }),
1130
- )
1131
- : [el('div', { text: 'No token usage reported for this session.' })],
1132
- ),
1133
- el('label', { class: 'toggle' }, [
1134
- el('input', {
1135
- type: 'checkbox',
1136
- ...(state.showThoughts ? { checked: '' } : {}),
1137
- onchange: (event) => {
1138
- state.showThoughts = event.target.checked;
1139
- render();
1140
- },
1141
- }),
1142
- el('span', { text: 'Show agent reasoning' }),
1143
- ]),
1144
- el(
1145
- 'div',
1146
- { class: 'event-list' },
1147
- detail.events.filter((event) => state.showThoughts || isFeedEvent(event)).map(renderEventRow),
1148
- ),
1149
- detail.candidates.length ? title('Candidates') : null,
1150
- ...detail.candidates.map((candidate) =>
1151
- el('div', { class: 'candidate' }, [
1152
- el('strong', { text: candidate.summary }),
1153
- el('span', {
1154
- class: 'muted',
1155
- text: `${candidate.route?.to || 'unrouted'} · ${candidate.route?.reason || ''}`,
1764
+ return el('div', { class: 'master-detail' }, [
1765
+ sessionList('Sessions'),
1766
+ detail ? renderSessionDetail(detail) : el('section', { class: 'card panel empty', text: 'Select a session.' }),
1767
+ ]);
1768
+ }
1769
+
1770
+ // Most findings of a sweep wait for the judge, so that state is quiet text and the others are badges.
1771
+ const CANDIDATE_STATES = {
1772
+ waiting: ['', 'Waits for the judge'],
1773
+ filed: ['fail', 'Filed as an issue'],
1774
+ dismissed: ['info', 'Dismissed'],
1775
+ ignored: ['info', 'Not sent to the judge'],
1776
+ };
1777
+
1778
+ /** One session: its numbers first, then its findings by failure, then its events. */
1779
+ function renderSessionDetail(detail) {
1780
+ const { session, report, candidates, events } = detail;
1781
+ const { findings, sweep } = report;
1782
+ const cases = sweep ? sweep.pass + sweep.fail + sweep.blocked : 0;
1783
+ const byId = new Map(candidates.map((candidate) => [candidate.id, candidate]));
1784
+ const agentEvents = events.some((event) => event.kind !== 'candidate');
1785
+ const timeline = el(
1786
+ 'div',
1787
+ { class: 'event-list' },
1788
+ events.filter((event) => state.showThoughts || isFeedEvent(event)).map(renderEventRow),
1789
+ );
1790
+ return el('section', { class: 'card panel session-detail' }, [
1791
+ title(
1792
+ firstSentence(session.summary) || `${capital(session.role)} session`,
1793
+ `${capital(session.role)} · ${formatTime(session.startedAt)}`,
1794
+ ),
1795
+ statBand([
1796
+ { label: 'Duration', value: session.status === 'running' ? 'Running' : formatDuration(report.durationMs) },
1797
+ sweep
1798
+ ? {
1799
+ label: 'Cases in this session',
1800
+ value: number(cases),
1801
+ note: `${number(sweep.endpoints)} endpoints tested · ${number(sweep.blocked)} blocked`,
1802
+ }
1803
+ : null,
1804
+ sweep
1805
+ ? {
1806
+ label: 'Pass · fail · blocked',
1807
+ value: `${number(sweep.pass)} · ${number(sweep.fail)} · ${number(sweep.blocked)}`,
1808
+ bar: caseBar(sweep),
1809
+ }
1810
+ : null,
1811
+ report.steps || !sweep
1812
+ ? { label: 'Steps', value: number(report.steps), note: `${report.screens.length} screens found` }
1813
+ : null,
1814
+ {
1815
+ label: 'Findings',
1816
+ value: number(findings.total),
1817
+ note: sweep
1818
+ ? `${number(findings.repeat)} failed two times`
1819
+ : `${number(findings.filed)} filed as ${findings.filed === 1 ? 'an issue' : 'issues'}`,
1820
+ },
1821
+ {
1822
+ label: 'Wait for the judge',
1823
+ value: number(findings.waiting),
1824
+ note: `${number(findings.filed)} filed · ${number(findings.dismissed)} dismissed`,
1825
+ tone: findings.waiting ? 'warn' : undefined,
1826
+ },
1827
+ session.tokens
1828
+ ? {
1829
+ label: 'Tokens',
1830
+ value: count(session.tokens.input + session.tokens.output),
1831
+ note: `API ${money(session.costUsd)}`,
1832
+ }
1833
+ : null,
1834
+ ]),
1835
+ findings.waiting
1836
+ ? callout(
1837
+ `The judge did not read ${number(findings.waiting)} findings of this session yet. Run the judge:`,
1838
+ cli('patrol --once'),
1839
+ )
1840
+ : null,
1841
+ report.groups.length
1842
+ ? el('section', { class: 'subsection' }, [
1843
+ title(
1844
+ 'Findings',
1845
+ `${number(findings.total)} in ${report.groups.length} ${report.groups.length === 1 ? 'group' : 'groups'}`,
1846
+ ),
1847
+ ...report.groups.map((group) => findingGroup(group, byId)),
1848
+ ])
1849
+ : el('p', { class: 'muted', text: 'This session reported no finding.' }),
1850
+ countChips('Findings by case', report.byCase),
1851
+ countChips('Findings by check', report.byCheck),
1852
+ countChips(
1853
+ 'Findings by screen',
1854
+ report.byScreen.map(({ label, count: n }) => ({ label: screenName(label), count: n })),
1855
+ ),
1856
+ // A sweep uses no model, so it has no usage to show.
1857
+ session.tokensByModel
1858
+ ? el('section', { class: 'subsection' }, [
1859
+ title('Usage'),
1860
+ el(
1861
+ 'div',
1862
+ { class: 'muted session-usage' },
1863
+ Object.entries(session.tokensByModel).map(([model, usage]) =>
1864
+ el('div', { title: tokenDetail(usage), text: `${model}: ${tokens(usage)}` }),
1865
+ ),
1866
+ ),
1867
+ ])
1868
+ : null,
1869
+ // A sweep has no actions: its events repeat its findings, so they stay one click away.
1870
+ agentEvents
1871
+ ? el('section', { class: 'subsection' }, [
1872
+ title(
1873
+ 'Timeline',
1874
+ state.showThoughts
1875
+ ? `${events.length} events`
1876
+ : `${events.filter(isFeedEvent).length} of ${events.length} events`,
1877
+ ),
1878
+ // The heading shows the first sentence. A longer summary shows here in full.
1879
+ session.summary && firstSentence(session.summary) !== session.summary.trim()
1880
+ ? el('div', { class: 'session-summary' }, [markdown(session.summary)])
1881
+ : null,
1882
+ el('label', { class: 'toggle' }, [
1883
+ el('input', {
1884
+ type: 'checkbox',
1885
+ ...(state.showThoughts ? { checked: '' } : {}),
1886
+ onchange: (event) => {
1887
+ state.showThoughts = event.target.checked;
1888
+ render();
1889
+ },
1156
1890
  }),
1891
+ el('span', { text: 'Show agent reasoning' }),
1157
1892
  ]),
1158
- ),
1159
- ])
1160
- : el('section', { class: 'card panel empty', text: 'Select a session.' });
1161
- return el('div', { class: 'master-detail' }, [list, timeline]);
1893
+ timeline,
1894
+ ])
1895
+ : el('details', { class: 'subsection raw-events' }, [
1896
+ el('summary', { text: `All events (${events.length})` }),
1897
+ timeline,
1898
+ ]),
1899
+ ]);
1900
+ }
1901
+
1902
+ function countChips(heading, counts) {
1903
+ if (!counts.length) return null;
1904
+ return el('section', { class: 'subsection' }, [
1905
+ title(heading),
1906
+ el(
1907
+ 'div',
1908
+ { class: 'chips' },
1909
+ counts.map(({ label, count: n }) =>
1910
+ el('span', { class: 'chip' }, [el('span', { text: label }), el('strong', { text: number(n) })]),
1911
+ ),
1912
+ ),
1913
+ ]);
1914
+ }
1915
+
1916
+ /** A group of findings with the same failure. Each finding opens to its evidence. */
1917
+ function findingGroup(group, byId) {
1918
+ const members = group.candidateIds.map((id) => byId.get(id)).filter(Boolean);
1919
+ return el('details', { class: 'finding-group' }, [
1920
+ el('summary', {}, [
1921
+ el('span', { class: 'group-label', text: capital(group.label) }),
1922
+ ...group.assertions.map((id) => el('code', { class: 'assertion', text: id })),
1923
+ el('span', {
1924
+ class: 'muted group-repeat',
1925
+ text: group.repeat && group.repeat < group.count ? `${group.repeat} of ${group.count} failed two times` : '',
1926
+ }),
1927
+ el('strong', { class: 'group-count', text: number(group.count) }),
1928
+ ]),
1929
+ ...members.map((candidate) => {
1930
+ const [badge, words] = CANDIDATE_STATES[candidate.state] ?? ['info', candidate.state];
1931
+ const at = candidate.source === 'sweep' ? candidate.summary.indexOf(': ') : -1;
1932
+ const capture = candidate.evidence?.capture;
1933
+ return el('details', { class: 'candidate-row' }, [
1934
+ el('summary', {}, [
1935
+ el('span', { class: 'grow truncate', text: at > 0 ? candidate.summary.slice(0, at) : candidate.summary }),
1936
+ candidate.state === 'filed' && candidate.issueId
1937
+ ? el('button', {
1938
+ type: 'button',
1939
+ class: `badge ${badge}`,
1940
+ text: words,
1941
+ onclick: (event) => {
1942
+ event.preventDefault();
1943
+ openIssue(candidate.issueId);
1944
+ },
1945
+ })
1946
+ : el('span', { class: badge ? `badge ${badge}` : 'muted state-words', text: words }),
1947
+ ]),
1948
+ candidate.route?.reason ? el('p', { class: 'muted', text: candidate.route.reason }) : null,
1949
+ capture?.kind === 'http'
1950
+ ? el('pre', { class: 'http', text: candidate.text })
1951
+ : captureFile(capture?.screenshot, candidate.summary, 'candidate-shot'),
1952
+ ]);
1953
+ }),
1954
+ ]);
1162
1955
  }
1163
1956
 
1164
1957
  function duration(session) {
@@ -1179,7 +1972,7 @@ function renderEventRow(event) {
1179
1972
  text: tokens(event.tokens, ''),
1180
1973
  })
1181
1974
  : null,
1182
- event.screenshot ? image(event.screenshot, event.summary, 'event-thumb') : null,
1975
+ event.capture ? captureFile(event.capture, event.summary, 'event-thumb') : null,
1183
1976
  event.input !== undefined || event.output !== undefined
1184
1977
  ? el('details', {}, [
1185
1978
  el('summary', { text: 'Tool data' }),
@@ -1193,52 +1986,17 @@ function renderEventRow(event) {
1193
1986
  // failed requests the app reported, and the backend logs that followed, merged
1194
1987
  // in time order. A log close behind a failure is marked correlated.
1195
1988
  function renderFlow() {
1196
- const list = el('section', { class: 'card panel' }, [
1197
- title('Flow'),
1198
- ...state.sessions.map((session) =>
1199
- el(
1200
- 'button',
1201
- {
1202
- class: `list-row ${session.id === state.selectedSessionId ? 'active' : ''}`,
1203
- onclick: async () => {
1204
- state.selectedSessionId = session.id;
1205
- await refresh();
1206
- },
1207
- },
1208
- [
1209
- roleIcon(session.role),
1210
- el('span', { class: 'grow' }, [
1211
- el('strong', { text: firstSentence(session.summary) || `${capital(session.role)} session` }),
1212
- el('small', {
1213
- class: 'muted',
1214
- text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)}`,
1215
- }),
1216
- ]),
1217
- ],
1218
- ),
1219
- ),
1220
- ]);
1989
+ if (!state.sessions.length) return noSessions('Flow');
1221
1990
  const flow = state.flow;
1222
- if (!flow) {
1223
- return el('div', { class: 'master-detail' }, [
1224
- list,
1225
- el('section', {
1226
- class: 'card panel empty',
1227
- text: 'No flow for this session. Add a `logs:` source to bugpatrol.yml and run a patrol.',
1228
- }),
1229
- ]);
1230
- }
1991
+ const list = sessionList('Sessions');
1992
+ if (!flow) return el('div', { class: 'master-detail' }, [list, el('section', { class: 'card panel' }, [noFlow()])]);
1231
1993
  const sources = flow.sources.length
1232
1994
  ? flow.sources.map((source) =>
1233
1995
  el('div', {
1234
1996
  text: `${source.name} (${source.kind}): ${source.collected} line${source.collected === 1 ? '' : 's'}`,
1235
1997
  }),
1236
1998
  )
1237
- : [
1238
- el('div', {
1239
- text: 'No log sources configured. Add `logs:` to bugpatrol.yml to see backend logs beside each action.',
1240
- }),
1241
- ];
1999
+ : [el('div', { text: 'The config had no log source for this session, so the flow has no backend logs.' })];
1242
2000
  return el('div', { class: 'master-detail' }, [
1243
2001
  list,
1244
2002
  el('section', { class: 'card panel' }, [
@@ -1251,13 +2009,56 @@ function renderFlow() {
1251
2009
  ]);
1252
2010
  }
1253
2011
 
2012
+ const LOGS_EXAMPLE = `logs:
2013
+ - name: api
2014
+ kind: file
2015
+ path: logs/api.log`;
2016
+
2017
+ /** Why the selected session has no flow, and the next step. */
2018
+ function noFlow() {
2019
+ const summary = state.summary;
2020
+ const session = state.sessionDetail?.session;
2021
+ const report = state.sessionDetail?.report;
2022
+ const what =
2023
+ 'Flow puts the actions of a session, the requests that failed, and the backend logs in one timeline, so you can see which log line follows which request.';
2024
+ if (session?.status === 'running')
2025
+ return emptyState({
2026
+ heading: 'The flow shows when the session ends',
2027
+ why: [what, 'This session runs now. Bugpatrol writes its flow at the end.'],
2028
+ });
2029
+ if (!summary.logs.length)
2030
+ return emptyState({
2031
+ heading: 'No backend logs',
2032
+ why: [
2033
+ what,
2034
+ `${summary.config ? fileName(summary.config) : 'The config'} has no logs: source, so Bugpatrol reads no backend logs. Add one, then run a patrol:`,
2035
+ ],
2036
+ command: LOGS_EXAMPLE,
2037
+ docs: `${DOCS}/dashboard.md#flow`,
2038
+ });
2039
+ if (report?.sweep && !report.steps)
2040
+ return emptyState({
2041
+ heading: 'A sweep has no flow',
2042
+ why: [
2043
+ what,
2044
+ 'This session is a sweep from bugpatrol sweep, which reads no logs. A patrol runs the sweep and reads the logs: sources too.',
2045
+ ],
2046
+ command: cli('patrol --once'),
2047
+ });
2048
+ return emptyState({
2049
+ heading: 'No flow for this session',
2050
+ why: [what, 'This session ran before the config had a logs: source. The next patrol writes a flow.'],
2051
+ command: cli('patrol --once'),
2052
+ });
2053
+ }
2054
+
1254
2055
  function renderFlowRow(entry) {
1255
2056
  return el('div', { class: `event-row flow-row flow-${entry.kind}${entry.correlated ? ' correlated' : ''}` }, [
1256
2057
  el('time', { text: new Date(entry.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }),
1257
2058
  el('span', { class: 'flow-kind', text: entry.kind }),
1258
2059
  el('span', { class: 'grow truncate', text: entry.summary }),
1259
2060
  entry.level ? el('small', { class: 'muted', text: entry.level }) : null,
1260
- entry.screenshot ? image(entry.screenshot, entry.summary, 'event-thumb') : null,
2061
+ entry.capture ? captureFile(entry.capture, entry.summary, 'event-thumb') : null,
1261
2062
  ]);
1262
2063
  }
1263
2064
 
@@ -1267,9 +2068,37 @@ async function openScreen(id) {
1267
2068
  await refresh();
1268
2069
  }
1269
2070
 
2071
+ /** Screens belong to a client app. For an API, the page says so and points to Coverage. */
2072
+ function noScreens() {
2073
+ const clients = surfacesOf('client');
2074
+ const apis = surfacesOf('api');
2075
+ const coverage = { label: 'Open Coverage', onclick: () => go('coverage') };
2076
+ if (!clients.length)
2077
+ return emptyState({
2078
+ heading: 'This project tests an API',
2079
+ why: [
2080
+ 'Screens shows the screens that the explorer finds in a client app, and how they connect.',
2081
+ `${apis[0]?.configs[0] ? fileName(apis[0].configs[0]) : 'The config'} tests an API, which has no screens. Coverage shows its endpoints and cases.`,
2082
+ 'A monorepo can test a client app too: add a config for it in .bugpatrol/.',
2083
+ ],
2084
+ action: coverage,
2085
+ });
2086
+ const client = clients[0];
2087
+ return emptyState({
2088
+ heading: 'No screens yet',
2089
+ why: [
2090
+ 'Screens shows the screens that the explorer finds in a client app, and how they connect.',
2091
+ `${client.configs[0] ? fileName(client.configs[0]) : 'This project'} tests the ${surfaceName(client).toLowerCase()}. The explorer did not find a screen yet.`,
2092
+ apis.length ? 'This project also tests an API. Coverage shows its endpoints.' : '',
2093
+ ].filter(Boolean),
2094
+ command: cli('explore', client),
2095
+ });
2096
+ }
2097
+
1270
2098
  function renderScreens() {
1271
2099
  const graphScreens = state.appmap?.screens ?? [];
1272
2100
  const screens = graphScreens.filter((screen) => !screen.virtual);
2101
+ if (!screens.length) return el('section', { class: 'card panel' }, [title('Screens'), noScreens()]);
1273
2102
  const edges = state.appmap?.edges ?? [];
1274
2103
  const selected = screens.find((screen) => screen.id === state.selectedScreenId);
1275
2104
  if (selected) {
@@ -1287,7 +2116,7 @@ function renderScreens() {
1287
2116
  },
1288
2117
  }),
1289
2118
  title(selected.name, `${selected.visits} visits · ${relativeTime(selected.lastSeenAt)}`),
1290
- image(selected.lastScreenshot, selected.name, 'screen-large'),
2119
+ captureFile(selected.lastCapture, selected.name, 'screen-large'),
1291
2120
  el('p', { text: selected.description }),
1292
2121
  title('Routine'),
1293
2122
  el('p', {
@@ -1338,38 +2167,48 @@ function renderScreens() {
1338
2167
  }),
1339
2168
  ),
1340
2169
  );
2170
+ const apis = surfacesOf('api');
1341
2171
  return el('div', {}, [
1342
2172
  title('Screens', `${screens.length} found`),
2173
+ apis.length
2174
+ ? el('p', { class: 'muted surface-note' }, [
2175
+ `This project also tests an API${apis[0].configs[0] ? ` (${fileName(apis[0].configs[0])})` : ''}. `,
2176
+ el('button', {
2177
+ type: 'button',
2178
+ class: 'link',
2179
+ text: 'Coverage shows its endpoints.',
2180
+ onclick: () => go('coverage'),
2181
+ }),
2182
+ ])
2183
+ : null,
1343
2184
  toggle,
1344
- screens.length === 0
1345
- ? el('div', { class: 'empty', text: 'No screens found yet.' })
1346
- : screenMode === 'graph'
1347
- ? renderScreenGraph(graphScreens, edges)
1348
- : el(
1349
- 'div',
1350
- { class: 'screen-grid' },
1351
- screens.map((screen) =>
1352
- el(
1353
- 'button',
1354
- {
1355
- class: 'card screen-card',
1356
- onclick: () => openScreen(screen.id),
1357
- },
1358
- [
1359
- image(screen.lastScreenshot, screen.name),
1360
- el('div', { class: 'screen-copy' }, [
1361
- el('strong', { text: screen.name }),
1362
- el('p', { class: 'truncate muted', text: screen.description }),
1363
- el('small', {
1364
- class: 'muted',
1365
- text: `${relativeTime(screen.lastSeenAt)} · ${screen.visits} visits`,
1366
- }),
1367
- screen.openIssues ? el('span', { class: 'issue-count', text: `${screen.openIssues} open` }) : null,
1368
- ]),
1369
- ],
1370
- ),
2185
+ screenMode === 'graph'
2186
+ ? renderScreenGraph(graphScreens, edges)
2187
+ : el(
2188
+ 'div',
2189
+ { class: 'screen-grid' },
2190
+ screens.map((screen) =>
2191
+ el(
2192
+ 'button',
2193
+ {
2194
+ class: 'card screen-card',
2195
+ onclick: () => openScreen(screen.id),
2196
+ },
2197
+ [
2198
+ captureFile(screen.lastCapture, screen.name),
2199
+ el('div', { class: 'screen-copy' }, [
2200
+ el('strong', { text: screen.name }),
2201
+ el('p', { class: 'truncate muted', text: screen.description }),
2202
+ el('small', {
2203
+ class: 'muted',
2204
+ text: `${relativeTime(screen.lastSeenAt)} · ${screen.visits} visits`,
2205
+ }),
2206
+ screen.openIssues ? el('span', { class: 'issue-count', text: `${screen.openIssues} open` }) : null,
2207
+ ]),
2208
+ ],
1371
2209
  ),
1372
2210
  ),
2211
+ ),
1373
2212
  ]);
1374
2213
  }
1375
2214
 
@@ -1467,14 +2306,14 @@ function renderScreenGraph(screens, allEdges) {
1467
2306
  },
1468
2307
  });
1469
2308
  card.append(svgEl('rect', { x: node.x, y: node.y, width: node.w, height: node.h, rx: 9, class: 'graph-card' }));
1470
- if (screen.lastScreenshot)
2309
+ if (screen.lastCapture)
1471
2310
  card.append(
1472
2311
  svgEl('image', {
1473
2312
  x: node.x + 5,
1474
2313
  y: node.y + 5,
1475
2314
  width: node.w - 10,
1476
2315
  height: 104,
1477
- href: artifact(screen.lastScreenshot),
2316
+ href: artifact(screen.lastCapture),
1478
2317
  preserveAspectRatio: 'xMidYMin slice',
1479
2318
  }),
1480
2319
  );
@@ -1646,11 +2485,21 @@ async function openReview(number) {
1646
2485
  }
1647
2486
 
1648
2487
  function renderReviews() {
2488
+ if (!state.reviews.length)
2489
+ return el('section', { class: 'card panel' }, [
2490
+ title('Pull request reviews'),
2491
+ emptyState({
2492
+ heading: 'No pull request reviews yet',
2493
+ why: [
2494
+ 'A review tests one pull request in the running app. It checks each claim of the pull request and lists the problems that the pull request adds.',
2495
+ 'Bugpatrol did not review a pull request in this project yet. Give the number of a pull request:',
2496
+ ],
2497
+ command: cli('review <pr>'),
2498
+ docs: `${DOCS}/github.md`,
2499
+ }),
2500
+ ]);
1649
2501
  const list = el('div', { class: 'card panel' }, [
1650
2502
  title('Pull request reviews', `${state.reviews.length}`),
1651
- state.reviews.length
1652
- ? null
1653
- : el('p', { class: 'empty', text: 'No reviews yet. Run bugpatrol review <pr> to review a pull request.' }),
1654
2503
  ...state.reviews.map((review) =>
1655
2504
  el(
1656
2505
  'button',
@@ -1841,7 +2690,9 @@ function buildEvidence(label, replay, recordings) {
1841
2690
  el(
1842
2691
  'div',
1843
2692
  { class: 'shot-strip' },
1844
- shots.map((path, index) => image(path, `${label}: ${index ? `after step ${index}` : 'before the first step'}`)),
2693
+ shots.map((path, index) =>
2694
+ captureFile(path, `${label}: ${index ? `after step ${index}` : 'before the first step'}`),
2695
+ ),
1845
2696
  );
1846
2697
  return el('figure', { class: 'build-evidence' }, [
1847
2698
  pick ? recordingView(pick) : shots.length ? strip() : el('div', { class: 'image-empty', text: 'No capture' }),
@@ -1860,7 +2711,10 @@ function recordingView(rec) {
1860
2711
  el('small', { class: 'muted', text: name }),
1861
2712
  ]);
1862
2713
  if (rec.kind === 'image')
1863
- return el('div', { class: 'recording' }, [image(rec.path, name), el('small', { class: 'muted', text: name })]);
2714
+ return el('div', { class: 'recording' }, [
2715
+ captureFile(rec.path, name),
2716
+ el('small', { class: 'muted', text: name }),
2717
+ ]);
1864
2718
  return castPlayer(rec.path);
1865
2719
  }
1866
2720
 
@@ -1884,8 +2738,7 @@ function castPlayer(path) {
1884
2738
  }, at * 1000);
1885
2739
  });
1886
2740
  });
1887
- fetch(artifact(path))
1888
- .then((response) => (response.ok ? response.text() : Promise.reject(new Error(String(response.status)))))
2741
+ fetchText(path)
1889
2742
  .then((text) => {
1890
2743
  cast = parseCast(text);
1891
2744
  if (!cast) throw new Error('not an asciicast file');
@@ -1908,8 +2761,11 @@ function findingBlock(finding) {
1908
2761
  el('p', { text: finding.reason }),
1909
2762
  finding.head || finding.base
1910
2763
  ? el('div', { class: 'retest-shots' }, [
1911
- el('figure', {}, [image(finding.base, 'Base'), el('figcaption', { text: 'Base' })]),
1912
- el('figure', {}, [image(finding.head, 'This pull request'), el('figcaption', { text: 'This pull request' })]),
2764
+ el('figure', {}, [captureFile(finding.base, 'Base'), el('figcaption', { text: 'Base' })]),
2765
+ el('figure', {}, [
2766
+ captureFile(finding.head, 'This pull request'),
2767
+ el('figcaption', { text: 'This pull request' }),
2768
+ ]),
1913
2769
  ])
1914
2770
  : null,
1915
2771
  finding.baseNote ? el('p', { class: 'muted', text: finding.baseNote }) : null,
@@ -1934,6 +2790,18 @@ function renderMemory() {
1934
2790
  el('span', { class: 'badge info', text: lesson.scope ?? 'app' }),
1935
2791
  el('span', { class: 'muted', text: `${lesson.source} · ×${lesson.hits} · ${relativeTime(lesson.lastSeenAt)}` }),
1936
2792
  ]);
2793
+ if (!lessons.length)
2794
+ return el('section', { class: 'card panel' }, [
2795
+ title('Memory'),
2796
+ emptyState({
2797
+ heading: 'No lessons yet',
2798
+ why: [
2799
+ 'A lesson is a fact about the app that an agent keeps for its next sessions, for example how to sign in.',
2800
+ 'The agents save lessons when they learn something. You can also add one:',
2801
+ ],
2802
+ command: cli('memory add --role explorer "text"'),
2803
+ }),
2804
+ ]);
1937
2805
  return el('section', { class: 'card panel' }, [
1938
2806
  title('Memory'),
1939
2807
  el('p', { class: 'muted', text: 'Use bugpatrol memory to edit lessons.' }),
@@ -1950,6 +2818,311 @@ function renderMemory() {
1950
2818
  ]);
1951
2819
  }
1952
2820
 
2821
+ const CASE_WORDS = {
2822
+ ok: ['2xx', 'The request as the spec describes it'],
2823
+ 'no-auth': ['no-auth', 'The request without credentials'],
2824
+ 'invalid-input': ['invalid-input', 'A body or a parameter that the schema rejects'],
2825
+ 'missing-id': ['missing-id', 'An id that does not exist'],
2826
+ 'other-user': ['other-user', 'The data of one user, read by a second user'],
2827
+ 'repeat-delete': ['repeat-delete', 'The same DELETE two times'],
2828
+ };
2829
+ const CELL_WORDS = {
2830
+ pass: 'Pass',
2831
+ fail: 'Fail',
2832
+ blocked: 'Blocked: the sweep could not run the case',
2833
+ skip: 'Skip: the request policy refuses the case',
2834
+ untested: 'Untested: the case waits in the sweep cursor',
2835
+ };
2836
+
2837
+ function caseHeader(id) {
2838
+ if (id.startsWith('declared:')) {
2839
+ const code = id.slice('declared:'.length);
2840
+ return [code, `A declared error status. The cell shows the case that got ${code}.`];
2841
+ }
2842
+ return CASE_WORDS[id] ?? [id, id];
2843
+ }
2844
+
2845
+ const LEGEND = { pass: '200', fail: '500', blocked: 'blocked', skip: 'skip', untested: 'untested' };
2846
+ const CELL_BADGE = { pass: 'pass', fail: 'fail', blocked: 'warn' };
2847
+
2848
+ /** The exchange behind one cell, as the server formats it, in a panel at the side of the matrix. */
2849
+ function renderCellExchange(key, cell) {
2850
+ const space = key.lastIndexOf(' ');
2851
+ const label = `${key.slice(0, space)} · ${caseHeader(key.slice(space + 1))[0]}`;
2852
+ const reason = cell.state === 'fail' ? `Failed checks: ${cell.reason}` : cell.reason;
2853
+ return el('aside', { class: 'drawer card', 'aria-label': 'The exchange of the cell' }, [
2854
+ el('div', { class: 'section-title' }, [
2855
+ el('h2', { class: 'mono', text: label }),
2856
+ el('span', { class: `badge ${CELL_BADGE[cell.state] ?? 'info'}`, text: cell.state }),
2857
+ el('button', {
2858
+ type: 'button',
2859
+ class: 'close',
2860
+ 'aria-label': 'Close the exchange',
2861
+ text: '×',
2862
+ onclick: () => {
2863
+ state.selectedCell = null;
2864
+ render();
2865
+ },
2866
+ }),
2867
+ ]),
2868
+ reason ? el('p', { class: 'muted', text: reason }) : null,
2869
+ captureFile(cell.exchange, label),
2870
+ ]);
2871
+ }
2872
+
2873
+ /** Coverage for each surface of the project: the endpoints of an API, the screens of a client app. */
2874
+ function renderCoverage() {
2875
+ const summary = state.summary;
2876
+ if (!summary.surfaces.length)
2877
+ return el('section', { class: 'card panel' }, [
2878
+ title('Coverage'),
2879
+ emptyState({
2880
+ heading: 'No config yet',
2881
+ why: 'Coverage shows what Bugpatrol tested: the screens of a client app and the endpoints of an API.',
2882
+ command: 'bugpatrol init',
2883
+ }),
2884
+ ]);
2885
+ return el(
2886
+ 'div',
2887
+ { class: 'stack-gap' },
2888
+ summary.surfaces.map((surface) =>
2889
+ surface.kind === 'api' ? renderApiCoverage(surface) : renderScreenCoverage(surface),
2890
+ ),
2891
+ );
2892
+ }
2893
+
2894
+ function surfaceTitle(surface, aside) {
2895
+ return el('div', { class: 'section-title' }, [
2896
+ el('h2', {}, [
2897
+ surfaceName(surface),
2898
+ surface.configs[0]
2899
+ ? el('span', { class: 'muted mono config-name', text: ` ${fileName(surface.configs[0])}` })
2900
+ : null,
2901
+ ]),
2902
+ aside ? el('span', { class: 'muted', text: aside }) : null,
2903
+ ]);
2904
+ }
2905
+
2906
+ function renderScreenCoverage(surface) {
2907
+ const totals = state.summary.coverage.screens;
2908
+ const screens = (state.appmap?.screens ?? []).filter((screen) => !screen.virtual);
2909
+ if (!totals?.found)
2910
+ return el('section', { class: 'card panel' }, [
2911
+ surfaceTitle(surface),
2912
+ screenCoverageBlock(null, state.summary, surface),
2913
+ ]);
2914
+ const open = state.issues.filter((issue) => !['fixed', 'dismissed'].includes(issue.status));
2915
+ const rows = screens
2916
+ .map((screen) => ({ screen, issues: open.filter((issue) => issue.screenId === screen.id).length }))
2917
+ .sort((a, b) => b.issues - a.issues || a.screen.name.localeCompare(b.screen.name));
2918
+ return el('section', { class: 'card panel' }, [
2919
+ surfaceTitle(surface, 'Screens that the explorer found'),
2920
+ statBand([
2921
+ { label: 'Screens found', value: number(totals.found) },
2922
+ { label: 'With a routine', value: number(totals.replayable), note: 'Bugpatrol can reach them again' },
2923
+ {
2924
+ label: 'With open issues',
2925
+ value: number(totals.withIssues),
2926
+ tone: totals.withIssues ? 'fail' : undefined,
2927
+ note: `${number(open.length)} open issues in all`,
2928
+ },
2929
+ ]),
2930
+ el('table', { class: 'usage-table screen-table' }, [
2931
+ el(
2932
+ 'tr',
2933
+ {},
2934
+ ['Screen', 'Visits', 'Routine', 'Open issues', 'Last seen'].map((text) => el('th', { text })),
2935
+ ),
2936
+ ...rows.map(({ screen, issues }) =>
2937
+ el('tr', {}, [
2938
+ el('td', {}, [
2939
+ el('button', { type: 'button', class: 'link', text: screen.name, onclick: () => openScreen(screen.id) }),
2940
+ ]),
2941
+ el('td', { text: number(screen.visits) }),
2942
+ el('td', { text: screen.routineId ? 'Yes' : 'No' }),
2943
+ el('td', {}, [
2944
+ issues
2945
+ ? el('span', { class: 'issue-count', text: `${issues} open` })
2946
+ : el('span', { class: 'muted', text: '0' }),
2947
+ ]),
2948
+ el('td', { text: relativeTime(screen.lastSeenAt) }),
2949
+ ]),
2950
+ ),
2951
+ ]),
2952
+ ]);
2953
+ }
2954
+
2955
+ /**
2956
+ * The rank of an endpoint: 0 with a failed case, 1 with a passed case, 2 with
2957
+ * a case that waits in the cursor, and 3 when the sweep skipped or blocked all of its cases.
2958
+ */
2959
+ function endpointRank(endpoint, cases, cells) {
2960
+ let rank = 3;
2961
+ for (const id of cases) {
2962
+ const cell = cells[`${endpoint} ${id}`]?.state;
2963
+ if (cell === 'fail') return 0;
2964
+ if (cell === 'pass') rank = 1;
2965
+ if (cell === 'untested') rank = Math.min(rank, 2);
2966
+ }
2967
+ return rank;
2968
+ }
2969
+
2970
+ function renderApiCoverage(surface) {
2971
+ const totals = state.summary.coverage.api;
2972
+ const data = state.coverage;
2973
+ if (!totals || !data)
2974
+ return el('section', { class: 'card panel' }, [surfaceTitle(surface), apiCoverageBlock(undefined, surface)]);
2975
+ const { cases, cells } = data;
2976
+ const own = CELL_STATES.reduce((sum, name) => sum + totals.cases[name], 0);
2977
+ const ranks = new Map(data.endpoints.map((endpoint) => [endpoint, endpointRank(endpoint, cases, cells)]));
2978
+ const endpoints = [...data.endpoints].sort((a, b) => ranks.get(a) - ranks.get(b));
2979
+ const filter = state.coverageFilter;
2980
+ const tbody = el('tbody', {
2981
+ onclick: (event) => {
2982
+ const button = event.target.closest('button[data-key]');
2983
+ if (!button) return;
2984
+ const key = button.dataset.key;
2985
+ state.selectedCell = state.selectedCell === key ? null : key;
2986
+ render();
2987
+ },
2988
+ });
2989
+ const shown = el('span', { class: 'muted' });
2990
+ const fill = () => {
2991
+ const query = filter.query.trim().toLowerCase();
2992
+ const rows = endpoints.filter(
2993
+ (endpoint) =>
2994
+ (!filter.failedOnly || ranks.get(endpoint) === 0) &&
2995
+ (!filter.hideSkipped || ranks.get(endpoint) < 3) &&
2996
+ (!query || endpoint.toLowerCase().includes(query)),
2997
+ );
2998
+ tbody.replaceChildren(...rows.map((endpoint) => matrixRow(endpoint, cases, cells)));
2999
+ shown.textContent = `${number(rows.length)} of ${number(endpoints.length)} endpoints`;
3000
+ };
3001
+ fill();
3002
+ const declared = cases.filter((id) => id.startsWith('declared:')).length;
3003
+ const table = el('table', { class: `matrix${filter.hideSkipped ? ' hide-skipped' : ''}` }, [
3004
+ el('thead', {}, [
3005
+ // The declared columns repeat a case that got that status, so they get their own group.
3006
+ declared
3007
+ ? el('tr', { class: 'matrix-groups' }, [
3008
+ el('th', {}),
3009
+ el('th', {}),
3010
+ el('th', { colspan: declared, class: 'declared', text: 'Declared error statuses' }),
3011
+ cases.length > declared + 1 ? el('th', { colspan: cases.length - declared - 1, text: 'Cases' }) : null,
3012
+ ])
3013
+ : null,
3014
+ el('tr', {}, [
3015
+ el('th', { text: 'Endpoint' }),
3016
+ ...cases.map((id) => {
3017
+ const [label, tip] = caseHeader(id);
3018
+ return el('th', { class: id.startsWith('declared:') ? 'declared' : '', title: tip, text: label });
3019
+ }),
3020
+ ]),
3021
+ ]),
3022
+ tbody,
3023
+ ]);
3024
+ const toggle = (key, label) =>
3025
+ el('label', { class: 'toggle' }, [
3026
+ el('input', {
3027
+ type: 'checkbox',
3028
+ ...(filter[key] ? { checked: '' } : {}),
3029
+ onchange: (event) => {
3030
+ filter[key] = event.target.checked;
3031
+ table.classList.toggle('hide-skipped', filter.hideSkipped);
3032
+ fill();
3033
+ },
3034
+ }),
3035
+ el('span', { text: label }),
3036
+ ]);
3037
+ const selected = state.selectedCell && cells[state.selectedCell];
3038
+ return el('section', { class: 'card panel' }, [
3039
+ surfaceTitle(surface, 'Endpoint × case, from the sweep'),
3040
+ statBand([
3041
+ {
3042
+ label: 'Endpoints tested',
3043
+ value: percent(totals.tested, totals.endpoints),
3044
+ note: `${number(totals.tested)} of ${number(totals.endpoints)}`,
3045
+ },
3046
+ {
3047
+ label: 'All cases',
3048
+ value: number(own),
3049
+ bar: caseBar(totals.cases),
3050
+ note: `${number(totals.cases.pass)} pass · ${number(totals.cases.fail)} fail`,
3051
+ },
3052
+ {
3053
+ label: 'Failed',
3054
+ value: number(totals.cases.fail),
3055
+ tone: totals.cases.fail ? 'fail' : undefined,
3056
+ note: `${percent(totals.cases.fail, totals.cases.pass + totals.cases.fail)} of the cases that ran`,
3057
+ },
3058
+ {
3059
+ label: 'Blocked · skipped',
3060
+ value: `${number(totals.cases.blocked)} · ${number(totals.cases.skip)}`,
3061
+ note: 'The sweep could not run them, or the policy refuses them',
3062
+ },
3063
+ {
3064
+ label: 'Wait in the cursor',
3065
+ value: number(totals.remaining),
3066
+ note: totals.remaining ? 'The next sweep runs them first' : 'This pass is complete',
3067
+ },
3068
+ ]),
3069
+ el('div', { class: 'matrix-tools' }, [
3070
+ el('input', {
3071
+ type: 'search',
3072
+ class: 'search',
3073
+ placeholder: 'Search the paths',
3074
+ 'aria-label': 'Search the endpoint paths',
3075
+ value: filter.query,
3076
+ oninput: (event) => {
3077
+ filter.query = event.target.value;
3078
+ fill();
3079
+ },
3080
+ }),
3081
+ toggle('failedOnly', 'Failed only'),
3082
+ toggle('hideSkipped', 'Hide skipped and blocked'),
3083
+ shown,
3084
+ el('div', { class: 'matrix-legend' }, [
3085
+ ...Object.entries(LEGEND).map(([name, sample]) =>
3086
+ el('span', {}, [el('span', { class: `matrix-cell ${name}`, text: sample }), name]),
3087
+ ),
3088
+ el('span', { text: 'Blank: the sweep makes no such case.' }),
3089
+ ]),
3090
+ ]),
3091
+ el('div', { class: 'matrix-scroll', 'data-keep-scroll': '' }, [table]),
3092
+ selected ? renderCellExchange(state.selectedCell, selected) : null,
3093
+ ]);
3094
+ }
3095
+
3096
+ function matrixRow(endpoint, cases, cells) {
3097
+ const space = endpoint.indexOf(' ');
3098
+ return el('tr', {}, [
3099
+ el('th', { scope: 'row' }, [
3100
+ el('span', { class: 'method', text: endpoint.slice(0, space) }),
3101
+ el('code', { text: endpoint.slice(space + 1) }),
3102
+ ]),
3103
+ ...cases.map((id) => {
3104
+ const key = `${endpoint} ${id}`;
3105
+ const cell = cells[key];
3106
+ if (!cell) return el('td', { class: 'matrix-none', title: 'The sweep makes no such case for this endpoint' });
3107
+ const tip = [CELL_WORDS[cell.state], cell.reason, cell.at ? relativeTime(cell.at) : ''].filter(Boolean);
3108
+ const props = {
3109
+ class: `matrix-cell ${cell.state}${state.selectedCell === key ? ' active' : ''}`,
3110
+ text: cell.status ?? cell.state,
3111
+ };
3112
+ return el('td', { class: `cell-${cell.state}` }, [
3113
+ cell.exchange
3114
+ ? el('button', {
3115
+ ...props,
3116
+ type: 'button',
3117
+ 'data-key': key,
3118
+ title: [...tip, 'Click to open the exchange'].join('\n'),
3119
+ })
3120
+ : el('span', { ...props, title: tip.join('\n') }),
3121
+ ]);
3122
+ }),
3123
+ ]);
3124
+ }
3125
+
1953
3126
  // ---------------------------------------------------------------- lightbox
1954
3127
 
1955
3128
  const lightbox = document.getElementById('lightbox');
@@ -1958,7 +3131,7 @@ const lightboxCaption = document.getElementById('lightbox-caption');
1958
3131
 
1959
3132
  function openLightbox(path, caption) {
1960
3133
  lightboxImg.src = artifact(path);
1961
- lightboxCaption.textContent = `${caption} — ${path}`;
3134
+ lightboxCaption.textContent = `${caption}: ${path}`;
1962
3135
  lightbox.hidden = false;
1963
3136
  }
1964
3137
  lightbox.addEventListener('click', () => {
@@ -1975,6 +3148,7 @@ tabs.addEventListener('click', async (event) => {
1975
3148
  if (!target) return;
1976
3149
  state.view = target.dataset.view;
1977
3150
  if (state.view === 'screens') state.selectedScreenId = null;
3151
+ if (state.view === 'coverage') state.selectedCell = null;
1978
3152
  await refresh();
1979
3153
  });
1980
3154