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/LICENSE +202 -0
- package/NOTICE +7 -0
- package/dist/bin.js +10611 -10236
- package/package.json +4 -2
- package/src/ui/app.js +850 -356
- package/src/ui/graph-layout.js +101 -43
- package/src/ui/index.html +7 -7
- package/src/ui/style.css +1067 -262
package/src/ui/app.js
CHANGED
|
@@ -32,7 +32,11 @@ const state = {
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
let screenMode = 'graph';
|
|
35
|
-
try {
|
|
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
|
|
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(
|
|
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 = {
|
|
136
|
-
|
|
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 (
|
|
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:
|
|
182
|
-
|
|
183
|
-
|
|
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(
|
|
201
|
+
const list = el(
|
|
202
|
+
'div',
|
|
203
|
+
{ class: 'card runlist' },
|
|
188
204
|
state.runs.map((run) => {
|
|
189
|
-
const button = el(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
state.selectedRunId
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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', {
|
|
218
|
-
|
|
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(
|
|
234
|
-
el('
|
|
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(
|
|
271
|
-
el('
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
el('div', {
|
|
275
|
-
|
|
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) =>
|
|
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(
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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(
|
|
344
|
-
el('
|
|
345
|
-
|
|
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', {
|
|
356
|
-
|
|
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(
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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(
|
|
403
|
-
|
|
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(
|
|
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', {
|
|
419
|
-
|
|
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 =
|
|
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 [
|
|
470
|
-
|
|
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' : ''
|
|
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
|
|
481
|
-
el('
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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', {
|
|
497
|
-
|
|
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', {
|
|
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 [
|
|
525
|
-
issue.
|
|
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(
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
el('
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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' }, [
|
|
559
|
-
live ?
|
|
560
|
-
|
|
561
|
-
el('div', {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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(
|
|
577
|
-
|
|
578
|
-
|
|
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
|
|
586
|
-
state.
|
|
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' }, [
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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(
|
|
622
|
-
(
|
|
623
|
-
|
|
624
|
-
|
|
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 = [
|
|
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
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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', {
|
|
654
|
-
|
|
655
|
-
|
|
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
|
|
666
|
-
el('
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
|
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
|
|
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(
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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 =
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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) =>
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
el('
|
|
756
|
-
|
|
757
|
-
el('
|
|
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) {
|
|
777
|
-
|
|
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) {
|
|
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' }, [
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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),
|
|
1143
|
+
roleIcon(event.role),
|
|
1144
|
+
el('span', { class: 'grow truncate', text: event.summary }),
|
|
861
1145
|
event.tokens && event.kind !== 'usage'
|
|
862
|
-
? el('small', {
|
|
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
|
|
866
|
-
el('
|
|
867
|
-
|
|
868
|
-
|
|
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(
|
|
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', {
|
|
889
|
-
|
|
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', {
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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) =>
|
|
906
|
-
el('
|
|
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(
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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(
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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)
|
|
948
|
-
|
|
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(
|
|
955
|
-
svgEl('
|
|
956
|
-
|
|
957
|
-
'text
|
|
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', {
|
|
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: () => {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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(
|
|
978
|
-
'text
|
|
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) => {
|
|
985
|
-
|
|
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,
|
|
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({
|
|
991
|
-
|
|
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,
|
|
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(
|
|
1002
|
-
|
|
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) => {
|
|
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)
|
|
1019
|
-
|
|
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) => {
|
|
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) {
|
|
1027
|
-
|
|
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) {
|
|
1030
|
-
|
|
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', {
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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) =>
|
|
1050
|
-
el('
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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) =>
|
|
1057
|
-
el('
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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', () => {
|
|
1077
|
-
|
|
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,
|
|
1584
|
+
exitCode: run.exitCode,
|
|
1585
|
+
status: run.status,
|
|
1092
1586
|
findings: { blocking: findings.filter((f) => f.route === 'check').length },
|
|
1093
1587
|
};
|
|
1094
1588
|
}
|