bugpatrol 0.3.0 → 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: [],
@@ -74,9 +113,9 @@ async function refresh({ keepSelection = true } = {}) {
74
113
  state.runs = snapshot.runs;
75
114
  state.live = snapshot.live;
76
115
  state.root = snapshot.root;
116
+ state.summary = await getJson('/api/summary');
77
117
  state.overview = await getJson('/api/overview');
78
- projectEl.textContent = state.overview.project.name;
79
- platformEl.textContent = state.overview.project.platform;
118
+ renderHeader();
80
119
  const patrol = state.overview.patrol;
81
120
  patrolEl.textContent = patrol.state === 'running' ? `Patrol running · cycle ${patrol.cycle}` : 'Patrol stopped';
82
121
  patrolEl.classList.toggle('running', patrol.state === 'running');
@@ -105,7 +144,7 @@ async function refresh({ keepSelection = true } = {}) {
105
144
  state.issueDetail = null;
106
145
  }
107
146
  }
108
- if (state.view === 'activity') {
147
+ if (state.view === 'activity' || state.view === 'flow') {
109
148
  state.sessions = await getJson('/api/sessions');
110
149
  if (!state.sessions.some((session) => session.id === state.selectedSessionId)) {
111
150
  state.selectedSessionId = state.sessions[0]?.id ?? null;
@@ -117,10 +156,6 @@ async function refresh({ keepSelection = true } = {}) {
117
156
  }
118
157
  }
119
158
  if (state.view === 'flow') {
120
- state.sessions = await getJson('/api/sessions');
121
- if (!state.sessions.some((session) => session.id === state.selectedSessionId)) {
122
- state.selectedSessionId = state.sessions[0]?.id ?? null;
123
- }
124
159
  state.flow = state.selectedSessionId
125
160
  ? await getJson(`/api/flow/${encodeURIComponent(state.selectedSessionId)}`).catch(() => null)
126
161
  : null;
@@ -135,6 +170,14 @@ async function refresh({ keepSelection = true } = {}) {
135
170
  }
136
171
  }
137
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
+ }
138
181
  if (state.view === 'reviews') {
139
182
  const reviews = await getJson('/api/reviews');
140
183
  if (!reviews.some((review) => review.pr.number === state.selectedReview)) {
@@ -177,9 +220,15 @@ function render() {
177
220
  state.selectedReview,
178
221
  state.selectedRunId,
179
222
  state.selectedScreenId,
223
+ state.selectedCell,
180
224
  ]);
181
225
  const opened = page === renderedPage ? detailsOpen() : undefined;
182
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"]');
183
232
  renderedPage = page;
184
233
  view.innerHTML = '';
185
234
  if (state.view === 'checks') {
@@ -194,11 +243,15 @@ function render() {
194
243
  screens: renderScreens,
195
244
  reviews: renderReviews,
196
245
  memory: renderMemory,
246
+ coverage: renderCoverage,
197
247
  checks: renderRuns,
198
248
  };
199
249
  view.append(renderers[state.view]());
200
250
  if (opened) {
201
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();
202
255
  window.scrollTo(0, scroll);
203
256
  }
204
257
  }
@@ -561,12 +614,17 @@ const roleIcons = {
561
614
  decider: '<path d="m3 8 3 3 7-7"/>',
562
615
  };
563
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
+
564
625
  function roleIcon(role) {
565
626
  const icon = roleIcons[role] ?? roleIcons.system;
566
- const svg =
567
- `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" ` +
568
- `stroke-width="1.4" aria-hidden="true">${icon}</svg>`;
569
- 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) });
570
628
  }
571
629
 
572
630
  function relativeTime(value) {
@@ -642,8 +700,26 @@ function renderUsage(usage) {
642
700
  ]);
643
701
  }
644
702
 
645
- 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 = '') {
646
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
+ }
647
723
  return el('img', {
648
724
  class: className,
649
725
  src: artifact(path),
@@ -653,6 +729,20 @@ function image(path, label, className = '') {
653
729
  });
654
730
  }
