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