bugpatrol 0.1.3 → 0.1.5

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
@@ -20,6 +20,7 @@ const state = {
20
20
  sessions: [],
21
21
  selectedSessionId: null,
22
22
  sessionDetail: null,
23
+ flow: null,
23
24
  appmap: null,
24
25
  selectedScreenId: null,
25
26
  routines: [],
@@ -32,7 +33,11 @@ const state = {
32
33
  };
33
34
 
34
35
  let screenMode = 'graph';
35
- try { screenMode = localStorage.getItem('bugpatrol-screen-mode') === 'grid' ? 'grid' : 'graph'; } catch { /* storage may be disabled */ }
36
+ try {
37
+ screenMode = localStorage.getItem('bugpatrol-screen-mode') === 'grid' ? 'grid' : 'graph';
38
+ } catch {
39
+ /* storage may be disabled */
40
+ }
36
41
  const graphCamera = { box: null, key: '' };
37
42
  let showBackLinks = false;
38
43
 
@@ -41,7 +46,7 @@ let showBackLinks = false;
41
46
  // Mirrors isFeedEvent in agents.ts: the feed shows actions, not reasoning.
42
47
  function isFeedEvent(event) {
43
48
  if (event.kind === 'thought' || event.kind === 'tool-call') return false;
44
- return Boolean(event.summary && event.summary.trim());
49
+ return Boolean(event.summary?.trim());
45
50
  }
46
51
 
47
52
  async function getJson(url) {
@@ -63,7 +68,10 @@ async function refresh({ keepSelection = true } = {}) {
63
68
  const patrol = state.overview.patrol;
64
69
  patrolEl.textContent = patrol.state === 'running' ? `Patrol running · cycle ${patrol.cycle}` : 'Patrol stopped';
65
70
  patrolEl.classList.toggle('running', patrol.state === 'running');
66
- liveEl.classList.toggle('working', state.overview.agents.some((agent) => agent.state === 'working'));
71
+ liveEl.classList.toggle(
72
+ 'working',
73
+ state.overview.agents.some((agent) => agent.state === 'working'),
74
+ );
67
75
 
68
76
  // Follow the newest run unless the user has deliberately pinned an older one.
69
77
  const stillExists = state.runs.some((r) => r.id === state.selectedRunId);
@@ -96,6 +104,15 @@ async function refresh({ keepSelection = true } = {}) {
96
104
  state.sessionDetail = null;
97
105
  }
98
106
  }
107
+ if (state.view === 'flow') {
108
+ state.sessions = await getJson('/api/sessions');
109
+ if (!state.sessions.some((session) => session.id === state.selectedSessionId)) {
110
+ state.selectedSessionId = state.sessions[0]?.id ?? null;
111
+ }
112
+ state.flow = state.selectedSessionId
113
+ ? await getJson(`/api/flow/${encodeURIComponent(state.selectedSessionId)}`).catch(() => null)
114
+ : null;
115
+ }
99
116
  if (state.view === 'screens') {
100
117
  state.appmap = await getJson('/api/appmap');
101
118
  state.issues = await getJson('/api/issues');
@@ -132,8 +149,15 @@ function render() {
132
149
  const banner = renderLiveBanner();
133
150
  if (banner) view.append(banner);
134
151
  }
135
- const renderers = { overview: renderOverview, issues: renderIssues, activity: renderActivity,
136
- screens: renderScreens, memory: renderMemory, checks: renderRuns };
152
+ const renderers = {
153
+ overview: renderOverview,
154
+ issues: renderIssues,
155
+ activity: renderActivity,
156
+ flow: renderFlow,
157
+ screens: renderScreens,
158
+ memory: renderMemory,
159
+ checks: renderRuns,
160
+ };
137
161
  view.append(renderers[state.view]());
138
162
  }
139
163
 
@@ -164,7 +188,7 @@ function svgEl(tag, props = {}, children = []) {
164
188
  /** Shown only while a run is actually in flight. */
165
189
  function renderLiveBanner() {
166
190
  const live = state.live;
167
- if (!live || live.status !== 'running') return null;
191
+ if (live?.status !== 'running') return null;
168
192
  const done = live.captured.length;
169
193
  const total = live.plannedCaptures || done;
170
194
  return el('div', { class: 'runbanner' }, [
@@ -178,29 +202,38 @@ function renderRuns() {
178
202
  if (state.runs.length === 0) {
179
203
  return el('div', {
180
204
  class: 'empty',
181
- html: state.live?.status === 'running'
182
- ? 'First check in progress.'
183
- : 'No runs yet. Run <code>bugpatrol run</code> in this project and results appear here automatically.',
205
+ html:
206
+ state.live?.status === 'running'
207
+ ? 'First check in progress.'
208
+ : 'No runs yet. Run <code>bugpatrol run</code> in this project and results appear here automatically.',
184
209
  });
185
210
  }
186
211
 
187
- const list = el('div', { class: 'card runlist' },
212
+ const list = el(
213
+ 'div',
214
+ { class: 'card runlist' },
188
215
  state.runs.map((run) => {
189
- const button = el('button', {
190
- class: `runitem${run.id === state.selectedRunId ? ' active' : ''}`,
191
- onclick: async () => {
192
- state.selectedRunId = run.id;
193
- state.detail = await getJson(`/api/runs/${encodeURIComponent(run.id)}`);
194
- render();
216
+ const button = el(
217
+ 'button',
218
+ {
219
+ class: `runitem${run.id === state.selectedRunId ? ' active' : ''}`,
220
+ onclick: async () => {
221
+ state.selectedRunId = run.id;
222
+ state.detail = await getJson(`/api/runs/${encodeURIComponent(run.id)}`);
223
+ render();
224
+ },
195
225
  },
196
- }, [
197
- el('div', { class: 'row' }, [
198
- el('span', { class: `badge ${verdictClass(run)}`, text: verdictLabel(run) }),
199
- el('span', { class: 'mono', text: shortCommit(run.commit) }),
200
- ]),
201
- el('div', { class: 'when',
202
- text: `${formatTime(run.startedAt)} · ${run.mode} · ${run.findings.total} finding(s)` }),
203
- ]);
226
+ [
227
+ el('div', { class: 'row' }, [
228
+ el('span', { class: `badge ${verdictClass(run)}`, text: verdictLabel(run) }),
229
+ el('span', { class: 'mono', text: shortCommit(run.commit) }),
230
+ ]),
231
+ el('div', {
232
+ class: 'when',
233
+ text: `${formatTime(run.startedAt)} · ${run.mode} · ${run.findings.total} finding(s)`,
234
+ }),
235
+ ],
236
+ );
204
237
  return button;
205
238
  }),
206
239
  );
@@ -214,8 +247,10 @@ function renderDetail() {
214
247
 
215
248
  const header = el('div', {}, [
216
249
  el('h2', {}, [
217
- el('span', { class: `badge ${verdictClass(summaryOf(run, findings))}`,
218
- text: verdictLabel(summaryOf(run, findings)) }),
250
+ el('span', {
251
+ class: `badge ${verdictClass(summaryOf(run, findings))}`,
252
+ text: verdictLabel(summaryOf(run, findings)),
253
+ }),
219
254
  el('span', { text: ` ${run.mode} run on ${shortCommit(run.commit)}` }),
220
255
  ]),
221
256
  el('div', { class: 'kv' }, [
@@ -230,9 +265,11 @@ function renderDetail() {
230
265
  const children = [header];
231
266
 
232
267
  if (!trace) {
233
- children.push(el('div', { class: 'notes' }, [
234
- el('p', { text: 'This run predates the trace format, so only its findings are available.' }),
235
- ]));
268
+ children.push(
269
+ el('div', { class: 'notes' }, [
270
+ el('p', { text: 'This run predates the trace format, so only its findings are available.' }),
271
+ ]),
272
+ );
236
273
  }
237
274
 
238
275
  const byScreen = new Map();
@@ -260,22 +297,24 @@ function renderDetail() {
260
297
  for (const screen of trace?.screens ?? []) {
261
298
  const own = Array.isArray(screen.findingIds)
262
299
  ? screen.findingIds.map((id) => byId.get(id)).filter(Boolean)
263
- : byScreen.get(screen.screenId) ?? [];
300
+ : (byScreen.get(screen.screenId) ?? []);
264
301
  const unique = own.filter((finding) => !seen.has(`${finding.screenId}|${problemKey(finding)}`));
265
302
  for (const finding of unique) seen.add(`${finding.screenId}|${problemKey(finding)}`);
266
303
  children.push(renderScreen(screen, unique, trace, viewports));
267
304
  }
268
305
 
269
306
  if (trace?.suppressed?.length) {
270
- children.push(el('div', { class: 'screen' }, [
271
- el('h3', { text: `Suppressed by the Intent Ledger (${trace.suppressed.length})` }),
272
- ...trace.suppressed.map((s) =>
273
- el('div', { class: 'finding' }, [
274
- el('div', { text: `${s.ruleId} on ${s.screenId}` }),
275
- el('div', { class: 'why', text: `"${s.reason}" — ${s.decidedBy}` }),
276
- ]),
277
- ),
278
- ]));
307
+ children.push(
308
+ el('div', { class: 'screen' }, [
309
+ el('h3', { text: `Suppressed by the Intent Ledger (${trace.suppressed.length})` }),
310
+ ...trace.suppressed.map((s) =>
311
+ el('div', { class: 'finding' }, [
312
+ el('div', { text: `${s.ruleId} on ${s.screenId}` }),
313
+ el('div', { class: 'why', text: `"${s.reason}" — ${s.decidedBy}` }),
314
+ ]),
315
+ ),
316
+ ]),
317
+ );
279
318
  }
280
319
 
281
320
  return el('div', { class: 'card detail' }, children);
@@ -300,8 +339,9 @@ function renderScreen(screen, findings, trace, viewports) {
300
339
  shots.length ? el('div', { class: 'shots' }, shots) : null,
301
340
 
302
341
  el('div', { class: 'checks' }, [
303
- ...fired.map((c) => el('span', { class: `chk fired sev-${c.severity ?? 'minor'}`,
304
- title: c.message ?? '', text: c.ruleId })),
342
+ ...fired.map((c) =>
343
+ el('span', { class: `chk fired sev-${c.severity ?? 'minor'}`, title: c.message ?? '', text: c.ruleId }),
344
+ ),
305
345
  passed.length ? el('span', { class: 'muted', text: `${passed.length} other checks passed` }) : null,
306
346
  ]),
307
347
 
@@ -332,18 +372,40 @@ function renderGroup(group, trace, viewports) {
332
372
  const tags = [...(viewports.get(key) ?? [])];
333
373
  if (group.length === 1) {
334
374
  const card = renderFinding(first, trace);
335
- card.append(el('div', { class: 'viewport-tags' }, tags.map((viewport) => el('span', { text: viewport }))));
375
+ card.append(
376
+ el(
377
+ 'div',
378
+ { class: 'viewport-tags' },
379
+ tags.map((viewport) => el('span', { text: viewport })),
380
+ ),
381
+ );
336
382
  return card;
337
383
  }
338
- const card = renderFinding({
339
- ...first, summary: first.summary.replace(/"[^"]*"/, `${group.length} elements`),
340
- }, trace);
341
- card.append(el('div', { class: 'viewport-tags' }, tags.map((viewport) => el('span', { text: viewport }))));
384
+ const card = renderFinding(
385
+ {
386
+ ...first,
387
+ summary: first.summary.replace(/"[^"]*"/, `${group.length} elements`),
388
+ },
389
+ trace,
390
+ );
391
+ card.append(
392
+ el(
393
+ 'div',
394
+ { class: 'viewport-tags' },
395
+ tags.map((viewport) => el('span', { text: viewport })),
396
+ ),
397
+ );
342
398
  const selectors = group.map((f) => (f.summary.match(/"([^"]*)"/) ?? [])[1]).filter(Boolean);
343
- card.append(el('details', { class: 'members' }, [
344
- el('summary', { text: `The same problem on ${group.length} elements` }),
345
- el('ul', {}, selectors.map((sel) => el('li', {}, [el('code', { text: sel })]))),
346
- ]));
399
+ card.append(
400
+ el('details', { class: 'members' }, [
401
+ el('summary', { text: `The same problem on ${group.length} elements` }),
402
+ el(
403
+ 'ul',
404
+ {},
405
+ selectors.map((sel) => el('li', {}, [el('code', { text: sel })])),
406
+ ),
407
+ ]),
408
+ );
347
409
  return card;
348
410
  }
349
411
 
@@ -352,8 +414,10 @@ function renderFinding(finding, trace) {
352
414
  return el('div', { class: `finding sev-${finding.severity}` }, [
353
415
  el('div', { text: finding.summary }),
354
416
  why ? el('div', { class: 'why', text: `Routed to ${finding.route}: ${why}` }) : null,
355
- el('div', { class: 'meta',
356
- text: `${finding.ruleId} · ${finding.tier} · ${finding.severity} · ${finding.fingerprint}` }),
417
+ el('div', {
418
+ class: 'meta',
419
+ text: `${finding.ruleId} · ${finding.tier} · ${finding.severity} · ${finding.fingerprint}`,
420
+ }),
357
421
  ]);
358
422
  }
359
423
 
@@ -375,18 +439,19 @@ function renderReasoning(screen) {
375
439
 
376
440
  if (screen.diff) {
377
441
  const d = screen.diff;
378
- add('Pixel diff', d.identical
379
- ? `identical (${d.engine}, ${Math.round(d.durationMs)}ms)`
380
- : `${d.changedPixels} px across ${d.regionCount} region(s) — ` +
381
- `${(d.changedFraction * 100).toFixed(3)}% of compared area (${d.engine})`);
382
- add('Masked', `${(d.maskedFraction * 100).toFixed(1)}% of the screen, ` +
383
- `${d.maskedRegionCount} region(s)`);
442
+ add(
443
+ 'Pixel diff',
444
+ d.identical
445
+ ? `identical (${d.engine}, ${Math.round(d.durationMs)}ms)`
446
+ : `${d.changedPixels} px across ${d.regionCount} region(s) — ` +
447
+ `${(d.changedFraction * 100).toFixed(3)}% of compared area (${d.engine})`,
448
+ );
449
+ add('Masked', `${(d.maskedFraction * 100).toFixed(1)}% of the screen, ` + `${d.maskedRegionCount} region(s)`);
384
450
  if (!d.enginesAgreed) add('Engines disagreed', `cross-check saw ${d.crossCheckChangedPixels} px`);
385
451
  // First line only: older runs stored the loader's full multi-line dump.
386
452
  if (d.degraded) add('Degraded', d.degraded.split('\n')[0].replace(/(: \/\S+)+.*$/, ''));
387
453
  if (d.dimensionMismatch) {
388
- add('Dimensions changed', `${d.dimensionMismatch.baseline.join('x')} → ` +
389
- d.dimensionMismatch.actual.join('x'));
454
+ add('Dimensions changed', `${d.dimensionMismatch.baseline.join('x')} → ${d.dimensionMismatch.actual.join('x')}`);
390
455
  }
391
456
  }
392
457
 
@@ -399,9 +464,10 @@ function renderReasoning(screen) {
399
464
 
400
465
  if (screen.decision) {
401
466
  const d = screen.decision;
402
- add('Decision layer', d.decider === 'none'
403
- ? `not consulted — ${d.skippedReason}`
404
- : `${d.decider}, $${(d.costUsd ?? 0).toFixed(6)}`);
467
+ add(
468
+ 'Decision layer',
469
+ d.decider === 'none' ? `not consulted — ${d.skippedReason}` : `${d.decider}, $${(d.costUsd ?? 0).toFixed(6)}`,
470
+ );
405
471
  if (d.stateChars) add('State digest', `${d.stateChars} chars (hash ${String(d.stateHash ?? '').slice(0, 12)})`);
406
472
  for (const [key, answer] of Object.entries(d.answers ?? {})) {
407
473
  add(`· ${key}`, `${answer.value} (confidence ${Number(answer.confidence).toFixed(2)})`);
@@ -410,13 +476,21 @@ function renderReasoning(screen) {
410
476
 
411
477
  return el('details', { class: 'reasoning' }, [
412
478
  el('summary', { text: 'How Bugpatrol reached this' }),
413
- el('table', {}, rows.map(([k, v]) => el('tr', {}, [el('td', { text: k }), el('td', { text: String(v) })]))),
479
+ el(
480
+ 'table',
481
+ {},
482
+ rows.map(([k, v]) => el('tr', {}, [el('td', { text: k }), el('td', { text: String(v) })])),
483
+ ),
414
484
  ]);
415
485
  }
416
486
 
417
487
  function shot(path, caption) {
418
- const img = el('img', { src: artifact(path), alt: caption, loading: 'lazy',
419
- onclick: () => openLightbox(path, caption) });
488
+ const img = el('img', {
489
+ src: artifact(path),
490
+ alt: caption,
491
+ loading: 'lazy',
492
+ onclick: () => openLightbox(path, caption),
493
+ });
420
494
  return el('figure', {}, [img, el('figcaption', { text: caption })]);
421
495
  }
422
496
 
@@ -432,7 +506,8 @@ const roleIcons = {
432
506
 
433
507
  function roleIcon(role) {
434
508
  const icon = roleIcons[role] ?? roleIcons.system;
435
- const svg = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" ` +
509
+ const svg =
510
+ `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" ` +
436
511
  `stroke-width="1.4" aria-hidden="true">${icon}</svg>`;
437
512
  return el('span', { class: 'role-icon', title: role, 'aria-label': role, html: svg });
438
513
  }
@@ -466,10 +541,16 @@ function tokens(usage, suffix = ' tokens') {
466
541
 
467
542
  function tokenDetail(usage) {
468
543
  if (!usage) return 'The runtime reported no token usage.';
469
- return [`${usage.input} input tokens`, usage.cacheRead ? `${usage.cacheRead} of them from the cache` : '',
470
- usage.cacheWrite ? `${usage.cacheWrite} written to the cache` : '', `${usage.output} output tokens`,
544
+ return [
545
+ `${usage.input} input tokens`,
546
+ usage.cacheRead ? `${usage.cacheRead} of them from the cache` : '',
547
+ usage.cacheWrite ? `${usage.cacheWrite} written to the cache` : '',
548
+ `${usage.output} output tokens`,
471
549
  usage.listCostUsd ? `list price ${money(usage.listCostUsd)}` : '',
472
- usage.estimated ? 'estimated from the text length' : ''].filter(Boolean).join(', ');
550
+ usage.estimated ? 'estimated from the text length' : '',
551
+ ]
552
+ .filter(Boolean)
553
+ .join(', ');
473
554
  }
474
555
 
475
556
  function renderUsage(usage) {
@@ -477,24 +558,42 @@ function renderUsage(usage) {
477
558
  const head = ['Agent', 'Model', 'Sessions', 'Input', 'Cached', 'Output', 'Per session', 'List price'];
478
559
  return el('section', { class: 'card panel usage' }, [
479
560
  title('Token usage', usage?.weekTotal ? `Last 7 days · ${tokens(usage.weekTotal)}` : 'Last 7 days'),
480
- rows.length ? el('table', { class: 'usage-table' }, [
481
- el('tr', {}, head.map((text) => el('th', { text }))),
482
- ...rows.map((row) => el('tr', { title: tokenDetail(row.tokens) }, [
483
- el('td', { text: capital(row.role) }), el('td', { text: row.model }),
484
- el('td', { text: String(row.sessions) }), el('td', { text: count(row.tokens.input) }),
485
- el('td', { text: row.tokens.cacheRead ? count(row.tokens.cacheRead) : '—' }),
486
- el('td', { text: count(row.tokens.output) }),
487
- el('td', { text: count(Math.round((row.tokens.input + row.tokens.output) / Math.max(1, row.sessions))) }),
488
- el('td', { text: row.tokens.listCostUsd ? money(row.tokens.listCostUsd) : '—' }),
489
- ])),
490
- ]) : el('p', { class: 'empty', text: 'No token usage yet. The API agents, claude (--output-format json), and codex (--json) report their tokens.' }),
561
+ rows.length
562
+ ? el('table', { class: 'usage-table' }, [
563
+ el(
564
+ 'tr',
565
+ {},
566
+ head.map((text) => el('th', { text })),
567
+ ),
568
+ ...rows.map((row) =>
569
+ el('tr', { title: tokenDetail(row.tokens) }, [
570
+ el('td', { text: capital(row.role) }),
571
+ el('td', { text: row.model }),
572
+ el('td', { text: String(row.sessions) }),
573
+ el('td', { text: count(row.tokens.input) }),
574
+ el('td', { text: row.tokens.cacheRead ? count(row.tokens.cacheRead) : '—' }),
575
+ el('td', { text: count(row.tokens.output) }),
576
+ el('td', { text: count(Math.round((row.tokens.input + row.tokens.output) / Math.max(1, row.sessions))) }),
577
+ el('td', { text: row.tokens.listCostUsd ? money(row.tokens.listCostUsd) : '—' }),
578
+ ]),
579
+ ),
580
+ ])
581
+ : el('p', {
582
+ class: 'empty',
583
+ text: 'No token usage yet. The API agents, claude (--output-format json), and codex (--json) report their tokens.',
584
+ }),
491
585
  ]);
492
586
  }
493
587
 
494
588
  function image(path, label, className = '') {
495
589
  if (!path) return el('div', { class: `image-empty ${className}`, text: 'No capture' });
496
- return el('img', { class: className, src: artifact(path), alt: label, loading: 'lazy',
497
- onclick: () => openLightbox(path, label) });
590
+ return el('img', {
591
+ class: className,
592
+ src: artifact(path),
593
+ alt: label,
594
+ loading: 'lazy',
595
+ onclick: () => openLightbox(path, label),
596
+ });
498
597
  }
499
598
 
500
599
  function severityChip(value) {
@@ -514,76 +613,132 @@ function githubChip({ kind, number, url, state, draft, ci }) {
514
613
  const label = kind === 'pr' ? 'PR' : 'Issue';
515
614
  const shown = state === 'open' && draft ? 'draft' : state;
516
615
  const checks = state === 'open' || !state ? CI_WORDS[ci] : undefined;
517
- return el('a', { href: url, target: '_blank', rel: 'noopener noreferrer',
616
+ return el('a', {
617
+ href: url,
618
+ target: '_blank',
619
+ rel: 'noopener noreferrer',
518
620
  class: `gh-chip gh-${shown || 'unknown'}${checks ? ` ci-${ci}` : ''}`,
519
621
  text: `${label} #${number}${shown ? ` · ${shown}` : ''}${checks ? ` · ${checks}` : ''}`,
520
- onclick: (event) => event.stopPropagation() });
622
+ onclick: (event) => event.stopPropagation(),
623
+ });
521
624
  }
522
625
 
523
626
  function issueChips(issue) {
524
- return [issue.pr ? githubChip({ kind: 'pr', ...issue.pr }) : null,
525
- issue.github ? githubChip({ kind: 'issue', ...issue.github }) : null];
627
+ return [
628
+ issue.pr ? githubChip({ kind: 'pr', ...issue.pr }) : null,
629
+ issue.github ? githubChip({ kind: 'issue', ...issue.github }) : null,
630
+ ];
526
631
  }
527
632
 
528
633
  function renderOverview() {
529
634
  const data = state.overview;
530
635
  if (!data) return el('div', { class: 'empty', text: 'Loading overview…' });
531
- const agents = el('div', { class: 'agent-grid' }, data.agents.map((agent) =>
532
- el('article', { class: 'card agent-card' }, [
533
- el('div', { class: 'agent-head' }, [roleIcon(agent.role), el('strong', { text: capital(agent.role) }),
534
- el('span', { class: `state-dot ${agent.state}`, title: agent.state })]),
535
- el('div', { class: 'muted', text: agent.runtime || 'No runtime configured' }),
536
- el('p', { text: agent.activity || capital(agent.state) }),
537
- // Bugpatrol sees only the API calls that it makes. A local agent CLI
538
- // bills the user's own plan, so its cost is not in this number.
539
- el('div', { class: 'muted', title: tokenDetail(data.usage?.todayByRole?.[agent.role]),
540
- text: `${tokens(data.usage?.todayByRole?.[agent.role])} today` }),
541
- el('div', { class: 'muted', text: `API ${money(agent.spentUsd)} today`
542
- + (String(agent.runtime).startsWith('cli') ? ' · the model runs on your CLI plan' : '') }),
543
- ])));
544
- const attention = el('section', { class: 'card panel' }, [title('Needs attention', `${data.counts.issuesOpen} open`),
545
- data.attention.length ? el('div', { class: 'stack' }, data.attention.map((issue) =>
546
- el('button', { class: 'attention-row', onclick: () => openIssue(issue.id) }, [
547
- image(issue.evidence?.screenshot, issue.title, 'thumb'),
548
- el('span', { class: 'grow' }, [el('strong', { text: issue.title }),
549
- el('span', { class: 'row-meta' }, [
550
- el('small', { class: 'muted', text: `${screenName(issue.screenId)} · ×${issue.occurrences}` }),
551
- ...issueChips(issue)])]),
552
- severityChip(issue.severity),
553
- el('span', { class: 'muted', text: issueStatus(issue, issue.fix) }),
554
- ]))) : el('p', { class: 'empty', text: 'No open issues. The agents found nothing wrong yet.' }),
636
+ const agents = el(
637
+ 'div',
638
+ { class: 'agent-grid' },
639
+ data.agents.map((agent) =>
640
+ el('article', { class: 'card agent-card' }, [
641
+ el('div', { class: 'agent-head' }, [
642
+ roleIcon(agent.role),
643
+ el('strong', { text: capital(agent.role) }),
644
+ el('span', { class: `state-dot ${agent.state}`, title: agent.state }),
645
+ ]),
646
+ el('div', { class: 'muted', text: agent.runtime || 'No runtime configured' }),
647
+ el('p', { text: agent.activity || capital(agent.state) }),
648
+ // Bugpatrol sees only the API calls that it makes. A local agent CLI
649
+ // bills the user's own plan, so its cost is not in this number.
650
+ el('div', {
651
+ class: 'muted',
652
+ title: tokenDetail(data.usage?.todayByRole?.[agent.role]),
653
+ text: `${tokens(data.usage?.todayByRole?.[agent.role])} today`,
654
+ }),
655
+ el('div', {
656
+ class: 'muted',
657
+ text:
658
+ `API ${money(agent.spentUsd)} today` +
659
+ (String(agent.runtime).startsWith('cli') ? ' · the model runs on your CLI plan' : ''),
660
+ }),
661
+ ]),
662
+ ),
663
+ );
664
+ const attention = el('section', { class: 'card panel' }, [
665
+ title('Needs attention', `${data.counts.issuesOpen} open`),
666
+ data.attention.length
667
+ ? el(
668
+ 'div',
669
+ { class: 'stack' },
670
+ data.attention.map((issue) =>
671
+ el('button', { class: 'attention-row', onclick: () => openIssue(issue.id) }, [
672
+ image(issue.evidence?.screenshot, issue.title, 'thumb'),
673
+ el('span', { class: 'grow' }, [
674
+ el('strong', { text: issue.title }),
675
+ el('span', { class: 'row-meta' }, [
676
+ el('small', { class: 'muted', text: `${screenName(issue.screenId)} · ×${issue.occurrences}` }),
677
+ ...issueChips(issue),
678
+ ]),
679
+ ]),
680
+ severityChip(issue.severity),
681
+ el('span', { class: 'muted', text: issueStatus(issue, issue.fix) }),
682
+ ]),
683
+ ),
684
+ )
685
+ : el('p', { class: 'empty', text: 'No open issues. The agents found nothing wrong yet.' }),
555
686
  ]);
556
687
  const live = data.live;
557
688
  const latest = data.recentSessions[0];
558
- const livePanel = el('section', { class: 'card panel' }, [title('Live', live ? 'Session running' : 'Quiet now'),
559
- live ? el('div', {}, [
560
- image(live.screenshot, 'Newest session capture', 'live-shot'),
561
- el('div', { class: 'event-list' }, live.events.map(renderEventRow)),
562
- ]) : el('div', { class: 'quiet' }, [
563
- el('p', { text: latest
564
- ? `Last: the ${latest.role}, ${relativeTime(latest.startedAt)}. ${firstSentence(latest.summary)}`
565
- : 'No sessions yet.' }),
566
- data.patrol?.nextAt ? el('p', { class: 'muted',
567
- text: `Next patrol at ${new Date(data.patrol.nextAt).toLocaleTimeString([], {
568
- hour: '2-digit', minute: '2-digit',
569
- })}${data.patrol.commit ? ` · last tested commit ${data.patrol.commit.slice(0, 7)}` : ''}`,
570
- }) : null,
571
- ]),
689
+ const livePanel = el('section', { class: 'card panel' }, [
690
+ title('Live', live ? 'Session running' : 'Quiet now'),
691
+ live
692
+ ? el('div', {}, [
693
+ image(live.screenshot, 'Newest session capture', 'live-shot'),
694
+ el('div', { class: 'event-list' }, live.events.map(renderEventRow)),
695
+ ])
696
+ : el('div', { class: 'quiet' }, [
697
+ el('p', {
698
+ text: latest
699
+ ? `Last: the ${latest.role}, ${relativeTime(latest.startedAt)}. ${firstSentence(latest.summary)}`
700
+ : 'No sessions yet.',
701
+ }),
702
+ data.patrol?.nextAt
703
+ ? el('p', {
704
+ class: 'muted',
705
+ text: `Next patrol at ${new Date(data.patrol.nextAt).toLocaleTimeString([], {
706
+ hour: '2-digit',
707
+ minute: '2-digit',
708
+ })}${data.patrol.commit ? ` · last tested commit ${data.patrol.commit.slice(0, 7)}` : ''}`,
709
+ })
710
+ : null,
711
+ ]),
572
712
  ]);
573
713
  const coverage = el('section', { class: 'card panel coverage' }, [
574
714
  title('Coverage', `${data.counts.screens} screens found`),
575
715
  data.github ? el('div', { class: 'muted github-summary', text: `GitHub · ${data.github}` }) : null,
576
- el('div', { class: 'coverage-row' }, data.screens.map((screen) =>
577
- el('button', { class: 'coverage-item', onclick: () => openScreen(screen.id) }, [
578
- image(screen.lastScreenshot, screen.name), el('span', { text: screen.name }),
579
- ]))),
716
+ el(
717
+ 'div',
718
+ { class: 'coverage-row' },
719
+ data.screens.map((screen) =>
720
+ el('button', { class: 'coverage-item', onclick: () => openScreen(screen.id) }, [
721
+ image(screen.lastScreenshot, screen.name),
722
+ el('span', { text: screen.name }),
723
+ ]),
724
+ ),
725
+ ),
726
+ ]);
727
+ return el('div', {}, [
728
+ agents,
729
+ el('div', { class: 'overview-grid' }, [attention, livePanel]),
730
+ coverage,
731
+ renderUsage(data.usage),
580
732
  ]);
581
- return el('div', {}, [agents, el('div', { class: 'overview-grid' }, [attention, livePanel]), coverage, renderUsage(data.usage)]);
582
733
  }
583
734
 
584
735
  function screenName(id) {
585
- return state.appmap?.screens.find((screen) => screen.id === id)?.name ||
586
- state.overview?.screens.find((screen) => screen.id === id)?.name || id || 'Unknown screen';
736
+ return (
737
+ state.appmap?.screens.find((screen) => screen.id === id)?.name ||
738
+ state.overview?.screens.find((screen) => screen.id === id)?.name ||
739
+ id ||
740
+ 'Unknown screen'
741
+ );
587
742
  }
588
743
 
589
744
  function capital(value) {
@@ -599,29 +754,54 @@ async function openIssue(id) {
599
754
  function renderIssues() {
600
755
  const filters = ['open', 'fixed', 'dismissed', 'all'];
601
756
  const filtered = visibleIssues();
602
- const list = el('div', { class: 'card panel' }, [title('Issues', `${filtered.length}`),
603
- el('div', { class: 'filters' }, filters.map((filter) => el('button', {
604
- class: `filter ${state.issueFilter === filter ? 'active' : ''}`, text: capital(filter),
605
- onclick: async () => { state.issueFilter = filter; await refresh(); },
606
- }))),
607
- ...filtered.map((issue) => el('button', { class: `list-row ${issue.id === state.selectedIssueId ? 'active' : ''}`,
608
- onclick: () => openIssue(issue.id) }, [
609
- // The chips sit under the title, so a long title keeps the row's width.
610
- el('span', { class: 'grow' }, [el('strong', { text: issue.title }),
611
- el('span', { class: 'row-meta' }, [
612
- el('small', { class: 'muted', text: `${screenName(issue.screenId)} · ${relativeTime(issue.lastSeenAt)}` }),
613
- ...issueChips(issue)])]),
614
- severityChip(issue.severity),
615
- ])),
757
+ const list = el('div', { class: 'card panel' }, [
758
+ title('Issues', `${filtered.length}`),
759
+ el(
760
+ 'div',
761
+ { class: 'filters' },
762
+ filters.map((filter) =>
763
+ el('button', {
764
+ class: `filter ${state.issueFilter === filter ? 'active' : ''}`,
765
+ text: capital(filter),
766
+ onclick: async () => {
767
+ state.issueFilter = filter;
768
+ await refresh();
769
+ },
770
+ }),
771
+ ),
772
+ ),
773
+ ...filtered.map((issue) =>
774
+ el(
775
+ 'button',
776
+ { class: `list-row ${issue.id === state.selectedIssueId ? 'active' : ''}`, onclick: () => openIssue(issue.id) },
777
+ [
778
+ // The chips sit under the title, so a long title keeps the row's width.
779
+ el('span', { class: 'grow' }, [
780
+ el('strong', { text: issue.title }),
781
+ el('span', { class: 'row-meta' }, [
782
+ el('small', {
783
+ class: 'muted',
784
+ text: `${screenName(issue.screenId)} · ${relativeTime(issue.lastSeenAt)}`,
785
+ }),
786
+ ...issueChips(issue),
787
+ ]),
788
+ ]),
789
+ severityChip(issue.severity),
790
+ ],
791
+ ),
792
+ ),
616
793
  ]);
617
794
  return el('div', { class: 'master-detail' }, [list, renderIssueDetail()]);
618
795
  }
619
796
 
620
797
  function visibleIssues() {
621
- return state.issues.filter((issue) => state.issueFilter === 'all' ||
622
- (state.issueFilter === 'open'
623
- ? !['fixed', 'dismissed'].includes(issue.status)
624
- : issue.status === state.issueFilter));
798
+ return state.issues.filter(
799
+ (issue) =>
800
+ state.issueFilter === 'all' ||
801
+ (state.issueFilter === 'open'
802
+ ? !['fixed', 'dismissed'].includes(issue.status)
803
+ : issue.status === state.issueFilter),
804
+ );
625
805
  }
626
806
 
627
807
  function renderIssueDetail() {
@@ -629,30 +809,54 @@ function renderIssueDetail() {
629
809
  if (!detail) return el('section', { class: 'card panel empty', text: 'Select an issue.' });
630
810
  const { issue, fix, candidates } = detail;
631
811
  const evidence = issue.evidence ?? {};
632
- const shots = [['Screenshot', evidence.screenshot], ['Baseline', evidence.baseline], ['Diff', evidence.diff]]
812
+ const shots = [
813
+ ['Screenshot', evidence.screenshot],
814
+ ['Baseline', evidence.baseline],
815
+ ['Diff', evidence.diff],
816
+ ]
633
817
  .filter(([, path]) => path)
634
818
  .map(([label, path]) => el('figure', {}, [image(path, label), el('figcaption', { text: label })]));
635
819
  const steps = evidence.steps ?? detail.routine?.steps ?? [];
636
820
  return el('article', { class: 'card panel issue-detail' }, [
637
821
  el('div', { class: 'detail-heading' }, [el('h1', { text: issue.title }), severityChip(issue.severity)]),
638
- fix?.pr || issue.github || issue.publishSkipped ? el('div', { class: 'kv' }, [
639
- ...issueChips({ pr: fix?.pr, github: issue.github }),
640
- issue.publishSkipped ? el('span', { class: 'muted', text: `Not published: ${issue.publishSkipped.reason}` }) : null,
641
- ]) : null,
642
- issue.fixRejected ? el('p', { class: 'muted',
643
- text: `The team closed PR #${issue.fixRejected.pr} without a merge. Bugpatrol will not propose this change again.` }) : null,
644
- el('div', { class: 'kv' }, [el('span', { text: issueStatus(issue, fix) }),
645
- el('span', { text: screenName(issue.screenId) }), el('span', { text: `×${issue.occurrences}` }),
822
+ fix?.pr || issue.github || issue.publishSkipped
823
+ ? el('div', { class: 'kv' }, [
824
+ ...issueChips({ pr: fix?.pr, github: issue.github }),
825
+ issue.publishSkipped
826
+ ? el('span', { class: 'muted', text: `Not published: ${issue.publishSkipped.reason}` })
827
+ : null,
828
+ ])
829
+ : null,
830
+ issue.fixRejected
831
+ ? el('p', {
832
+ class: 'muted',
833
+ text: `The team closed PR #${issue.fixRejected.pr} without a merge. Bugpatrol will not propose this change again.`,
834
+ })
835
+ : null,
836
+ el('div', { class: 'kv' }, [
837
+ el('span', { text: issueStatus(issue, fix) }),
838
+ el('span', { text: screenName(issue.screenId) }),
839
+ el('span', { text: `×${issue.occurrences}` }),
646
840
  el('span', { text: `First ${relativeTime(issue.firstSeenAt)}` }),
647
- el('span', { text: `Last ${relativeTime(issue.lastSeenAt)}` })]),
841
+ el('span', { text: `Last ${relativeTime(issue.lastSeenAt)}` }),
842
+ ]),
648
843
  markdown(issue.body),
649
844
  shots.length ? el('div', { class: 'evidence-grid' }, shots) : null,
650
845
  // The judge writes the clean steps in the body. The recorded path is the
651
846
  // exact replay, detours included, so it is one click away.
652
847
  el('details', { class: 'recorded-path' }, [
653
- el('summary', { text: `Recorded path: ${evidence.routineId ? `the routine ${evidence.routineId}, then ` : ''}`
654
- + `${steps.length} step(s)` }),
655
- steps.length ? el('ol', {}, steps.map((step) => el('li', { text: stepWords(step) }))) : null,
848
+ el('summary', {
849
+ text:
850
+ `Recorded path: ${evidence.routineId ? `the routine ${evidence.routineId}, then ` : ''}` +
851
+ `${steps.length} step(s)`,
852
+ }),
853
+ steps.length
854
+ ? el(
855
+ 'ol',
856
+ {},
857
+ steps.map((step) => el('li', { text: stepWords(step) })),
858
+ )
859
+ : null,
656
860
  ]),
657
861
  title("Judge's reasoning"),
658
862
  el('p', { text: `${issue.judgement.by}: ${issue.judgement.reason}` }),
@@ -662,34 +866,52 @@ function renderIssueDetail() {
662
866
  candidates.length
663
867
  ? el('p', { class: 'muted', text: `From ${candidates.map((candidate) => candidate.id).join(', ')}` })
664
868
  : null,
665
- fix ? el('section', { class: 'fix' }, [
666
- el('div', { class: 'section-title' }, [el('h2', { text: 'Proposed fix' }), fixBadge(fix)]),
667
- markdown(fix.summary || 'No summary yet.'),
668
- ...(fix.retests?.length ? [retestBlock(fix.retests.at(-1), issue),
669
- fix.retests.length > 1 ? el('details', { class: 'earlier-retests' }, [
670
- el('summary', { text: `Earlier attempts (${fix.retests.length - 1})` }),
671
- ...fix.retests.slice(0, -1).reverse().map((retest) => retestBlock(retest, issue)),
672
- ]) : null] : []),
673
- title('Code change'),
674
- fix.diffStat ? el('pre', { text: fix.diffStat }) : null,
675
- // Collapsed: the verdict and the screenshots come first; the diff is
676
- // one click away for the reader who reviews the code.
677
- fix.diff ? el('details', { class: 'diff-details' }, [
678
- el('summary', { text: `Show the diff (${fix.diff.split(/^diff --git /m).length - 1} file(s))` }),
679
- diffBlock(fix.diff),
680
- ]) : null,
681
- el('div', { class: 'kv' }, [el('span', { text: fix.branch }),
682
- fix.worktreeRemovedAt
683
- ? el('span', { text: `Worktree removed ${new Date(fix.worktreeRemovedAt).toLocaleString()}` })
684
- : el('code', { text: fix.worktree })]),
685
- ]) : null,
869
+ fix
870
+ ? el('section', { class: 'fix' }, [
871
+ el('div', { class: 'section-title' }, [el('h2', { text: 'Proposed fix' }), fixBadge(fix)]),
872
+ markdown(fix.summary || 'No summary yet.'),
873
+ ...(fix.retests?.length
874
+ ? [
875
+ retestBlock(fix.retests.at(-1), issue),
876
+ fix.retests.length > 1
877
+ ? el('details', { class: 'earlier-retests' }, [
878
+ el('summary', { text: `Earlier attempts (${fix.retests.length - 1})` }),
879
+ ...fix.retests
880
+ .slice(0, -1)
881
+ .reverse()
882
+ .map((retest) => retestBlock(retest, issue)),
883
+ ])
884
+ : null,
885
+ ]
886
+ : []),
887
+ title('Code change'),
888
+ fix.diffStat ? el('pre', { text: fix.diffStat }) : null,
889
+ // Collapsed: the verdict and the screenshots come first; the diff is
890
+ // one click away for the reader who reviews the code.
891
+ fix.diff
892
+ ? el('details', { class: 'diff-details' }, [
893
+ el('summary', { text: `Show the diff (${fix.diff.split(/^diff --git /m).length - 1} file(s))` }),
894
+ diffBlock(fix.diff),
895
+ ])
896
+ : null,
897
+ el('div', { class: 'kv' }, [
898
+ el('span', { text: fix.branch }),
899
+ fix.worktreeRemovedAt
900
+ ? el('span', { text: `Worktree removed ${new Date(fix.worktreeRemovedAt).toLocaleString()}` })
901
+ : el('code', { text: fix.worktree }),
902
+ ]),
903
+ ])
904
+ : null,
686
905
  ]);
687
906
  }
688
907
 
689
908
  /** The first sentence, for a list row. Long agent summaries belong in the detail. */
690
909
  function firstSentence(text) {
691
910
  if (!text) return '';
692
- const plain = text.replace(/[*`#>]/g, '').replace(/\s+/g, ' ').trim();
911
+ const plain = text
912
+ .replace(/[*`#>]/g, '')
913
+ .replace(/\s+/g, ' ')
914
+ .trim();
693
915
  const match = plain.match(/^(.{20,160}?[.!?])(\s|$)/);
694
916
  const sentence = match ? match[1] : plain;
695
917
  return sentence.length > 160 ? `${sentence.slice(0, 157)}...` : sentence;
@@ -702,7 +924,8 @@ function issueStatus(issue, fix) {
702
924
  if (issue.status === 'new' && issue.regression) return 'Regression';
703
925
  if (fix?.status === 'verified') return 'Fix verified in the app';
704
926
  if (fix?.status === 'retesting') return 'Retesting the fix';
705
- if (fix?.status === 'proposed' && fix.retests?.at(-1)?.outcome === 'not-fixed') return 'Fix not verified';
927
+ if (fix?.status === 'proposed' && ['not-fixed', 'unclear'].includes(fix.retests?.at(-1)?.outcome))
928
+ return 'Fix not verified';
706
929
  if (fix && ['proposed', 'verified', 'opened'].includes(fix.status)) return 'Fix proposed';
707
930
  // The fixer read the code and found nothing to fix: a human should look.
708
931
  if (fix && fix.status === 'declined') return 'Fixer: not a bug?';
@@ -722,41 +945,72 @@ function stepWords(step) {
722
945
  }
723
946
 
724
947
  function diffBlock(diff) {
725
- return el('div', { class: 'diff-files' }, diff.split(/(?=^diff --git )/m).filter(Boolean).map((section) => {
726
- const lines = section.split('\n');
727
- const file = lines[0]?.match(/^diff --git a\/.* b\/(.*)$/)?.[1] ?? 'Patch';
728
- return el('section', { class: 'diff-file' }, [el('h3', { text: file }),
729
- el('pre', { class: 'diff-block' }, lines.map((line) => el('span', {
730
- class: line.startsWith('+') && !line.startsWith('+++') ? 'added'
731
- : line.startsWith('-') && !line.startsWith('---') ? 'removed'
732
- : line.startsWith('@@') ? 'hunk' : '', text: line || ' ',
733
- })))]);
734
- }));
948
+ return el(
949
+ 'div',
950
+ { class: 'diff-files' },
951
+ diff
952
+ .split(/(?=^diff --git )/m)
953
+ .filter(Boolean)
954
+ .map((section) => {
955
+ const lines = section.split('\n');
956
+ const file = lines[0]?.match(/^diff --git a\/.* b\/(.*)$/)?.[1] ?? 'Patch';
957
+ return el('section', { class: 'diff-file' }, [
958
+ el('h3', { text: file }),
959
+ el(
960
+ 'pre',
961
+ { class: 'diff-block' },
962
+ lines.map((line) =>
963
+ el('span', {
964
+ class:
965
+ line.startsWith('+') && !line.startsWith('+++')
966
+ ? 'added'
967
+ : line.startsWith('-') && !line.startsWith('---')
968
+ ? 'removed'
969
+ : line.startsWith('@@')
970
+ ? 'hunk'
971
+ : '',
972
+ text: line || ' ',
973
+ }),
974
+ ),
975
+ ),
976
+ ]);
977
+ }),
978
+ );
735
979
  }
736
980
 
737
981
  function fixBadge(fix) {
738
982
  const last = fix.retests?.at(-1);
739
- const label = fix.status === 'verified' ? 'Verified in the app'
740
- : fix.status === 'retesting' ? 'Retesting…'
741
- : fix.status === 'declined' ? 'Declined'
742
- : fix.status === 'failed' ? 'Failed'
743
- : last && last.outcome !== 'fixed' ? 'Not verified' : 'Proposed';
983
+ const label =
984
+ fix.status === 'verified'
985
+ ? 'Verified in the app'
986
+ : fix.status === 'retesting'
987
+ ? 'Retesting…'
988
+ : fix.status === 'declined'
989
+ ? 'Declined'
990
+ : fix.status === 'failed'
991
+ ? 'Failed'
992
+ : last && last.outcome !== 'fixed'
993
+ ? 'Not verified'
994
+ : 'Proposed';
744
995
  const kind = fix.status === 'verified' ? 'pass' : ['declined', 'failed'].includes(fix.status) ? 'fail' : 'warn';
745
996
  return el('span', { class: `badge ${kind}`, text: label });
746
997
  }
747
998
 
748
999
  function retestBlock(retest, issue) {
749
1000
  const before = retest.before ?? issue.evidence?.screenshot;
750
- const pair = (shot) => el('div', { class: 'retest-shot-pair' }, [
751
- retest.shots?.length ? el('h4', { text: screenName(shot.screenId) }) : null,
752
- shot.reached === false ? el('span', { class: 'muted', text: 'Not reached' }) : null,
753
- shot.note ? el('p', { class: 'muted', text: shot.note }) : null,
754
- el('div', { class: 'retest-shots' }, [
755
- el('figure', {}, [image(shot.before, 'Before'), el('figcaption', { text: 'Before' })]),
756
- el('figure', {}, [image(shot.after, 'After (with the fix)'),
757
- el('figcaption', { text: 'After (with the fix)' })]),
758
- ]),
759
- ]);
1001
+ const pair = (shot) =>
1002
+ el('div', { class: 'retest-shot-pair' }, [
1003
+ retest.shots?.length ? el('h4', { text: screenName(shot.screenId) }) : null,
1004
+ shot.reached === false ? el('span', { class: 'muted', text: 'Not reached' }) : null,
1005
+ shot.note ? el('p', { class: 'muted', text: shot.note }) : null,
1006
+ el('div', { class: 'retest-shots' }, [
1007
+ el('figure', {}, [image(shot.before, 'Before'), el('figcaption', { text: 'Before' })]),
1008
+ el('figure', {}, [
1009
+ image(shot.after, 'After (with the fix)'),
1010
+ el('figcaption', { text: 'After (with the fix)' }),
1011
+ ]),
1012
+ ]),
1013
+ ]);
760
1014
  return el('div', { class: 'retest-block' }, [
761
1015
  el('h3', { text: `Retest ${retest.attempt}: ${retest.outcome}` }),
762
1016
  el('p', { text: retest.reason }),
@@ -773,12 +1027,17 @@ function markdown(source = '') {
773
1027
  let code = null;
774
1028
  for (const line of lines) {
775
1029
  if (line.startsWith('```')) {
776
- if (code) { container.append(code); code = null; }
777
- else code = el('pre', {}, [el('code')]);
1030
+ if (code) {
1031
+ container.append(code);
1032
+ code = null;
1033
+ } else code = el('pre', {}, [el('code')]);
778
1034
  list = null;
779
1035
  continue;
780
1036
  }
781
- if (code) { code.firstChild.textContent += `${line}\n`; continue; }
1037
+ if (code) {
1038
+ code.firstChild.textContent += `${line}\n`;
1039
+ continue;
1040
+ }
782
1041
  const match = line.match(/^\s*(?:[-*]|\d+\.) (.*)$/);
783
1042
  if (match) {
784
1043
  const tag = /^\s*\d+\./.test(line) ? 'ol' : 'ul';
@@ -810,41 +1069,76 @@ function inlineMarkdown(line) {
810
1069
  }
811
1070
 
812
1071
  function renderActivity() {
813
- const list = el('section', { class: 'card panel' }, [title('Activity'),
814
- ...state.sessions.map((session) => el('button', {
815
- class: `list-row ${session.id === state.selectedSessionId ? 'active' : ''}`,
816
- onclick: async () => { state.selectedSessionId = session.id; await refresh(); } }, [
817
- roleIcon(session.role), el('span', { class: 'grow' }, [
818
- el('strong', { text: firstSentence(session.summary) || `${capital(session.role)} session` }),
819
- el('small', { class: 'muted',
820
- text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)} · ${session.steps} steps · ` +
821
- `${session.tokens ? `${tokens(session.tokens)} · ` : ''}API ${money(session.costUsd)} · `
822
- + `${session.screensFound.length} screens · ${session.issues.length} issues`,
823
- }),
824
- ]),
825
- ])),
1072
+ const list = el('section', { class: 'card panel' }, [
1073
+ title('Activity'),
1074
+ ...state.sessions.map((session) =>
1075
+ el(
1076
+ 'button',
1077
+ {
1078
+ class: `list-row ${session.id === state.selectedSessionId ? 'active' : ''}`,
1079
+ onclick: async () => {
1080
+ state.selectedSessionId = session.id;
1081
+ await refresh();
1082
+ },
1083
+ },
1084
+ [
1085
+ roleIcon(session.role),
1086
+ el('span', { class: 'grow' }, [
1087
+ el('strong', { text: firstSentence(session.summary) || `${capital(session.role)} session` }),
1088
+ el('small', {
1089
+ class: 'muted',
1090
+ text:
1091
+ `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)} · ${session.steps} steps · ` +
1092
+ `${session.tokens ? `${tokens(session.tokens)} · ` : ''}API ${money(session.costUsd)} · ` +
1093
+ `${session.screensFound.length} screens · ${session.issues.length} issues`,
1094
+ }),
1095
+ ]),
1096
+ ],
1097
+ ),
1098
+ ),
826
1099
  ]);
827
1100
  const detail = state.sessionDetail;
828
- const timeline = detail ? el('section', { class: 'card panel' }, [
829
- title('Timeline', `${capital(detail.session.role)} · ${relativeTime(detail.session.startedAt)}`),
830
- detail.session.summary ? el('div', { class: 'session-summary' }, [markdown(detail.session.summary)]) : null,
831
- el('div', { class: 'muted session-usage' }, detail.session.tokensByModel
832
- ? Object.entries(detail.session.tokensByModel).map(([model, usage]) =>
833
- el('div', { title: tokenDetail(usage), text: `${model}: ${tokens(usage)}` }))
834
- : [el('div', { text: 'No token usage reported for this session.' })]),
835
- el('label', { class: 'toggle' }, [el('input', {
836
- type: 'checkbox',
837
- ...(state.showThoughts ? { checked: '' } : {}),
838
- onchange: (event) => { state.showThoughts = event.target.checked; render(); },
839
- }), el('span', { text: 'Show agent reasoning' })]),
840
- el('div', { class: 'event-list' }, detail.events.filter((event) => state.showThoughts || isFeedEvent(event))
841
- .map(renderEventRow)),
842
- detail.candidates.length ? title('Candidates') : null,
843
- ...detail.candidates.map((candidate) => el('div', { class: 'candidate' }, [
844
- el('strong', { text: candidate.summary }),
845
- el('span', { class: 'muted', text: `${candidate.route?.to || 'unrouted'} · ${candidate.route?.reason || ''}` }),
846
- ])),
847
- ]) : el('section', { class: 'card panel empty', text: 'Select a session.' });
1101
+ const timeline = detail
1102
+ ? el('section', { class: 'card panel' }, [
1103
+ title('Timeline', `${capital(detail.session.role)} · ${relativeTime(detail.session.startedAt)}`),
1104
+ detail.session.summary ? el('div', { class: 'session-summary' }, [markdown(detail.session.summary)]) : null,
1105
+ el(
1106
+ 'div',
1107
+ { class: 'muted session-usage' },
1108
+ detail.session.tokensByModel
1109
+ ? Object.entries(detail.session.tokensByModel).map(([model, usage]) =>
1110
+ el('div', { title: tokenDetail(usage), text: `${model}: ${tokens(usage)}` }),
1111
+ )
1112
+ : [el('div', { text: 'No token usage reported for this session.' })],
1113
+ ),
1114
+ el('label', { class: 'toggle' }, [
1115
+ el('input', {
1116
+ type: 'checkbox',
1117
+ ...(state.showThoughts ? { checked: '' } : {}),
1118
+ onchange: (event) => {
1119
+ state.showThoughts = event.target.checked;
1120
+ render();
1121
+ },
1122
+ }),
1123
+ el('span', { text: 'Show agent reasoning' }),
1124
+ ]),
1125
+ el(
1126
+ 'div',
1127
+ { class: 'event-list' },
1128
+ detail.events.filter((event) => state.showThoughts || isFeedEvent(event)).map(renderEventRow),
1129
+ ),
1130
+ detail.candidates.length ? title('Candidates') : null,
1131
+ ...detail.candidates.map((candidate) =>
1132
+ el('div', { class: 'candidate' }, [
1133
+ el('strong', { text: candidate.summary }),
1134
+ el('span', {
1135
+ class: 'muted',
1136
+ text: `${candidate.route?.to || 'unrouted'} · ${candidate.route?.reason || ''}`,
1137
+ }),
1138
+ ]),
1139
+ ),
1140
+ ])
1141
+ : el('section', { class: 'card panel empty', text: 'Select a session.' });
848
1142
  return el('div', { class: 'master-detail' }, [list, timeline]);
849
1143
  }
850
1144
 
@@ -857,15 +1151,94 @@ function duration(session) {
857
1151
  function renderEventRow(event) {
858
1152
  return el('div', { class: 'event-row' }, [
859
1153
  el('time', { text: new Date(event.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }),
860
- roleIcon(event.role), el('span', { class: 'grow truncate', text: event.summary }),
1154
+ roleIcon(event.role),
1155
+ el('span', { class: 'grow truncate', text: event.summary }),
861
1156
  event.tokens && event.kind !== 'usage'
862
- ? el('small', { class: 'muted token-chip', title: `${event.model ?? ''} ${tokenDetail(event.tokens)}`.trim(), text: tokens(event.tokens, '') })
1157
+ ? el('small', {
1158
+ class: 'muted token-chip',
1159
+ title: `${event.model ?? ''} ${tokenDetail(event.tokens)}`.trim(),
1160
+ text: tokens(event.tokens, ''),
1161
+ })
863
1162
  : null,
864
1163
  event.screenshot ? image(event.screenshot, event.summary, 'event-thumb') : null,
865
- event.input !== undefined || event.output !== undefined ? el('details', {}, [
866
- el('summary', { text: 'Tool data' }),
867
- el('pre', { text: JSON.stringify({ input: event.input, output: event.output }, null, 2) }),
868
- ]) : null,
1164
+ event.input !== undefined || event.output !== undefined
1165
+ ? el('details', {}, [
1166
+ el('summary', { text: 'Tool data' }),
1167
+ el('pre', { text: JSON.stringify({ input: event.input, output: event.output }, null, 2) }),
1168
+ ])
1169
+ : null,
1170
+ ]);
1171
+ }
1172
+
1173
+ // The flow view is what a human reads when triaging: the agent's actions, the
1174
+ // failed requests the app reported, and the backend logs that followed, merged
1175
+ // in time order. A log close behind a failure is marked correlated.
1176
+ function renderFlow() {
1177
+ const list = el('section', { class: 'card panel' }, [
1178
+ title('Flow'),
1179
+ ...state.sessions.map((session) =>
1180
+ el(
1181
+ 'button',
1182
+ {
1183
+ class: `list-row ${session.id === state.selectedSessionId ? 'active' : ''}`,
1184
+ onclick: async () => {
1185
+ state.selectedSessionId = session.id;
1186
+ await refresh();
1187
+ },
1188
+ },
1189
+ [
1190
+ roleIcon(session.role),
1191
+ el('span', { class: 'grow' }, [
1192
+ el('strong', { text: firstSentence(session.summary) || `${capital(session.role)} session` }),
1193
+ el('small', {
1194
+ class: 'muted',
1195
+ text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)}`,
1196
+ }),
1197
+ ]),
1198
+ ],
1199
+ ),
1200
+ ),
1201
+ ]);
1202
+ const flow = state.flow;
1203
+ if (!flow) {
1204
+ return el('div', { class: 'master-detail' }, [
1205
+ list,
1206
+ el('section', {
1207
+ class: 'card panel empty',
1208
+ text: 'No flow for this session. Add a `logs:` source to bugpatrol.yml and run a patrol.',
1209
+ }),
1210
+ ]);
1211
+ }
1212
+ const sources = flow.sources.length
1213
+ ? flow.sources.map((source) =>
1214
+ el('div', {
1215
+ text: `${source.name} (${source.kind}): ${source.collected} line${source.collected === 1 ? '' : 's'}`,
1216
+ }),
1217
+ )
1218
+ : [
1219
+ el('div', {
1220
+ text: 'No log sources configured. Add `logs:` to bugpatrol.yml to see backend logs beside each action.',
1221
+ }),
1222
+ ];
1223
+ return el('div', { class: 'master-detail' }, [
1224
+ list,
1225
+ el('section', { class: 'card panel' }, [
1226
+ title('Flow', `${capital(flow.role)} · ${relativeTime(flow.startedAt)}`),
1227
+ el('div', { class: 'muted session-usage' }, sources),
1228
+ flow.entries.length
1229
+ ? el('div', { class: 'event-list' }, flow.entries.map(renderFlowRow))
1230
+ : el('div', { class: 'muted', text: 'This session recorded nothing.' }),
1231
+ ]),
1232
+ ]);
1233
+ }
1234
+
1235
+ function renderFlowRow(entry) {
1236
+ return el('div', { class: `event-row flow-row flow-${entry.kind}${entry.correlated ? ' correlated' : ''}` }, [
1237
+ el('time', { text: new Date(entry.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }),
1238
+ el('span', { class: 'flow-kind', text: entry.kind }),
1239
+ el('span', { class: 'grow truncate', text: entry.summary }),
1240
+ entry.level ? el('small', { class: 'muted', text: entry.level }) : null,
1241
+ entry.screenshot ? image(entry.screenshot, entry.summary, 'event-thumb') : null,
869
1242
  ]);
870
1243
  }
871
1244
 
@@ -882,129 +1255,276 @@ function renderScreens() {
882
1255
  const selected = screens.find((screen) => screen.id === state.selectedScreenId);
883
1256
  if (selected) {
884
1257
  const routine = state.routines.find((entry) => entry.id === selected.routineId);
885
- const issues = state.issues.filter((issue) =>
886
- issue.screenId === selected.id && !['fixed', 'dismissed'].includes(issue.status));
1258
+ const issues = state.issues.filter(
1259
+ (issue) => issue.screenId === selected.id && !['fixed', 'dismissed'].includes(issue.status),
1260
+ );
887
1261
  return el('div', { class: 'card panel screen-detail' }, [
888
- el('button', { class: 'back', text: '← All screens',
889
- onclick: () => { state.selectedScreenId = null; render(); } }),
1262
+ el('button', {
1263
+ class: 'back',
1264
+ text: '← All screens',
1265
+ onclick: () => {
1266
+ state.selectedScreenId = null;
1267
+ render();
1268
+ },
1269
+ }),
890
1270
  title(selected.name, `${selected.visits} visits · ${relativeTime(selected.lastSeenAt)}`),
891
1271
  image(selected.lastScreenshot, selected.name, 'screen-large'),
892
1272
  el('p', { text: selected.description }),
893
1273
  title('Routine'),
894
- el('p', { text: routine
895
- ? `${routine.id} · ${routine.description} · ${routine.steps} steps`
896
- : selected.routineId || 'No routine' }),
897
- ...[['Comes from', edges.filter((edge) => edge.to === selected.id), 'from'],
898
- ['Goes to', edges.filter((edge) => edge.from === selected.id), 'to']].map(([heading, links, end]) =>
899
- el('section', { class: 'screen-links' }, [title(heading), ...links.map((edge) =>
900
- el('button', { class: 'list-row', onclick: () => openScreen(edge[end]) }, [
901
- el('strong', { text: screenName(edge[end]) }),
902
- edge.via ? el('small', { class: 'muted', text: edge.via }) : null,
903
- ]))])),
1274
+ el('p', {
1275
+ text: routine
1276
+ ? `${routine.id} · ${routine.description} · ${routine.steps} steps`
1277
+ : selected.routineId || 'No routine',
1278
+ }),
1279
+ ...[
1280
+ ['Comes from', edges.filter((edge) => edge.to === selected.id), 'from'],
1281
+ ['Goes to', edges.filter((edge) => edge.from === selected.id), 'to'],
1282
+ ].map(([heading, links, end]) =>
1283
+ el('section', { class: 'screen-links' }, [
1284
+ title(heading),
1285
+ ...links.map((edge) =>
1286
+ el('button', { class: 'list-row', onclick: () => openScreen(edge[end]) }, [
1287
+ el('strong', { text: screenName(edge[end]) }),
1288
+ edge.via ? el('small', { class: 'muted', text: edge.via }) : null,
1289
+ ]),
1290
+ ),
1291
+ ]),
1292
+ ),
904
1293
  title('Open issues', `${selected.openIssues}`),
905
- ...issues.map((issue) => el('button', { class: 'list-row', onclick: () => openIssue(issue.id) }, [
906
- el('strong', { text: issue.title }), severityChip(issue.severity),
907
- ])),
1294
+ ...issues.map((issue) =>
1295
+ el('button', { class: 'list-row', onclick: () => openIssue(issue.id) }, [
1296
+ el('strong', { text: issue.title }),
1297
+ severityChip(issue.severity),
1298
+ ]),
1299
+ ),
908
1300
  ]);
909
1301
  }
910
- const toggle = el('div', { class: 'screen-mode', role: 'group', 'aria-label': 'Screen view' },
911
- ['graph', 'grid'].map((mode) => el('button', { class: screenMode === mode ? 'active' : '',
912
- text: mode === 'graph' ? 'Graph' : 'Grid', 'aria-pressed': screenMode === mode,
913
- onclick: () => { screenMode = mode; try { localStorage.setItem('bugpatrol-screen-mode', mode); } catch { /* disabled */ } render(); },
914
- })));
915
- return el('div', {}, [title('Screens', `${screens.length} found`),
1302
+ const toggle = el(
1303
+ 'div',
1304
+ { class: 'screen-mode', role: 'group', 'aria-label': 'Screen view' },
1305
+ ['graph', 'grid'].map((mode) =>
1306
+ el('button', {
1307
+ class: screenMode === mode ? 'active' : '',
1308
+ text: mode === 'graph' ? 'Graph' : 'Grid',
1309
+ 'aria-pressed': screenMode === mode,
1310
+ onclick: () => {
1311
+ screenMode = mode;
1312
+ try {
1313
+ localStorage.setItem('bugpatrol-screen-mode', mode);
1314
+ } catch {
1315
+ /* disabled */
1316
+ }
1317
+ render();
1318
+ },
1319
+ }),
1320
+ ),
1321
+ );
1322
+ return el('div', {}, [
1323
+ title('Screens', `${screens.length} found`),
916
1324
  toggle,
917
- screens.length === 0 ? el('div', { class: 'empty', text: 'No screens found yet.' })
918
- : screenMode === 'graph' ? renderScreenGraph(graphScreens, edges) : el('div', { class: 'screen-grid' }, screens.map((screen) => el('button', {
919
- class: 'card screen-card', onclick: () => openScreen(screen.id),
920
- }, [image(screen.lastScreenshot, screen.name), el('div', { class: 'screen-copy' }, [
921
- el('strong', { text: screen.name }),
922
- el('p', { class: 'truncate muted', text: screen.description }),
923
- el('small', { class: 'muted', text: `${relativeTime(screen.lastSeenAt)} · ${screen.visits} visits` }),
924
- screen.openIssues ? el('span', { class: 'issue-count', text: `${screen.openIssues} open` }) : null,
925
- ])])))]);
1325
+ screens.length === 0
1326
+ ? el('div', { class: 'empty', text: 'No screens found yet.' })
1327
+ : screenMode === 'graph'
1328
+ ? renderScreenGraph(graphScreens, edges)
1329
+ : el(
1330
+ 'div',
1331
+ { class: 'screen-grid' },
1332
+ screens.map((screen) =>
1333
+ el(
1334
+ 'button',
1335
+ {
1336
+ class: 'card screen-card',
1337
+ onclick: () => openScreen(screen.id),
1338
+ },
1339
+ [
1340
+ image(screen.lastScreenshot, screen.name),
1341
+ el('div', { class: 'screen-copy' }, [
1342
+ el('strong', { text: screen.name }),
1343
+ el('p', { class: 'truncate muted', text: screen.description }),
1344
+ el('small', {
1345
+ class: 'muted',
1346
+ text: `${relativeTime(screen.lastSeenAt)} · ${screen.visits} visits`,
1347
+ }),
1348
+ screen.openIssues ? el('span', { class: 'issue-count', text: `${screen.openIssues} open` }) : null,
1349
+ ]),
1350
+ ],
1351
+ ),
1352
+ ),
1353
+ ),
1354
+ ]);
926
1355
  }
927
1356
 
928
1357
  function renderScreenGraph(screens, allEdges) {
929
1358
  const layout = layoutGraph(screens, allEdges, state.appmap?.entryId);
930
1359
  const key = `${screens.map((screen) => screen.id).join('|')}|${allEdges.length}`;
931
1360
  const canvas = svgEl('svg', { class: 'screen-graph', role: 'group', 'aria-label': 'Map of app screens' });
932
- const marker = svgEl('marker', { id: 'graph-arrow', markerWidth: 9, markerHeight: 9, refX: 8, refY: 4.5,
933
- orient: 'auto', markerUnits: 'userSpaceOnUse' }, [svgEl('path', { d: 'M 0 0 L 9 4.5 L 0 9 Z' })]);
934
- const mutedMarker = svgEl('marker', { id: 'graph-arrow-muted', markerWidth: 9, markerHeight: 9,
935
- refX: 8, refY: 4.5, orient: 'auto', markerUnits: 'userSpaceOnUse' },
936
- [svgEl('path', { d: 'M 0 0 L 9 4.5 L 0 9 Z' })]);
1361
+ const marker = svgEl(
1362
+ 'marker',
1363
+ {
1364
+ id: 'graph-arrow',
1365
+ markerWidth: 9,
1366
+ markerHeight: 9,
1367
+ refX: 8,
1368
+ refY: 4.5,
1369
+ orient: 'auto',
1370
+ markerUnits: 'userSpaceOnUse',
1371
+ },
1372
+ [svgEl('path', { d: 'M 0 0 L 9 4.5 L 0 9 Z' })],
1373
+ );
1374
+ const mutedMarker = svgEl(
1375
+ 'marker',
1376
+ {
1377
+ id: 'graph-arrow-muted',
1378
+ markerWidth: 9,
1379
+ markerHeight: 9,
1380
+ refX: 8,
1381
+ refY: 4.5,
1382
+ orient: 'auto',
1383
+ markerUnits: 'userSpaceOnUse',
1384
+ },
1385
+ [svgEl('path', { d: 'M 0 0 L 9 4.5 L 0 9 Z' })],
1386
+ );
937
1387
  canvas.append(svgEl('defs', {}, [marker, mutedMarker]));
938
- const edgeNodes = layout.edges.filter((edge) => showBackLinks || edge.kind !== 'back').map((edge) => {
939
- const path = svgEl('path', { d: edge.path, class: `graph-edge ${edge.kind}`,
940
- 'data-from': edge.from, 'data-to': edge.to,
941
- 'marker-end': `url(#graph-arrow${['route', 'other', 'back'].includes(edge.kind) ? '-muted' : ''})`,
942
- 'stroke-width': ['route', 'other'].includes(edge.kind) ? 1 : 1.5 + Math.min(4, Math.log2(edge.count || 1)) });
943
- path.append(svgEl('title', { text: edge.via || edge.kind }));
944
- return path;
945
- });
1388
+ const edgeNodes = layout.edges
1389
+ .filter((edge) => showBackLinks || edge.kind !== 'back')
1390
+ .map((edge) => {
1391
+ const path = svgEl('path', {
1392
+ d: edge.path,
1393
+ class: `graph-edge ${edge.kind}`,
1394
+ 'data-from': edge.from,
1395
+ 'data-to': edge.to,
1396
+ 'marker-end': `url(#graph-arrow${['route', 'other', 'back'].includes(edge.kind) ? '-muted' : ''})`,
1397
+ 'stroke-width': ['route', 'other'].includes(edge.kind) ? 1 : 1.5 + Math.min(4, Math.log2(edge.count || 1)),
1398
+ });
1399
+ path.append(svgEl('title', { text: edge.via || edge.kind }));
1400
+ return path;
1401
+ });
946
1402
  canvas.append(svgEl('g', { class: 'graph-edges' }, edgeNodes));
947
- if (layout.unlinkedX !== null) canvas.append(svgEl('text', { x: layout.unlinkedX, y: layout.unlinkedY + 20,
948
- class: 'graph-unlinked', text: 'Not linked' }));
1403
+ if (layout.unlinkedX !== null)
1404
+ canvas.append(
1405
+ svgEl('text', { x: layout.unlinkedX, y: layout.unlinkedY + 20, class: 'graph-unlinked', text: 'Not linked' }),
1406
+ );
949
1407
  const screenById = new Map(screens.map((screen) => [screen.id, screen]));
950
1408
  let dragMoved = false;
951
1409
  for (const node of layout.nodes) {
952
1410
  const screen = screenById.get(node.id);
953
1411
  if (screen.virtual) {
954
- canvas.append(svgEl('g', { class: 'graph-start', 'aria-label': 'App start' }, [
955
- svgEl('rect', { x: node.x, y: node.y, width: node.w, height: node.h, rx: node.h / 2 }),
956
- svgEl('text', { x: node.x + node.w / 2, y: node.y + node.h / 2 + 5,
957
- 'text-anchor': 'middle', text: 'App start' }),
958
- ]));
1412
+ canvas.append(
1413
+ svgEl('g', { class: 'graph-start', 'aria-label': 'App start' }, [
1414
+ svgEl('rect', { x: node.x, y: node.y, width: node.w, height: node.h, rx: node.h / 2 }),
1415
+ svgEl('text', {
1416
+ x: node.x + node.w / 2,
1417
+ y: node.y + node.h / 2 + 5,
1418
+ 'text-anchor': 'middle',
1419
+ text: 'App start',
1420
+ }),
1421
+ ]),
1422
+ );
959
1423
  continue;
960
1424
  }
961
- const card = svgEl('g', { class: 'graph-node', role: 'button', tabindex: 0,
1425
+ const card = svgEl('g', {
1426
+ class: 'graph-node',
1427
+ role: 'button',
1428
+ tabindex: 0,
962
1429
  'aria-label': `${screen.name}${screen.openIssues ? `, ${screen.openIssues} open issues` : ''}`,
963
- onclick: () => { if (!dragMoved) openScreen(node.id); },
964
- onkeydown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openScreen(node.id); } },
965
- onpointerenter: () => { for (const path of edgeNodes) path.classList.toggle('faded',
966
- path.getAttribute('data-from') !== node.id && path.getAttribute('data-to') !== node.id); },
967
- onpointerleave: () => edgeNodes.forEach((path) => path.classList.remove('faded')),
1430
+ onclick: () => {
1431
+ if (!dragMoved) openScreen(node.id);
1432
+ },
1433
+ onkeydown: (event) => {
1434
+ if (event.key === 'Enter' || event.key === ' ') {
1435
+ event.preventDefault();
1436
+ openScreen(node.id);
1437
+ }
1438
+ },
1439
+ onpointerenter: () => {
1440
+ for (const path of edgeNodes)
1441
+ path.classList.toggle(
1442
+ 'faded',
1443
+ path.getAttribute('data-from') !== node.id && path.getAttribute('data-to') !== node.id,
1444
+ );
1445
+ },
1446
+ onpointerleave: () => {
1447
+ for (const path of edgeNodes) path.classList.remove('faded');
1448
+ },
968
1449
  });
969
- card.append(svgEl('rect', { x: node.x, y: node.y, width: node.w, height: node.h, rx: 9,
970
- class: 'graph-card' }));
971
- if (screen.lastScreenshot) card.append(svgEl('image', { x: node.x + 5, y: node.y + 5,
972
- width: node.w - 10, height: 104, href: artifact(screen.lastScreenshot), preserveAspectRatio: 'xMidYMin slice' }));
973
- card.append(svgEl('text', { x: node.x + 10, y: node.y + 134, class: 'graph-name',
974
- text: screen.name.length > 24 ? `${screen.name.slice(0, 23)}…` : screen.name }));
1450
+ card.append(svgEl('rect', { x: node.x, y: node.y, width: node.w, height: node.h, rx: 9, class: 'graph-card' }));
1451
+ if (screen.lastScreenshot)
1452
+ card.append(
1453
+ svgEl('image', {
1454
+ x: node.x + 5,
1455
+ y: node.y + 5,
1456
+ width: node.w - 10,
1457
+ height: 104,
1458
+ href: artifact(screen.lastScreenshot),
1459
+ preserveAspectRatio: 'xMidYMin slice',
1460
+ }),
1461
+ );
1462
+ card.append(
1463
+ svgEl('text', {
1464
+ x: node.x + 10,
1465
+ y: node.y + 134,
1466
+ class: 'graph-name',
1467
+ text: screen.name.length > 24 ? `${screen.name.slice(0, 23)}…` : screen.name,
1468
+ }),
1469
+ );
975
1470
  if (screen.openIssues) {
976
1471
  card.append(svgEl('circle', { cx: node.x + node.w - 12, cy: node.y + 12, r: 12, class: 'graph-issue' }));
977
- card.append(svgEl('text', { x: node.x + node.w - 12, y: node.y + 16,
978
- 'text-anchor': 'middle', class: 'graph-issue-text', text: screen.openIssues }));
1472
+ card.append(
1473
+ svgEl('text', {
1474
+ x: node.x + node.w - 12,
1475
+ y: node.y + 16,
1476
+ 'text-anchor': 'middle',
1477
+ class: 'graph-issue-text',
1478
+ text: screen.openIssues,
1479
+ }),
1480
+ );
979
1481
  }
980
1482
  card.append(svgEl('title', { text: screen.name }));
981
1483
  canvas.append(card);
982
1484
  }
983
1485
  let box = graphCamera.key === key ? graphCamera.box : null;
984
- const setBox = (next) => { box = next; graphCamera.box = next; graphCamera.key = key;
985
- canvas.setAttribute('viewBox', `${next.x} ${next.y} ${next.w} ${next.h}`); };
1486
+ const setBox = (next) => {
1487
+ box = next;
1488
+ graphCamera.box = next;
1489
+ graphCamera.key = key;
1490
+ canvas.setAttribute('viewBox', `${next.x} ${next.y} ${next.w} ${next.h}`);
1491
+ };
986
1492
  const fit = () => {
987
- const width = canvas.clientWidth, height = canvas.clientHeight;
1493
+ const width = canvas.clientWidth,
1494
+ height = canvas.clientHeight;
988
1495
  if (!width || !height) return;
989
1496
  const scale = Math.max((layout.width + 80) / width, (layout.height + 100) / height);
990
- setBox({ x: (layout.width - width * scale) / 2, y: (layout.height - height * scale) / 2,
991
- w: width * scale, h: height * scale });
1497
+ setBox({
1498
+ x: (layout.width - width * scale) / 2,
1499
+ y: (layout.height - height * scale) / 2,
1500
+ w: width * scale,
1501
+ h: height * scale,
1502
+ });
992
1503
  };
993
1504
  const zoom = (factor, clientX, clientY) => {
994
1505
  if (!box) fit();
995
1506
  const bounds = canvas.getBoundingClientRect();
996
- const fx = (clientX - bounds.left) / bounds.width, fy = (clientY - bounds.top) / bounds.height;
1507
+ const fx = (clientX - bounds.left) / bounds.width,
1508
+ fy = (clientY - bounds.top) / bounds.height;
997
1509
  const w = Math.max(200, Math.min(layout.width * 5, box.w * factor));
998
- const h = w * bounds.height / bounds.width;
1510
+ const h = (w * bounds.height) / bounds.width;
999
1511
  setBox({ x: box.x + (box.w - w) * fx, y: box.y + (box.h - h) * fy, w, h });
1000
1512
  };
1001
- canvas.addEventListener('wheel', (event) => { event.preventDefault();
1002
- zoom(Math.exp(event.deltaY * 0.001), event.clientX, event.clientY); }, { passive: false });
1513
+ canvas.addEventListener(
1514
+ 'wheel',
1515
+ (event) => {
1516
+ event.preventDefault();
1517
+ zoom(Math.exp(event.deltaY * 0.001), event.clientX, event.clientY);
1518
+ },
1519
+ { passive: false },
1520
+ );
1003
1521
  const pointers = new Map();
1004
1522
  let lastPinch = 0;
1005
- canvas.addEventListener('pointerdown', (event) => { dragMoved = false;
1523
+ canvas.addEventListener('pointerdown', (event) => {
1524
+ dragMoved = false;
1006
1525
  pointers.set(event.pointerId, [event.clientX, event.clientY]);
1007
- event.target.setPointerCapture(event.pointerId); });
1526
+ event.target.setPointerCapture(event.pointerId);
1527
+ });
1008
1528
  canvas.addEventListener('pointermove', (event) => {
1009
1529
  if (!pointers.has(event.pointerId)) return;
1010
1530
  const before = pointers.get(event.pointerId);
@@ -1015,29 +1535,75 @@ function renderScreenGraph(screens, allEdges) {
1015
1535
  const distance = Math.hypot(a[0] - b[0], a[1] - b[1]);
1016
1536
  if (lastPinch) zoom(lastPinch / distance, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2);
1017
1537
  lastPinch = distance;
1018
- } else if (box) setBox({ ...box, x: box.x - (event.clientX - before[0]) * box.w / canvas.clientWidth,
1019
- y: box.y - (event.clientY - before[1]) * box.h / canvas.clientHeight });
1538
+ } else if (box)
1539
+ setBox({
1540
+ ...box,
1541
+ x: box.x - ((event.clientX - before[0]) * box.w) / canvas.clientWidth,
1542
+ y: box.y - ((event.clientY - before[1]) * box.h) / canvas.clientHeight,
1543
+ });
1020
1544
  });
1021
- const endPointer = (event) => { pointers.delete(event.pointerId); lastPinch = 0; };
1545
+ const endPointer = (event) => {
1546
+ pointers.delete(event.pointerId);
1547
+ lastPinch = 0;
1548
+ };
1022
1549
  canvas.addEventListener('pointerup', endPointer);
1023
1550
  canvas.addEventListener('pointercancel', endPointer);
1024
1551
  requestAnimationFrame(() => {
1025
1552
  if (!canvas.isConnected) return;
1026
- if (box) { setBox(box); return; }
1027
- if (canvas.clientWidth && canvas.clientHeight) { fit(); return; }
1553
+ if (box) {
1554
+ setBox(box);
1555
+ return;
1556
+ }
1557
+ if (canvas.clientWidth && canvas.clientHeight) {
1558
+ fit();
1559
+ return;
1560
+ }
1028
1561
  const observer = new ResizeObserver(() => {
1029
- if (!canvas.isConnected) { observer.disconnect(); return; }
1030
- if (canvas.clientWidth && canvas.clientHeight) { observer.disconnect(); fit(); }
1562
+ if (!canvas.isConnected) {
1563
+ observer.disconnect();
1564
+ return;
1565
+ }
1566
+ if (canvas.clientWidth && canvas.clientHeight) {
1567
+ observer.disconnect();
1568
+ fit();
1569
+ }
1031
1570
  });
1032
1571
  observer.observe(canvas);
1033
1572
  });
1034
1573
  return el('div', { class: 'graph-wrap' }, [
1035
1574
  el('div', { class: 'graph-toolbar' }, [
1036
1575
  el('button', { text: 'Fit', onclick: fit }),
1037
- el('button', { text: '+', 'aria-label': 'Zoom in', onclick: () => zoom(.8, canvas.getBoundingClientRect().x + canvas.clientWidth / 2, canvas.getBoundingClientRect().y + canvas.clientHeight / 2) }),
1038
- el('button', { text: '−', 'aria-label': 'Zoom out', onclick: () => zoom(1.25, canvas.getBoundingClientRect().x + canvas.clientWidth / 2, canvas.getBoundingClientRect().y + canvas.clientHeight / 2) }),
1039
- el('label', {}, [el('input', { type: 'checkbox', checked: showBackLinks ? '' : null,
1040
- onchange: (event) => { showBackLinks = event.target.checked; render(); } }), ' Show back links']),
1576
+ el('button', {
1577
+ text: '+',
1578
+ 'aria-label': 'Zoom in',
1579
+ onclick: () =>
1580
+ zoom(
1581
+ 0.8,
1582
+ canvas.getBoundingClientRect().x + canvas.clientWidth / 2,
1583
+ canvas.getBoundingClientRect().y + canvas.clientHeight / 2,
1584
+ ),
1585
+ }),
1586
+ el('button', {
1587
+ text: '−',
1588
+ 'aria-label': 'Zoom out',
1589
+ onclick: () =>
1590
+ zoom(
1591
+ 1.25,
1592
+ canvas.getBoundingClientRect().x + canvas.clientWidth / 2,
1593
+ canvas.getBoundingClientRect().y + canvas.clientHeight / 2,
1594
+ ),
1595
+ }),
1596
+ el('label', {}, [
1597
+ el('input', {
1598
+ type: 'checkbox',
1599
+ checked: showBackLinks ? '' : null,
1600
+ onchange: (event) => {
1601
+ showBackLinks = event.target.checked;
1602
+ render();
1603
+ },
1604
+ }),
1605
+ ' Show back links',
1606
+ ]),
1041
1607
  ]),
1042
1608
  el('p', { class: 'graph-legend', text: 'Solid = tap · dashed = deep link · grey = replay route' }),
1043
1609
  canvas,
@@ -1046,19 +1612,25 @@ function renderScreenGraph(screens, allEdges) {
1046
1612
 
1047
1613
  function renderMemory() {
1048
1614
  const lessons = state.memory?.lessons ?? [];
1049
- const row = (lesson) => el('div', { class: 'memory-row' }, [
1050
- el('span', { class: 'grow', text: lesson.text }),
1051
- el('span', { class: 'badge info', text: lesson.scope ?? 'app' }),
1052
- el('span', { class: 'muted', text: `${lesson.source} · ×${lesson.hits} · ${relativeTime(lesson.lastSeenAt)}` }),
1053
- ]);
1054
- return el('section', { class: 'card panel' }, [title('Memory'),
1615
+ const row = (lesson) =>
1616
+ el('div', { class: 'memory-row' }, [
1617
+ el('span', { class: 'grow', text: lesson.text }),
1618
+ el('span', { class: 'badge info', text: lesson.scope ?? 'app' }),
1619
+ el('span', { class: 'muted', text: `${lesson.source} · ×${lesson.hits} · ${relativeTime(lesson.lastSeenAt)}` }),
1620
+ ]);
1621
+ return el('section', { class: 'card panel' }, [
1622
+ title('Memory'),
1055
1623
  el('p', { class: 'muted', text: 'Use bugpatrol memory to edit lessons.' }),
1056
- ...['explorer', 'judge', 'fixer'].map((role) => el('section', { class: 'memory-group' }, [
1057
- el('h3', { text: capital(role) }),
1058
- ...lessons.filter((lesson) => lesson.role === role && !lesson.retired).map(row),
1059
- ])),
1060
- el('details', {}, [el('summary', { text: `Retired lessons (${lessons.filter((lesson) => lesson.retired).length})` }),
1061
- ...lessons.filter((lesson) => lesson.retired).map(row)]),
1624
+ ...['explorer', 'judge', 'fixer'].map((role) =>
1625
+ el('section', { class: 'memory-group' }, [
1626
+ el('h3', { text: capital(role) }),
1627
+ ...lessons.filter((lesson) => lesson.role === role && !lesson.retired).map(row),
1628
+ ]),
1629
+ ),
1630
+ el('details', {}, [
1631
+ el('summary', { text: `Retired lessons (${lessons.filter((lesson) => lesson.retired).length})` }),
1632
+ ...lessons.filter((lesson) => lesson.retired).map(row),
1633
+ ]),
1062
1634
  ]);
1063
1635
  }
1064
1636
 
@@ -1073,8 +1645,12 @@ function openLightbox(path, caption) {
1073
1645
  lightboxCaption.textContent = `${caption} — ${path}`;
1074
1646
  lightbox.hidden = false;
1075
1647
  }
1076
- lightbox.addEventListener('click', () => { lightbox.hidden = true; });
1077
- document.addEventListener('keydown', (event) => { if (event.key === 'Escape') lightbox.hidden = true; });
1648
+ lightbox.addEventListener('click', () => {
1649
+ lightbox.hidden = true;
1650
+ });
1651
+ document.addEventListener('keydown', (event) => {
1652
+ if (event.key === 'Escape') lightbox.hidden = true;
1653
+ });
1078
1654
 
1079
1655
  // ---------------------------------------------------------------- boot
1080
1656
 
@@ -1088,7 +1664,8 @@ tabs.addEventListener('click', async (event) => {
1088
1664
 
1089
1665
  function summaryOf(run, findings) {
1090
1666
  return {
1091
- exitCode: run.exitCode, status: run.status,
1667
+ exitCode: run.exitCode,
1668
+ status: run.status,
1092
1669
  findings: { blocking: findings.filter((f) => f.route === 'check').length },
1093
1670
  };
1094
1671
  }