655
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
+
656
746
  function severityChip(value) {
657
747
  return el('span', { class: `severity severity-${value}`, text: value });
658
748
  }
@@ -664,6 +754,190 @@ function title(text, aside) {
664
754
  ]);
665
755
  }
666
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
+
667
941
  const CI_WORDS = { pending: 'CI running', passed: 'CI green', failed: 'CI failed, fixing', 'gave-up': 'CI red' };
668
942
 
669
943
  function githubChip({ kind, number, url, state, draft, ci }) {
@@ -689,7 +963,8 @@ function issueChips(issue) {
689
963
 
690
964
  function renderOverview() {
691
965
  const data = state.overview;
692
- 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…' });
693
968
  const agents = el(
694
969
  'div',
695
970
  { class: 'agent-grid' },
@@ -718,77 +993,271 @@ function renderOverview() {
718
993
  ]),
719
994
  ),
720
995
  );
721
- const attention = el('section', { class: 'card panel' }, [
722
- title('Needs attention', `${data.counts.issuesOpen} open`),
723
- data.attention.length
724
- ? el(
725
- 'div',
726
- { class: 'stack' },
727
- data.attention.map((issue) =>
728
- el('button', { class: 'attention-row', onclick: () => openIssue(issue.id) }, [
729
- image(issue.evidence?.screenshot, issue.title, 'thumb'),
730
- el('span', { class: 'grow' }, [
731
- el('strong', { text: issue.title }),
732
- el('span', { class: 'row-meta' }, [
733
- el('small', { class: 'muted', text: `${screenName(issue.screenId)} · ×${issue.occurrences}` }),
734
- ...issueChips(issue),
735
- ]),
736
- ]),
737
- severityChip(issue.severity),
738
- el('span', { class: 'muted', text: issueStatus(issue, issue.fix) }),
739
- ]),
740
- ),
741
- )
742
- : 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),
743
1005
  ]);
