bugpatrol 0.1.3 → 0.1.4

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