744
- const live = data.live;
745
- const latest = data.recentSessions[0];
746
- const livePanel = el('section', { class: 'card panel' }, [
747
- title('Live', live ? 'Session running' : 'Quiet now'),
748
- live
749
- ? el('div', {}, [
750
- image(live.screenshot, 'Newest session capture', 'live-shot'),
751
- el('div', { class: 'event-list' }, live.events.map(renderEventRow)),
752
- ])
753
- : el('div', { class: 'quiet' }, [
754
- el('p', {
755
- text: latest
756
- ? `Last: the ${latest.role}, ${relativeTime(latest.startedAt)}. ${firstSentence(latest.summary)}`
757
- : 'No sessions yet.',
758
- }),
759
- data.patrol?.nextAt
760
- ? el('p', {
761
- class: 'muted',
762
- text: `Next patrol at ${new Date(data.patrol.nextAt).toLocaleTimeString([], {
763
- hour: '2-digit',
764
- minute: '2-digit',
765
- })}${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'),
766
1121
  })
767
1122
  : null,
768
- ]),
1123
+ ])
1124
+ : null,
1125
+ ]);
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)),
769
1133
  ]);
770
- const coverage = el('section', { class: 'card panel coverage' }, [
771
- title('Coverage', `${data.counts.screens} screens found`),
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' : ''),
772
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
+ ]),
773
1202
  el(
774
1203
  'div',
775
1204
  { class: 'coverage-row' },
776
1205
  data.screens.map((screen) =>
777
1206
  el('button', { class: 'coverage-item', onclick: () => openScreen(screen.id) }, [
778
- image(screen.lastScreenshot, screen.name),
1207
+ captureFile(screen.lastCapture, screen.name),
779
1208
  el('span', { text: screen.name }),
780
1209
  ]),
781
1210
  ),
782
1211
  ),
783
1212
  ]);
784
- return el('div', {}, [
785
- agents,
786
- el('div', { class: 'overview-grid' }, [attention, livePanel]),
787
- coverage,
788
- 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
+ }),
789
1250
  ]);
790
1251
  }
791
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
+
792
1261
  function screenName(id) {
793
1262
  return (
794
1263
  state.appmap?.screens.find((screen) => screen.id === id)?.name ||
@@ -809,6 +1278,25 @@ async function openIssue(id) {
809
1278
  }
810
1279
 
811
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
+ }
812
1300
  const filters = ['open', 'fixed', 'dismissed', 'all'];
813
1301
  const filtered = visibleIssues();
814
1302
  const list = el('div', { class: 'card panel' }, [
@@ -827,6 +1315,9 @@ function renderIssues() {
827
1315
  }),
828
1316
  ),
829
1317
  ),
1318
+ filtered.length
1319
+ ? null
1320
+ : el('p', { class: 'muted', text: `No issue is ${state.issueFilter}. Select a different filter.` }),
830
1321
  ...filtered.map((issue) =>
831
1322
  el(
832
1323
  'button',
@@ -838,7 +1329,7 @@ function renderIssues() {
838
1329
  el('span', { class: 'row-meta' }, [
839
1330
  el('small', {
840
1331
  class: 'muted',
841
- text: `${screenName(issue.screenId)} · ${relativeTime(issue.lastSeenAt)}`,
1332
+ text: `${issueWhere(issue)} · ${relativeTime(issue.lastSeenAt)}`,
842
1333
  }),
843
1334
  ...issueChips(issue),
844
1335
  ]),
@@ -866,13 +1357,22 @@ function renderIssueDetail() {
866
1357
  if (!detail) return el('section', { class: 'card panel empty', text: 'Select an issue.' });
867
1358
  const { issue, fix, candidates, gaveUpAfter, attemptDiffs = {} } = detail;
868
1359
  const evidence = issue.evidence ?? {};
869
- const shots = [
870
- ['Screenshot', evidence.screenshot],
871
- ['Baseline', evidence.baseline],
872
- ['Diff', evidence.diff],
873
- ]
874
- .filter(([, path]) => path)
875
- .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 })]));
876
1376
  const steps = evidence.steps ?? detail.routine?.steps ?? [];
877
1377
  return el('article', { class: 'card panel issue-detail' }, [
878
1378
  el('div', { class: 'detail-heading' }, [el('h1', { text: issue.title }), severityChip(issue.severity)]),
@@ -892,7 +1392,7 @@ function renderIssueDetail() {
892
1392
  : null,
893
1393
  el('div', { class: 'kv' }, [
894
1394
  el('span', { text: issueStatus(issue, fix) }),
895
- el('span', { text: screenName(issue.screenId) }),
1395
+ el('span', { text: issueWhere(issue) }),
896
1396
  el('span', { text: `×${issue.occurrences}` }),
897
1397
  el('span', { text: `First ${relativeTime(issue.firstSeenAt)}` }),
898
1398
  el('span', { text: `Last ${relativeTime(issue.lastSeenAt)}` }),
@@ -985,6 +1485,7 @@ function firstSentence(text) {
985
1485
  function issueStatus(issue, fix) {
986
1486
  if (issue.status === 'dismissed') return issue.closedBy ? `Dismissed by ${issue.closedBy.by}` : 'Dismissed';
987
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';
988
1489
  if (fix?.status === 'rejected' || issue.fixRejected) return 'Fix rejected by the team';
989
1490
  if (issue.status === 'new' && issue.regression) return 'Regression';
990
1491
  if (fix?.status === 'verified') return 'Fix verified in the app';
@@ -1006,6 +1507,7 @@ function stepWords(step) {
1006
1507
  if (step.kind === 'open') return `Open ${step.url}`;
1007
1508
  if (step.kind === 'wait') return `Wait ${step.ms} ms`;
1008
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}"`;
1009
1511
  return step.kind === 'back' ? 'Go back' : step.kind;
1010
1512
  }
1011
1513
 
@@ -1134,16 +1636,20 @@ function lazyDiff(path) {
1134
1636
  }
1135
1637
 
1136
1638
  function retestBlock(retest, issue) {
1137
- 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');
1138
1644
  const pair = (shot) =>
1139
1645
  el('div', { class: 'retest-shot-pair' }, [
1140
- retest.shots?.length ? el('h4', { text: screenName(shot.screenId) }) : null,
1646
+ retest.captures?.length ? el('h4', { text: screenName(shot.screenId) }) : null,
1141
1647
  shot.reached === false ? el('span', { class: 'muted', text: 'Not reached' }) : null,
1142
1648
  shot.note ? el('p', { class: 'muted', text: shot.note }) : null,
1143
1649
  el('div', { class: 'retest-shots' }, [
1144
- el('figure', {}, [image(shot.before, 'Before'), el('figcaption', { text: 'Before' })]),
1650
+ el('figure', {}, [beforeView(shot.before), el('figcaption', { text: 'Before' })]),
1145
1651
  el('figure', {}, [
1146
- image(shot.after, 'After (with the fix)'),
1652
+ captureFile(shot.after, 'After (with the fix)'),
1147
1653
  el('figcaption', { text: 'After (with the fix)' }),
1148
1654
  ]),
1149
1655
  ]),
@@ -1151,8 +1657,8 @@ function retestBlock(retest, issue) {
1151
1657
  return el('div', { class: 'retest-block' }, [
1152
1658
  el('h3', { text: `Retest ${retest.attempt}: ${retest.outcome}` }),
1153
1659
  el('p', { text: retest.reason }),
1154
- retest.shots?.length
1155
- ? retest.shots.map((shot) => pair(shot))
1660
+ retest.captures?.length
1661
+ ? retest.captures.map((shot) => pair(shot))
1156
1662
  : pair({ before, after: retest.after, note: retest.note }),
1157
1663
  ]);
1158
1664
  }
@@ -1205,9 +1711,9 @@ function inlineMarkdown(line) {
1205
1711
  return parts;
1206
1712
  }
1207
1713
 
1208
- function renderActivity() {
1209
- const list = el('section', { class: 'card panel' }, [
1210
- title('Activity'),
1714
+ function sessionList(heading) {
1715
+ return el('section', { class: 'card panel' }, [
1716
+ title(heading, `${state.sessions.length}`),
1211
1717
  ...state.sessions.map((session) =>
1212
1718
  el(
1213
1719
  'button',
@@ -1224,59 +1730,228 @@ function renderActivity() {
1224
1730
  el('strong', { text: firstSentence(session.summary) || `${capital(session.role)} session` }),
1225
1731
  el('small', {
1226
1732
  class: 'muted',
1227
- text:
1228
- `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)} · ${session.steps} steps · ` +
1229
- `${session.tokens ? `${tokens(session.tokens)} · ` : ''}API ${money(session.costUsd)} · ` +
1230
- `${session.screensFound.length} screens · ${session.issues.length} issues`,
1733
+ text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)}`,
1231
1734
  }),
1232
1735
  ]),
1736
+ session.status === 'finished'
1737
+ ? null
1738
+ : el('span', { class: `badge ${SESSION_BADGE[session.status]}`, text: session.status }),
1233
1739
  ],
1234
1740
  ),
1235
1741
  ),
1236
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');
1237
1763
  const detail = state.sessionDetail;
1238
- const timeline = detail
1239
- ? el('section', { class: 'card panel' }, [
1240
- title('Timeline', `${capital(detail.session.role)} · ${relativeTime(detail.session.startedAt)}`),
1241
- detail.session.summary ? el('div', { class: 'session-summary' }, [markdown(detail.session.summary)]) : null,
1242
- el(
1243
- 'div',
1244
- { class: 'muted session-usage' },
1245
- detail.session.tokensByModel
1246
- ? Object.entries(detail.session.tokensByModel).map(([model, usage]) =>
1247
- el('div', { title: tokenDetail(usage), text: `${model}: ${tokens(usage)}` }),
1248
- )
1249
- : [el('div', { text: 'No token usage reported for this session.' })],
1250
- ),
1251
- el('label', { class: 'toggle' }, [
1252
- el('input', {
1253
- type: 'checkbox',
1254
- ...(state.showThoughts ? { checked: '' } : {}),
1255
- onchange: (event) => {
1256
- state.showThoughts = event.target.checked;
1257
- render();
1258
- },
1259
- }),
1260
- el('span', { text: 'Show agent reasoning' }),
1261
- ]),
1262
- el(
1263
- 'div',
1264
- { class: 'event-list' },
1265
- detail.events.filter((event) => state.showThoughts || isFeedEvent(event)).map(renderEventRow),
1266
- ),
1267
- detail.candidates.length ? title('Candidates') : null,
1268
- ...detail.candidates.map((candidate) =>
1269
- el('div', { class: 'candidate' }, [
1270
- el('strong', { text: candidate.summary }),
1271
- el('span', {
1272
- class: 'muted',
1273
- 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
+ },
1274
1890
  }),
1891
+ el('span', { text: 'Show agent reasoning' }),
1275
1892
  ]),
1276
- ),
1277
- ])
1278
- : el('section', { class: 'card panel empty', text: 'Select a session.' });
1279
- 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
+ ]);
1280
1955
  }
1281
1956
 
1282
1957
  function duration(session) {
@@ -1297,7 +1972,7 @@ function renderEventRow(event) {
1297
1972
  text: tokens(event.tokens, ''),
1298
1973
  })
1299
1974
  : null,
1300
- event.screenshot ? image(event.screenshot, event.summary, 'event-thumb') : null,
1975
+ event.capture ? captureFile(event.capture, event.summary, 'event-thumb') : null,
1301
1976
  event.input !== undefined || event.output !== undefined
1302
1977
  ? el('details', {}, [
1303
1978
  el('summary', { text: 'Tool data' }),
@@ -1311,52 +1986,17 @@ function renderEventRow(event) {
1311
1986
  // failed requests the app reported, and the backend logs that followed, merged
1312
1987
  // in time order. A log close behind a failure is marked correlated.
1313
1988
  function renderFlow() {
1314
- const list = el('section', { class: 'card panel' }, [
1315
- title('Flow'),
1316
- ...state.sessions.map((session) =>
1317
- el(
1318
- 'button',
1319
- {
1320
- class: `list-row ${session.id === state.selectedSessionId ? 'active' : ''}`,
1321
- onclick: async () => {
1322
- state.selectedSessionId = session.id;
1323
- await refresh();
1324
- },
1325
- },
1326
- [
1327
- roleIcon(session.role),
1328
- el('span', { class: 'grow' }, [
1329
- el('strong', { text: firstSentence(session.summary) || `${capital(session.role)} session` }),
1330
- el('small', {
1331
- class: 'muted',
1332
- text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)}`,
1333
- }),
1334
- ]),
1335
- ],
1336
- ),
1337
- ),
1338
- ]);
1989
+ if (!state.sessions.length) return noSessions('Flow');
1339
1990
  const flow = state.flow;
1340
- if (!flow) {
1341
- return el('div', { class: 'master-detail' }, [
1342
- list,
1343
- el('section', {
1344
- class: 'card panel empty',
1345
- text: 'No flow for this session. Add a `logs:` source to bugpatrol.yml and run a patrol.',
1346
- }),
1347
- ]);
1348
- }
1991
+ const list = sessionList('Sessions');
1992
+ if (!flow) return el('div', { class: 'master-detail' }, [list, el('section', { class: 'card panel' }, [noFlow()])]);
1349
1993
  const sources = flow.sources.length
1350
1994
  ? flow.sources.map((source) =>
1351
1995
  el('div', {
1352
1996
  text: `${source.name} (${source.kind}): ${source.collected} line${source.collected === 1 ? '' : 's'}`,
1353
1997
  }),
1354
1998
  )
1355
- : [
1356
- el('div', {
1357
- text: 'No log sources configured. Add `logs:` to bugpatrol.yml to see backend logs beside each action.',
1358
- }),
1359
- ];
1999
+ : [el('div', { text: 'The config had no log source for this session, so the flow has no backend logs.' })];
1360
2000
  return el('div', { class: 'master-detail' }, [
1361
2001
  list,
1362
2002
  el('section', { class: 'card panel' }, [
@@ -1369,13 +2009,56 @@ function renderFlow() {
1369
2009
  ]);
1370
2010
  }
1371
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
+
1372
2055
  function renderFlowRow(entry) {
1373
2056
  return el('div', { class: `event-row flow-row flow-${entry.kind}${entry.correlated ? ' correlated' : ''}` }, [
1374
2057
  el('time', { text: new Date(entry.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }),
1375
2058
  el('span', { class: 'flow-kind', text: entry.kind }),
1376
2059
  el('span', { class: 'grow truncate', text: entry.summary }),
1377
2060
  entry.level ? el('small', { class: 'muted', text: entry.level }) : null,
1378
- entry.screenshot ? image(entry.screenshot, entry.summary, 'event-thumb') : null,
2061
+ entry.capture ? captureFile(entry.capture, entry.summary, 'event-thumb') : null,
1379
2062
  ]);
1380
2063
  }
1381
2064
 
@@ -1385,9 +2068,37 @@ async function openScreen(id) {
1385
2068
  await refresh();
1386
2069
  }
1387
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
+
1388
2098
  function renderScreens() {
1389
2099
  const graphScreens = state.appmap?.screens ?? [];
1390
2100
  const screens = graphScreens.filter((screen) => !screen.virtual);
2101
+ if (!screens.length) return el('section', { class: 'card panel' }, [title('Screens'), noScreens()]);
1391
2102
  const edges = state.appmap?.edges ?? [];
1392
2103
  const selected = screens.find((screen) => screen.id === state.selectedScreenId);
1393
2104
  if (selected) {
@@ -1405,7 +2116,7 @@ function renderScreens() {
1405
2116
  },
1406
2117
  }),
1407
2118
  title(selected.name, `${selected.visits} visits · ${relativeTime(selected.lastSeenAt)}`),
1408
- image(selected.lastScreenshot, selected.name, 'screen-large'),
2119
+ captureFile(selected.lastCapture, selected.name, 'screen-large'),
1409
2120
  el('p', { text: selected.description }),
1410
2121
  title('Routine'),
1411
2122
  el('p', {
@@ -1456,38 +2167,48 @@ function renderScreens() {
1456
2167
  }),
1457
2168
  ),
1458
2169
  );
2170
+ const apis = surfacesOf('api');
1459
2171
  return el('div', {}, [
1460
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,
1461
2184
  toggle,
1462
- screens.length === 0
1463
- ? el('div', { class: 'empty', text: 'No screens found yet.' })
1464
- : screenMode === 'graph'
1465
- ? renderScreenGraph(graphScreens, edges)
1466
- : el(
1467
- 'div',
1468
- { class: 'screen-grid' },
1469
- screens.map((screen) =>
1470
- el(
1471
- 'button',
1472
- {
1473
- class: 'card screen-card',
1474
- onclick: () => openScreen(screen.id),
1475
- },
1476
- [
1477
- image(screen.lastScreenshot, screen.name),
1478
- el('div', { class: 'screen-copy' }, [
1479
- el('strong', { text: screen.name }),
1480
- el('p', { class: 'truncate muted', text: screen.description }),
1481
- el('small', {
1482
- class: 'muted',
1483
- text: `${relativeTime(screen.lastSeenAt)} · ${screen.visits} visits`,
1484
- }),
1485
- screen.openIssues ? el('span', { class: 'issue-count', text: `${screen.openIssues} open` }) : null,
1486
- ]),
1487
- ],
1488
- ),
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
+ ],
1489
2209
  ),
1490
2210
  ),
2211
+ ),
1491
2212
  ]);
1492
2213
  }
1493
2214
 
@@ -1585,14 +2306,14 @@ function renderScreenGraph(screens, allEdges) {
1585
2306
  },
1586
2307
  });
1587
2308
  card.append(svgEl('rect', { x: node.x, y: node.y, width: node.w, height: node.h, rx: 9, class: 'graph-card' }));
1588
- if (screen.lastScreenshot)
2309
+ if (screen.lastCapture)
1589
2310
  card.append(
1590
2311
  svgEl('image', {
1591
2312
  x: node.x + 5,
1592
2313
  y: node.y + 5,
1593
2314
  width: node.w - 10,
1594
2315
  height: 104,
1595
- href: artifact(screen.lastScreenshot),
2316
+ href: artifact(screen.lastCapture),
1596
2317
  preserveAspectRatio: 'xMidYMin slice',
1597
2318
  }),
1598
2319
  );
@@ -1764,11 +2485,21 @@ async function openReview(number) {
1764
2485
  }
1765
2486
 
1766
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
+ ]);
1767
2501
  const list = el('div', { class: 'card panel' }, [
1768
2502
  title('Pull request reviews', `${state.reviews.length}`),
1769
- state.reviews.length
1770
- ? null
1771
- : el('p', { class: 'empty', text: 'No reviews yet. Run bugpatrol review <pr> to review a pull request.' }),
1772
2503
  ...state.reviews.map((review) =>
1773
2504
  el(
1774
2505
  'button',
@@ -1959,7 +2690,9 @@ function buildEvidence(label, replay, recordings) {
1959
2690
  el(
1960
2691
  'div',
1961
2692
  { class: 'shot-strip' },
1962
- 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
+ ),
1963
2696
  );
1964
2697
  return el('figure', { class: 'build-evidence' }, [
1965
2698
  pick ? recordingView(pick) : shots.length ? strip() : el('div', { class: 'image-empty', text: 'No capture' }),
@@ -1978,7 +2711,10 @@ function recordingView(rec) {
1978
2711
  el('small', { class: 'muted', text: name }),
1979
2712
  ]);
1980
2713
  if (rec.kind === 'image')
1981
- 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
+ ]);
1982
2718
  return castPlayer(rec.path);
1983
2719
  }
1984
2720
 
@@ -2025,8 +2761,11 @@ function findingBlock(finding) {
2025
2761
  el('p', { text: finding.reason }),
2026
2762
  finding.head || finding.base
2027
2763
  ? el('div', { class: 'retest-shots' }, [
2028
- el('figure', {}, [image(finding.base, 'Base'), el('figcaption', { text: 'Base' })]),
2029
- 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
+ ]),
2030
2769
  ])
2031
2770
  : null,
2032
2771
  finding.baseNote ? el('p', { class: 'muted', text: finding.baseNote }) : null,
@@ -2051,6 +2790,18 @@ function renderMemory() {
2051
2790
  el('span', { class: 'badge info', text: lesson.scope ?? 'app' }),
2052
2791
  el('span', { class: 'muted', text: `${lesson.source} · ×${lesson.hits} · ${relativeTime(lesson.lastSeenAt)}` }),
2053
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
+ ]);
2054
2805
  return el('section', { class: 'card panel' }, [
2055
2806
  title('Memory'),
2056
2807
  el('p', { class: 'muted', text: 'Use bugpatrol memory to edit lessons.' }),
@@ -2067,6 +2818,311 @@ function renderMemory() {
2067
2818
  ]);
2068
2819
  }
2069
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
+
2070
3126
  // ---------------------------------------------------------------- lightbox
2071
3127
 
2072
3128
  const lightbox = document.getElementById('lightbox');
@@ -2092,6 +3148,7 @@ tabs.addEventListener('click', async (event) => {
2092
3148
  if (!target) return;
2093
3149
  state.view = target.dataset.view;
2094
3150
  if (state.view === 'screens') state.selectedScreenId = null;
3151
+ if (state.view === 'coverage') state.selectedCell = null;
2095
3152
  await refresh();
2096
3153
  });
2097
3154