bughunters 0.1.2 → 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/README.md +5 -31
- package/package.json +7 -20
- package/src/args.js +11 -0
- package/src/bin.js +6 -0
- package/dist/bin.js +0 -12088
- package/src/ui/app.js +0 -1126
- package/src/ui/graph-layout.js +0 -166
- package/src/ui/index.html +0 -40
- package/src/ui/style.css +0 -425
package/src/ui/app.js
DELETED
|
@@ -1,1126 +0,0 @@
|
|
|
1
|
-
// Bughunters dashboard. Vanilla ES modules on purpose: no build step means the UI
|
|
2
|
-
// is served straight from source, so `bughunters dashboard` works from a clone with
|
|
3
|
-
// nothing compiled but the CLI itself.
|
|
4
|
-
import { layoutGraph } from './graph-layout.js';
|
|
5
|
-
|
|
6
|
-
const view = document.getElementById('view');
|
|
7
|
-
const tabs = document.getElementById('tabs');
|
|
8
|
-
const liveEl = document.getElementById('live');
|
|
9
|
-
const projectEl = document.getElementById('project-name');
|
|
10
|
-
const platformEl = document.getElementById('platform');
|
|
11
|
-
const patrolEl = document.getElementById('patrol');
|
|
12
|
-
|
|
13
|
-
const state = {
|
|
14
|
-
view: 'overview',
|
|
15
|
-
overview: null,
|
|
16
|
-
issues: [],
|
|
17
|
-
issueFilter: 'open',
|
|
18
|
-
selectedIssueId: null,
|
|
19
|
-
issueDetail: null,
|
|
20
|
-
sessions: [],
|
|
21
|
-
selectedSessionId: null,
|
|
22
|
-
sessionDetail: null,
|
|
23
|
-
appmap: null,
|
|
24
|
-
selectedScreenId: null,
|
|
25
|
-
routines: [],
|
|
26
|
-
memory: { lessons: [] },
|
|
27
|
-
runs: [],
|
|
28
|
-
selectedRunId: null,
|
|
29
|
-
detail: null,
|
|
30
|
-
live: null,
|
|
31
|
-
root: '',
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
let screenMode = 'graph';
|
|
35
|
-
try { screenMode = localStorage.getItem('bughunters-screen-mode') === 'grid' ? 'grid' : 'graph'; } catch { /* storage may be disabled */ }
|
|
36
|
-
const graphCamera = { box: null, key: '' };
|
|
37
|
-
let showBackLinks = false;
|
|
38
|
-
|
|
39
|
-
// ---------------------------------------------------------------- data
|
|
40
|
-
|
|
41
|
-
// Mirrors isFeedEvent in agents.ts: the feed shows actions, not reasoning.
|
|
42
|
-
function isFeedEvent(event) {
|
|
43
|
-
if (event.kind === 'thought' || event.kind === 'tool-call') return false;
|
|
44
|
-
return Boolean(event.summary && event.summary.trim());
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function getJson(url) {
|
|
48
|
-
const response = await fetch(url);
|
|
49
|
-
if (!response.ok) throw new Error(`${url} -> ${response.status}`);
|
|
50
|
-
return response.json();
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const artifact = (path) => `/api/artifact?path=${encodeURIComponent(path)}`;
|
|
54
|
-
|
|
55
|
-
async function refresh({ keepSelection = true } = {}) {
|
|
56
|
-
const snapshot = await getJson('/api/state');
|
|
57
|
-
state.runs = snapshot.runs;
|
|
58
|
-
state.live = snapshot.live;
|
|
59
|
-
state.root = snapshot.root;
|
|
60
|
-
state.overview = await getJson('/api/overview');
|
|
61
|
-
projectEl.textContent = state.overview.project.name;
|
|
62
|
-
platformEl.textContent = state.overview.project.platform;
|
|
63
|
-
const patrol = state.overview.patrol;
|
|
64
|
-
patrolEl.textContent = patrol.state === 'running' ? `Patrol running · cycle ${patrol.cycle}` : 'Patrol stopped';
|
|
65
|
-
patrolEl.classList.toggle('running', patrol.state === 'running');
|
|
66
|
-
liveEl.classList.toggle('working', state.overview.agents.some((agent) => agent.state === 'working'));
|
|
67
|
-
|
|
68
|
-
// Follow the newest run unless the user has deliberately pinned an older one.
|
|
69
|
-
const stillExists = state.runs.some((r) => r.id === state.selectedRunId);
|
|
70
|
-
if (!keepSelection || !stillExists) state.selectedRunId = state.runs[0]?.id ?? null;
|
|
71
|
-
|
|
72
|
-
if (state.view === 'checks' && state.selectedRunId) {
|
|
73
|
-
state.detail = await getJson(`/api/runs/${encodeURIComponent(state.selectedRunId)}`);
|
|
74
|
-
}
|
|
75
|
-
if (state.view === 'issues') {
|
|
76
|
-
state.issues = await getJson('/api/issues');
|
|
77
|
-
state.appmap = await getJson('/api/appmap');
|
|
78
|
-
const visible = visibleIssues();
|
|
79
|
-
if (!visible.some((issue) => issue.id === state.selectedIssueId)) {
|
|
80
|
-
state.selectedIssueId = visible[0]?.id ?? null;
|
|
81
|
-
}
|
|
82
|
-
if (state.selectedIssueId) {
|
|
83
|
-
state.issueDetail = await getJson(`/api/issues/${encodeURIComponent(state.selectedIssueId)}`);
|
|
84
|
-
} else {
|
|
85
|
-
state.issueDetail = null;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
if (state.view === 'activity') {
|
|
89
|
-
state.sessions = await getJson('/api/sessions');
|
|
90
|
-
if (!state.sessions.some((session) => session.id === state.selectedSessionId)) {
|
|
91
|
-
state.selectedSessionId = state.sessions[0]?.id ?? null;
|
|
92
|
-
}
|
|
93
|
-
if (state.selectedSessionId) {
|
|
94
|
-
state.sessionDetail = await getJson(`/api/sessions/${encodeURIComponent(state.selectedSessionId)}`);
|
|
95
|
-
} else {
|
|
96
|
-
state.sessionDetail = null;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
if (state.view === 'screens') {
|
|
100
|
-
state.appmap = await getJson('/api/appmap');
|
|
101
|
-
state.issues = await getJson('/api/issues');
|
|
102
|
-
state.routines = await getJson('/api/routines');
|
|
103
|
-
// A detail shows only after a click and closes if its screen goes away.
|
|
104
|
-
if (!state.appmap?.screens.some((screen) => screen.id === state.selectedScreenId)) {
|
|
105
|
-
state.selectedScreenId = null;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
if (state.view === 'memory') state.memory = await getJson('/api/memory');
|
|
109
|
-
render();
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function connectLive() {
|
|
113
|
-
const source = new EventSource('/api/events');
|
|
114
|
-
source.addEventListener('changed', () => refresh());
|
|
115
|
-
source.onopen = () => liveEl.classList.remove('stale');
|
|
116
|
-
source.onerror = () => liveEl.classList.add('stale');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// ---------------------------------------------------------------- render
|
|
120
|
-
|
|
121
|
-
function render() {
|
|
122
|
-
// Checks shows `bughunters run` gate results. A project with no gate runs
|
|
123
|
-
// (native apps have none) does not need the tab.
|
|
124
|
-
const hasChecks = state.runs.length > 0 || state.live?.status === 'running';
|
|
125
|
-
if (state.view === 'checks' && !hasChecks) state.view = 'overview';
|
|
126
|
-
for (const button of tabs.querySelectorAll('button')) {
|
|
127
|
-
button.classList.toggle('active', button.dataset.view === state.view);
|
|
128
|
-
if (button.dataset.view === 'checks') button.hidden = !hasChecks;
|
|
129
|
-
}
|
|
130
|
-
view.innerHTML = '';
|
|
131
|
-
if (state.view === 'checks') {
|
|
132
|
-
const banner = renderLiveBanner();
|
|
133
|
-
if (banner) view.append(banner);
|
|
134
|
-
}
|
|
135
|
-
const renderers = { overview: renderOverview, issues: renderIssues, activity: renderActivity,
|
|
136
|
-
screens: renderScreens, memory: renderMemory, checks: renderRuns };
|
|
137
|
-
view.append(renderers[state.view]());
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function el(tag, props = {}, children = []) {
|
|
141
|
-
const node = document.createElement(tag);
|
|
142
|
-
for (const [key, value] of Object.entries(props)) {
|
|
143
|
-
if (key === 'class') node.className = value;
|
|
144
|
-
else if (key === 'text') node.textContent = value;
|
|
145
|
-
else if (key === 'html') node.innerHTML = value;
|
|
146
|
-
else if (key.startsWith('on')) node.addEventListener(key.slice(2).toLowerCase(), value);
|
|
147
|
-
else if (value !== undefined && value !== null) node.setAttribute(key, String(value));
|
|
148
|
-
}
|
|
149
|
-
for (const child of [].concat(children)) if (child) node.append(child);
|
|
150
|
-
return node;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function svgEl(tag, props = {}, children = []) {
|
|
154
|
-
const node = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
|
155
|
-
for (const [key, value] of Object.entries(props)) {
|
|
156
|
-
if (key === 'text') node.textContent = value;
|
|
157
|
-
else if (key.startsWith('on')) node.addEventListener(key.slice(2).toLowerCase(), value);
|
|
158
|
-
else if (value !== undefined && value !== null) node.setAttribute(key, String(value));
|
|
159
|
-
}
|
|
160
|
-
for (const child of children) if (child) node.append(child);
|
|
161
|
-
return node;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Shown only while a run is actually in flight. */
|
|
165
|
-
function renderLiveBanner() {
|
|
166
|
-
const live = state.live;
|
|
167
|
-
if (!live || live.status !== 'running') return null;
|
|
168
|
-
const done = live.captured.length;
|
|
169
|
-
const total = live.plannedCaptures || done;
|
|
170
|
-
return el('div', { class: 'runbanner' }, [
|
|
171
|
-
el('span', { class: 'spinner' }),
|
|
172
|
-
el('span', { text: `Run in progress — ${done}/${total} captures` }),
|
|
173
|
-
live.currentStep ? el('span', { class: 'muted', text: live.currentStep }) : null,
|
|
174
|
-
]);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function renderRuns() {
|
|
178
|
-
if (state.runs.length === 0) {
|
|
179
|
-
return el('div', {
|
|
180
|
-
class: 'empty',
|
|
181
|
-
html: state.live?.status === 'running'
|
|
182
|
-
? 'First check in progress.'
|
|
183
|
-
: 'No runs yet. Run <code>bughunters run</code> in this project and results appear here automatically.',
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const list = el('div', { class: 'card runlist' },
|
|
188
|
-
state.runs.map((run) => {
|
|
189
|
-
const button = el('button', {
|
|
190
|
-
class: `runitem${run.id === state.selectedRunId ? ' active' : ''}`,
|
|
191
|
-
onclick: async () => {
|
|
192
|
-
state.selectedRunId = run.id;
|
|
193
|
-
state.detail = await getJson(`/api/runs/${encodeURIComponent(run.id)}`);
|
|
194
|
-
render();
|
|
195
|
-
},
|
|
196
|
-
}, [
|
|
197
|
-
el('div', { class: 'row' }, [
|
|
198
|
-
el('span', { class: `badge ${verdictClass(run)}`, text: verdictLabel(run) }),
|
|
199
|
-
el('span', { class: 'mono', text: shortCommit(run.commit) }),
|
|
200
|
-
]),
|
|
201
|
-
el('div', { class: 'when',
|
|
202
|
-
text: `${formatTime(run.startedAt)} · ${run.mode} · ${run.findings.total} finding(s)` }),
|
|
203
|
-
]);
|
|
204
|
-
return button;
|
|
205
|
-
}),
|
|
206
|
-
);
|
|
207
|
-
|
|
208
|
-
return el('div', { class: 'layout' }, [list, renderDetail()]);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function renderDetail() {
|
|
212
|
-
if (!state.detail) return el('div', { class: 'card empty', text: 'Select a run.' });
|
|
213
|
-
const { run, findings, trace } = state.detail;
|
|
214
|
-
|
|
215
|
-
const header = el('div', {}, [
|
|
216
|
-
el('h2', {}, [
|
|
217
|
-
el('span', { class: `badge ${verdictClass(summaryOf(run, findings))}`,
|
|
218
|
-
text: verdictLabel(summaryOf(run, findings)) }),
|
|
219
|
-
el('span', { text: ` ${run.mode} run on ${shortCommit(run.commit)}` }),
|
|
220
|
-
]),
|
|
221
|
-
el('div', { class: 'kv' }, [
|
|
222
|
-
el('span', { text: `${run.plan.coverage.screensSelected}/${run.plan.coverage.screensTotal} screens` }),
|
|
223
|
-
el('span', { text: `exit ${run.exitCode}` }),
|
|
224
|
-
el('span', { text: `${run.suppressionCount} suppressed` }),
|
|
225
|
-
el('span', { text: `$${(run.cost.decisionUsd ?? 0).toFixed(5)} decision spend` }),
|
|
226
|
-
el('span', { text: formatTime(run.startedAt) }),
|
|
227
|
-
]),
|
|
228
|
-
]);
|
|
229
|
-
|
|
230
|
-
const children = [header];
|
|
231
|
-
|
|
232
|
-
if (!trace) {
|
|
233
|
-
children.push(el('div', { class: 'notes' }, [
|
|
234
|
-
el('p', { text: 'This run predates the trace format, so only its findings are available.' }),
|
|
235
|
-
]));
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const byScreen = new Map();
|
|
239
|
-
for (const finding of findings) {
|
|
240
|
-
const key = String(finding.screenId ?? '');
|
|
241
|
-
if (!byScreen.has(key)) byScreen.set(key, []);
|
|
242
|
-
byScreen.get(key).push(finding);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// A trace screen is one page at one viewport, and it records exactly which
|
|
246
|
-
// findings it produced. Grouping by page alone listed the mobile findings
|
|
247
|
-
// under desktop too, so every finding appeared twice.
|
|
248
|
-
const byId = new Map(findings.map((f) => [f.id, f]));
|
|
249
|
-
const seen = new Set();
|
|
250
|
-
const viewports = new Map();
|
|
251
|
-
for (const screen of trace?.screens ?? []) {
|
|
252
|
-
for (const id of screen.findingIds ?? []) {
|
|
253
|
-
const finding = byId.get(id);
|
|
254
|
-
if (!finding) continue;
|
|
255
|
-
const key = `${finding.screenId}|${problemKey(finding)}`;
|
|
256
|
-
if (!viewports.has(key)) viewports.set(key, new Set());
|
|
257
|
-
viewports.get(key).add(screen.viewport);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
for (const screen of trace?.screens ?? []) {
|
|
261
|
-
const own = Array.isArray(screen.findingIds)
|
|
262
|
-
? screen.findingIds.map((id) => byId.get(id)).filter(Boolean)
|
|
263
|
-
: byScreen.get(screen.screenId) ?? [];
|
|
264
|
-
const unique = own.filter((finding) => !seen.has(`${finding.screenId}|${problemKey(finding)}`));
|
|
265
|
-
for (const finding of unique) seen.add(`${finding.screenId}|${problemKey(finding)}`);
|
|
266
|
-
children.push(renderScreen(screen, unique, trace, viewports));
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (trace?.suppressed?.length) {
|
|
270
|
-
children.push(el('div', { class: 'screen' }, [
|
|
271
|
-
el('h3', { text: `Suppressed by the Intent Ledger (${trace.suppressed.length})` }),
|
|
272
|
-
...trace.suppressed.map((s) =>
|
|
273
|
-
el('div', { class: 'finding' }, [
|
|
274
|
-
el('div', { text: `${s.ruleId} on ${s.screenId}` }),
|
|
275
|
-
el('div', { class: 'why', text: `"${s.reason}" — ${s.decidedBy}` }),
|
|
276
|
-
]),
|
|
277
|
-
),
|
|
278
|
-
]));
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
return el('div', { class: 'card detail' }, children);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function renderScreen(screen, findings, trace, viewports) {
|
|
285
|
-
const shots = [];
|
|
286
|
-
if (screen.artifacts.baseline) shots.push(shot(screen.artifacts.baseline, 'expected'));
|
|
287
|
-
if (screen.artifacts.actual) shots.push(shot(screen.artifacts.actual, 'actual'));
|
|
288
|
-
if (screen.artifacts.diff) shots.push(shot(screen.artifacts.diff, 'diff'));
|
|
289
|
-
|
|
290
|
-
const fired = screen.checks.filter((c) => c.fired);
|
|
291
|
-
const passed = screen.checks.filter((c) => !c.fired);
|
|
292
|
-
|
|
293
|
-
return el('section', { class: 'screen' }, [
|
|
294
|
-
el('div', { class: 'screen-head' }, [
|
|
295
|
-
el('strong', { text: screen.title || screen.screenId }),
|
|
296
|
-
el('span', { class: 'vp', text: `${screen.viewport} · ${screen.url}` }),
|
|
297
|
-
screen.baselineCreated ? el('span', { class: 'badge info', text: 'baseline created' }) : null,
|
|
298
|
-
]),
|
|
299
|
-
|
|
300
|
-
shots.length ? el('div', { class: 'shots' }, shots) : null,
|
|
301
|
-
|
|
302
|
-
el('div', { class: 'checks' }, [
|
|
303
|
-
...fired.map((c) => el('span', { class: `chk fired sev-${c.severity ?? 'minor'}`,
|
|
304
|
-
title: c.message ?? '', text: c.ruleId })),
|
|
305
|
-
passed.length ? el('span', { class: 'muted', text: `${passed.length} other checks passed` }) : null,
|
|
306
|
-
]),
|
|
307
|
-
|
|
308
|
-
...groupFindings(findings).map((group) => renderGroup(group, trace, viewports)),
|
|
309
|
-
|
|
310
|
-
renderReasoning(screen),
|
|
311
|
-
]);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/** Same rule, same claim, different element: one problem. Mirrors graph.ts. */
|
|
315
|
-
function problemKey(finding) {
|
|
316
|
-
return `${finding.ruleId}|${finding.route}|${(finding.summary ?? '').replace(/"[^"]*"/, '"…"')}`;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function groupFindings(findings) {
|
|
320
|
-
const groups = new Map();
|
|
321
|
-
for (const finding of findings) {
|
|
322
|
-
const key = problemKey(finding);
|
|
323
|
-
if (!groups.has(key)) groups.set(key, []);
|
|
324
|
-
groups.get(key).push(finding);
|
|
325
|
-
}
|
|
326
|
-
return [...groups.values()];
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function renderGroup(group, trace, viewports) {
|
|
330
|
-
const first = group[0];
|
|
331
|
-
const key = `${first.screenId}|${problemKey(first)}`;
|
|
332
|
-
const tags = [...(viewports.get(key) ?? [])];
|
|
333
|
-
if (group.length === 1) {
|
|
334
|
-
const card = renderFinding(first, trace);
|
|
335
|
-
card.append(el('div', { class: 'viewport-tags' }, tags.map((viewport) => el('span', { text: viewport }))));
|
|
336
|
-
return card;
|
|
337
|
-
}
|
|
338
|
-
const card = renderFinding({
|
|
339
|
-
...first, summary: first.summary.replace(/"[^"]*"/, `${group.length} elements`),
|
|
340
|
-
}, trace);
|
|
341
|
-
card.append(el('div', { class: 'viewport-tags' }, tags.map((viewport) => el('span', { text: viewport }))));
|
|
342
|
-
const selectors = group.map((f) => (f.summary.match(/"([^"]*)"/) ?? [])[1]).filter(Boolean);
|
|
343
|
-
card.append(el('details', { class: 'members' }, [
|
|
344
|
-
el('summary', { text: `The same problem on ${group.length} elements` }),
|
|
345
|
-
el('ul', {}, selectors.map((sel) => el('li', {}, [el('code', { text: sel })]))),
|
|
346
|
-
]));
|
|
347
|
-
return card;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function renderFinding(finding, trace) {
|
|
351
|
-
const why = trace?.findings?.find((t) => t.findingId === finding.id)?.routeReason;
|
|
352
|
-
return el('div', { class: `finding sev-${finding.severity}` }, [
|
|
353
|
-
el('div', { text: finding.summary }),
|
|
354
|
-
why ? el('div', { class: 'why', text: `Routed to ${finding.route}: ${why}` }) : null,
|
|
355
|
-
el('div', { class: 'meta',
|
|
356
|
-
text: `${finding.ruleId} · ${finding.tier} · ${finding.severity} · ${finding.fingerprint}` }),
|
|
357
|
-
]);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* The "show your work" panel. Everything that led to the verdict for this
|
|
362
|
-
* screen: how the capture settled, what the diff measured, what was masked,
|
|
363
|
-
* and what the decision layer was asked.
|
|
364
|
-
*/
|
|
365
|
-
function renderReasoning(screen) {
|
|
366
|
-
const rows = [];
|
|
367
|
-
const add = (label, value) => {
|
|
368
|
-
if (value !== undefined && value !== null && value !== '') rows.push([label, value]);
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
if (screen.stability) {
|
|
372
|
-
add('Capture settled', `after ${screen.stability.frames} frame(s), ${screen.stability.elapsedMs}ms`);
|
|
373
|
-
}
|
|
374
|
-
add('Selected because', screen.planReason);
|
|
375
|
-
|
|
376
|
-
if (screen.diff) {
|
|
377
|
-
const d = screen.diff;
|
|
378
|
-
add('Pixel diff', d.identical
|
|
379
|
-
? `identical (${d.engine}, ${Math.round(d.durationMs)}ms)`
|
|
380
|
-
: `${d.changedPixels} px across ${d.regionCount} region(s) — ` +
|
|
381
|
-
`${(d.changedFraction * 100).toFixed(3)}% of compared area (${d.engine})`);
|
|
382
|
-
add('Masked', `${(d.maskedFraction * 100).toFixed(1)}% of the screen, ` +
|
|
383
|
-
`${d.maskedRegionCount} region(s)`);
|
|
384
|
-
if (!d.enginesAgreed) add('Engines disagreed', `cross-check saw ${d.crossCheckChangedPixels} px`);
|
|
385
|
-
// First line only: older runs stored the loader's full multi-line dump.
|
|
386
|
-
if (d.degraded) add('Degraded', d.degraded.split('\n')[0].replace(/(: \/\S+)+.*$/, ''));
|
|
387
|
-
if (d.dimensionMismatch) {
|
|
388
|
-
add('Dimensions changed', `${d.dimensionMismatch.baseline.join('x')} → ` +
|
|
389
|
-
d.dimensionMismatch.actual.join('x'));
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
if (screen.maskedSelectors?.length) add('Mask selectors', screen.maskedSelectors.join(', '));
|
|
394
|
-
if (screen.missingFonts?.length) add('Missing fonts', screen.missingFonts.join(', '));
|
|
395
|
-
if (screen.consoleErrors?.length) add('Console errors', screen.consoleErrors.slice(0, 5).join(' | '));
|
|
396
|
-
if (screen.links?.length) {
|
|
397
|
-
add('Links found', `${screen.links.length} (${screen.links.filter((l) => l.external).length} external)`);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
if (screen.decision) {
|
|
401
|
-
const d = screen.decision;
|
|
402
|
-
add('Decision layer', d.decider === 'none'
|
|
403
|
-
? `not consulted — ${d.skippedReason}`
|
|
404
|
-
: `${d.decider}, $${(d.costUsd ?? 0).toFixed(6)}`);
|
|
405
|
-
if (d.stateChars) add('State digest', `${d.stateChars} chars (hash ${String(d.stateHash ?? '').slice(0, 12)})`);
|
|
406
|
-
for (const [key, answer] of Object.entries(d.answers ?? {})) {
|
|
407
|
-
add(`· ${key}`, `${answer.value} (confidence ${Number(answer.confidence).toFixed(2)})`);
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
return el('details', { class: 'reasoning' }, [
|
|
412
|
-
el('summary', { text: 'How Bughunters reached this' }),
|
|
413
|
-
el('table', {}, rows.map(([k, v]) => el('tr', {}, [el('td', { text: k }), el('td', { text: String(v) })]))),
|
|
414
|
-
]);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function shot(path, caption) {
|
|
418
|
-
const img = el('img', { src: artifact(path), alt: caption, loading: 'lazy',
|
|
419
|
-
onclick: () => openLightbox(path, caption) });
|
|
420
|
-
return el('figure', {}, [img, el('figcaption', { text: caption })]);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// Agent views use text nodes for all state read from disk. Even markdown is built
|
|
424
|
-
// from a small allowlist of DOM elements rather than inserted as HTML.
|
|
425
|
-
const roleIcons = {
|
|
426
|
-
explorer: '<circle cx="8" cy="8" r="5"/><path d="m8 4 2 4-4 2 2-6Z"/>',
|
|
427
|
-
judge: '<path d="M3 6h10M8 3v9M5 12h6M4 6l-2 4h4L4 6Zm8 0-2 4h4l-2-4Z"/>',
|
|
428
|
-
fixer: '<path d="M10 3a3 3 0 0 0-3 4l-4 4a2 2 0 0 0 2 2l4-4a3 3 0 0 0 4-3l-2 1-2-2 1-2Z"/>',
|
|
429
|
-
system: '<circle cx="8" cy="8" r="5"/>',
|
|
430
|
-
decider: '<path d="m3 8 3 3 7-7"/>',
|
|
431
|
-
};
|
|
432
|
-
|
|
433
|
-
function roleIcon(role) {
|
|
434
|
-
const icon = roleIcons[role] ?? roleIcons.system;
|
|
435
|
-
const svg = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" ` +
|
|
436
|
-
`stroke-width="1.4" aria-hidden="true">${icon}</svg>`;
|
|
437
|
-
return el('span', { class: 'role-icon', title: role, 'aria-label': role, html: svg });
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
function relativeTime(value) {
|
|
441
|
-
if (!value) return '—';
|
|
442
|
-
const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000));
|
|
443
|
-
if (!Number.isFinite(seconds)) return value;
|
|
444
|
-
if (seconds < 60) return 'just now';
|
|
445
|
-
if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
|
|
446
|
-
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hr ago`;
|
|
447
|
-
return `${Math.floor(seconds / 86400)} d ago`;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function money(value) {
|
|
451
|
-
return `$${Number(value ?? 0).toFixed(3)}`;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function count(value) {
|
|
455
|
-
const n = Number(value ?? 0);
|
|
456
|
-
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
457
|
-
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
458
|
-
return String(n);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/** "25.6k in · 4 out tokens", or a dash when the runtime reported no usage. */
|
|
462
|
-
function tokens(usage, suffix = ' tokens') {
|
|
463
|
-
if (!usage) return `no tokens reported`;
|
|
464
|
-
return `${usage.estimated ? '~' : ''}${count(usage.input)} in · ${count(usage.output)} out${suffix}`;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function tokenDetail(usage) {
|
|
468
|
-
if (!usage) return 'The runtime reported no token usage.';
|
|
469
|
-
return [`${usage.input} input tokens`, usage.cacheRead ? `${usage.cacheRead} of them from the cache` : '',
|
|
470
|
-
usage.cacheWrite ? `${usage.cacheWrite} written to the cache` : '', `${usage.output} output tokens`,
|
|
471
|
-
usage.listCostUsd ? `list price ${money(usage.listCostUsd)}` : '',
|
|
472
|
-
usage.estimated ? 'estimated from the text length' : ''].filter(Boolean).join(', ');
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function renderUsage(usage) {
|
|
476
|
-
const rows = usage?.week ?? [];
|
|
477
|
-
const head = ['Agent', 'Model', 'Sessions', 'Input', 'Cached', 'Output', 'Per session', 'List price'];
|
|
478
|
-
return el('section', { class: 'card panel usage' }, [
|
|
479
|
-
title('Token usage', usage?.weekTotal ? `Last 7 days · ${tokens(usage.weekTotal)}` : 'Last 7 days'),
|
|
480
|
-
rows.length ? el('table', { class: 'usage-table' }, [
|
|
481
|
-
el('tr', {}, head.map((text) => el('th', { text }))),
|
|
482
|
-
...rows.map((row) => el('tr', { title: tokenDetail(row.tokens) }, [
|
|
483
|
-
el('td', { text: capital(row.role) }), el('td', { text: row.model }),
|
|
484
|
-
el('td', { text: String(row.sessions) }), el('td', { text: count(row.tokens.input) }),
|
|
485
|
-
el('td', { text: row.tokens.cacheRead ? count(row.tokens.cacheRead) : '—' }),
|
|
486
|
-
el('td', { text: count(row.tokens.output) }),
|
|
487
|
-
el('td', { text: count(Math.round((row.tokens.input + row.tokens.output) / Math.max(1, row.sessions))) }),
|
|
488
|
-
el('td', { text: row.tokens.listCostUsd ? money(row.tokens.listCostUsd) : '—' }),
|
|
489
|
-
])),
|
|
490
|
-
]) : el('p', { class: 'empty', text: 'No token usage yet. The API agents, claude (--output-format json), and codex (--json) report their tokens.' }),
|
|
491
|
-
]);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function image(path, label, className = '') {
|
|
495
|
-
if (!path) return el('div', { class: `image-empty ${className}`, text: 'No capture' });
|
|
496
|
-
return el('img', { class: className, src: artifact(path), alt: label, loading: 'lazy',
|
|
497
|
-
onclick: () => openLightbox(path, label) });
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function severityChip(value) {
|
|
501
|
-
return el('span', { class: `severity severity-${value}`, text: value });
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function title(text, aside) {
|
|
505
|
-
return el('div', { class: 'section-title' }, [
|
|
506
|
-
el('h2', { text }),
|
|
507
|
-
aside ? el('span', { class: 'muted', text: aside }) : null,
|
|
508
|
-
]);
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
const CI_WORDS = { pending: 'CI running', passed: 'CI green', failed: 'CI failed, fixing', 'gave-up': 'CI red' };
|
|
512
|
-
|
|
513
|
-
function githubChip({ kind, number, url, state, draft, ci }) {
|
|
514
|
-
const label = kind === 'pr' ? 'PR' : 'Issue';
|
|
515
|
-
const shown = state === 'open' && draft ? 'draft' : state;
|
|
516
|
-
const checks = state === 'open' || !state ? CI_WORDS[ci] : undefined;
|
|
517
|
-
return el('a', { href: url, target: '_blank', rel: 'noopener noreferrer',
|
|
518
|
-
class: `gh-chip gh-${shown || 'unknown'}${checks ? ` ci-${ci}` : ''}`,
|
|
519
|
-
text: `${label} #${number}${shown ? ` · ${shown}` : ''}${checks ? ` · ${checks}` : ''}`,
|
|
520
|
-
onclick: (event) => event.stopPropagation() });
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
function issueChips(issue) {
|
|
524
|
-
return [issue.pr ? githubChip({ kind: 'pr', ...issue.pr }) : null,
|
|
525
|
-
issue.github ? githubChip({ kind: 'issue', ...issue.github }) : null];
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
function renderOverview() {
|
|
529
|
-
const data = state.overview;
|
|
530
|
-
if (!data) return el('div', { class: 'empty', text: 'Loading overview…' });
|
|
531
|
-
const agents = el('div', { class: 'agent-grid' }, data.agents.map((agent) =>
|
|
532
|
-
el('article', { class: 'card agent-card' }, [
|
|
533
|
-
el('div', { class: 'agent-head' }, [roleIcon(agent.role), el('strong', { text: capital(agent.role) }),
|
|
534
|
-
el('span', { class: `state-dot ${agent.state}`, title: agent.state })]),
|
|
535
|
-
el('div', { class: 'muted', text: agent.runtime || 'No runtime configured' }),
|
|
536
|
-
el('p', { text: agent.activity || capital(agent.state) }),
|
|
537
|
-
// Bughunters sees only the API calls that it makes. A local agent CLI
|
|
538
|
-
// bills the user's own plan, so its cost is not in this number.
|
|
539
|
-
el('div', { class: 'muted', title: tokenDetail(data.usage?.todayByRole?.[agent.role]),
|
|
540
|
-
text: `${tokens(data.usage?.todayByRole?.[agent.role])} today` }),
|
|
541
|
-
el('div', { class: 'muted', text: `API ${money(agent.spentUsd)} today`
|
|
542
|
-
+ (String(agent.runtime).startsWith('cli') ? ' · the model runs on your CLI plan' : '') }),
|
|
543
|
-
])));
|
|
544
|
-
const attention = el('section', { class: 'card panel' }, [title('Needs attention', `${data.counts.issuesOpen} open`),
|
|
545
|
-
data.attention.length ? el('div', { class: 'stack' }, data.attention.map((issue) =>
|
|
546
|
-
el('button', { class: 'attention-row', onclick: () => openIssue(issue.id) }, [
|
|
547
|
-
image(issue.evidence?.screenshot, issue.title, 'thumb'),
|
|
548
|
-
el('span', { class: 'grow' }, [el('strong', { text: issue.title }),
|
|
549
|
-
el('span', { class: 'row-meta' }, [
|
|
550
|
-
el('small', { class: 'muted', text: `${screenName(issue.screenId)} · ×${issue.occurrences}` }),
|
|
551
|
-
...issueChips(issue)])]),
|
|
552
|
-
severityChip(issue.severity),
|
|
553
|
-
el('span', { class: 'muted', text: issueStatus(issue, issue.fix) }),
|
|
554
|
-
]))) : el('p', { class: 'empty', text: 'No open issues. The agents found nothing wrong yet.' }),
|
|
555
|
-
]);
|
|
556
|
-
const live = data.live;
|
|
557
|
-
const latest = data.recentSessions[0];
|
|
558
|
-
const livePanel = el('section', { class: 'card panel' }, [title('Live', live ? 'Session running' : 'Quiet now'),
|
|
559
|
-
live ? el('div', {}, [
|
|
560
|
-
image(live.screenshot, 'Newest session capture', 'live-shot'),
|
|
561
|
-
el('div', { class: 'event-list' }, live.events.map(renderEventRow)),
|
|
562
|
-
]) : el('div', { class: 'quiet' }, [
|
|
563
|
-
el('p', { text: latest
|
|
564
|
-
? `Last: the ${latest.role}, ${relativeTime(latest.startedAt)}. ${firstSentence(latest.summary)}`
|
|
565
|
-
: 'No sessions yet.' }),
|
|
566
|
-
data.patrol?.nextAt ? el('p', { class: 'muted',
|
|
567
|
-
text: `Next patrol at ${new Date(data.patrol.nextAt).toLocaleTimeString([], {
|
|
568
|
-
hour: '2-digit', minute: '2-digit',
|
|
569
|
-
})}${data.patrol.commit ? ` · last tested commit ${data.patrol.commit.slice(0, 7)}` : ''}`,
|
|
570
|
-
}) : null,
|
|
571
|
-
]),
|
|
572
|
-
]);
|
|
573
|
-
const coverage = el('section', { class: 'card panel coverage' }, [
|
|
574
|
-
title('Coverage', `${data.counts.screens} screens found`),
|
|
575
|
-
data.github ? el('div', { class: 'muted github-summary', text: `GitHub · ${data.github}` }) : null,
|
|
576
|
-
el('div', { class: 'coverage-row' }, data.screens.map((screen) =>
|
|
577
|
-
el('button', { class: 'coverage-item', onclick: () => openScreen(screen.id) }, [
|
|
578
|
-
image(screen.lastScreenshot, screen.name), el('span', { text: screen.name }),
|
|
579
|
-
]))),
|
|
580
|
-
]);
|
|
581
|
-
return el('div', {}, [agents, el('div', { class: 'overview-grid' }, [attention, livePanel]), coverage, renderUsage(data.usage)]);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function screenName(id) {
|
|
585
|
-
return state.appmap?.screens.find((screen) => screen.id === id)?.name ||
|
|
586
|
-
state.overview?.screens.find((screen) => screen.id === id)?.name || id || 'Unknown screen';
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
function capital(value) {
|
|
590
|
-
return value ? value[0].toUpperCase() + value.slice(1) : '';
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
async function openIssue(id) {
|
|
594
|
-
state.view = 'issues';
|
|
595
|
-
state.selectedIssueId = id;
|
|
596
|
-
await refresh();
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
function renderIssues() {
|
|
600
|
-
const filters = ['open', 'fixed', 'dismissed', 'all'];
|
|
601
|
-
const filtered = visibleIssues();
|
|
602
|
-
const list = el('div', { class: 'card panel' }, [title('Issues', `${filtered.length}`),
|
|
603
|
-
el('div', { class: 'filters' }, filters.map((filter) => el('button', {
|
|
604
|
-
class: `filter ${state.issueFilter === filter ? 'active' : ''}`, text: capital(filter),
|
|
605
|
-
onclick: async () => { state.issueFilter = filter; await refresh(); },
|
|
606
|
-
}))),
|
|
607
|
-
...filtered.map((issue) => el('button', { class: `list-row ${issue.id === state.selectedIssueId ? 'active' : ''}`,
|
|
608
|
-
onclick: () => openIssue(issue.id) }, [
|
|
609
|
-
// The chips sit under the title, so a long title keeps the row's width.
|
|
610
|
-
el('span', { class: 'grow' }, [el('strong', { text: issue.title }),
|
|
611
|
-
el('span', { class: 'row-meta' }, [
|
|
612
|
-
el('small', { class: 'muted', text: `${screenName(issue.screenId)} · ${relativeTime(issue.lastSeenAt)}` }),
|
|
613
|
-
...issueChips(issue)])]),
|
|
614
|
-
severityChip(issue.severity),
|
|
615
|
-
])),
|
|
616
|
-
]);
|
|
617
|
-
return el('div', { class: 'master-detail' }, [list, renderIssueDetail()]);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
function visibleIssues() {
|
|
621
|
-
return state.issues.filter((issue) => state.issueFilter === 'all' ||
|
|
622
|
-
(state.issueFilter === 'open'
|
|
623
|
-
? !['fixed', 'dismissed'].includes(issue.status)
|
|
624
|
-
: issue.status === state.issueFilter));
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
function renderIssueDetail() {
|
|
628
|
-
const detail = state.issueDetail;
|
|
629
|
-
if (!detail) return el('section', { class: 'card panel empty', text: 'Select an issue.' });
|
|
630
|
-
const { issue, fix, candidates } = detail;
|
|
631
|
-
const evidence = issue.evidence ?? {};
|
|
632
|
-
const shots = [['Screenshot', evidence.screenshot], ['Baseline', evidence.baseline], ['Diff', evidence.diff]]
|
|
633
|
-
.filter(([, path]) => path)
|
|
634
|
-
.map(([label, path]) => el('figure', {}, [image(path, label), el('figcaption', { text: label })]));
|
|
635
|
-
const steps = evidence.steps ?? detail.routine?.steps ?? [];
|
|
636
|
-
return el('article', { class: 'card panel issue-detail' }, [
|
|
637
|
-
el('div', { class: 'detail-heading' }, [el('h1', { text: issue.title }), severityChip(issue.severity)]),
|
|
638
|
-
fix?.pr || issue.github || issue.publishSkipped ? el('div', { class: 'kv' }, [
|
|
639
|
-
...issueChips({ pr: fix?.pr, github: issue.github }),
|
|
640
|
-
issue.publishSkipped ? el('span', { class: 'muted', text: `Not published: ${issue.publishSkipped.reason}` }) : null,
|
|
641
|
-
]) : null,
|
|
642
|
-
issue.fixRejected ? el('p', { class: 'muted',
|
|
643
|
-
text: `The team closed PR #${issue.fixRejected.pr} without a merge. Bughunters will not propose this change again.` }) : null,
|
|
644
|
-
el('div', { class: 'kv' }, [el('span', { text: issueStatus(issue, fix) }),
|
|
645
|
-
el('span', { text: screenName(issue.screenId) }), el('span', { text: `×${issue.occurrences}` }),
|
|
646
|
-
el('span', { text: `First ${relativeTime(issue.firstSeenAt)}` }),
|
|
647
|
-
el('span', { text: `Last ${relativeTime(issue.lastSeenAt)}` })]),
|
|
648
|
-
markdown(issue.body),
|
|
649
|
-
shots.length ? el('div', { class: 'evidence-grid' }, shots) : null,
|
|
650
|
-
// The judge writes the clean steps in the body. The recorded path is the
|
|
651
|
-
// exact replay, detours included, so it is one click away.
|
|
652
|
-
el('details', { class: 'recorded-path' }, [
|
|
653
|
-
el('summary', { text: `Recorded path: ${evidence.routineId ? `the routine ${evidence.routineId}, then ` : ''}`
|
|
654
|
-
+ `${steps.length} step(s)` }),
|
|
655
|
-
steps.length ? el('ol', {}, steps.map((step) => el('li', { text: stepWords(step) }))) : null,
|
|
656
|
-
]),
|
|
657
|
-
title("Judge's reasoning"),
|
|
658
|
-
el('p', { text: `${issue.judgement.by}: ${issue.judgement.reason}` }),
|
|
659
|
-
issue.judgement.confidence !== undefined
|
|
660
|
-
? el('p', { class: 'muted', text: `Confidence ${Math.round(issue.judgement.confidence * 100)}%` })
|
|
661
|
-
: null,
|
|
662
|
-
candidates.length
|
|
663
|
-
? el('p', { class: 'muted', text: `From ${candidates.map((candidate) => candidate.id).join(', ')}` })
|
|
664
|
-
: null,
|
|
665
|
-
fix ? el('section', { class: 'fix' }, [
|
|
666
|
-
el('div', { class: 'section-title' }, [el('h2', { text: 'Proposed fix' }), fixBadge(fix)]),
|
|
667
|
-
markdown(fix.summary || 'No summary yet.'),
|
|
668
|
-
...(fix.retests?.length ? [retestBlock(fix.retests.at(-1), issue),
|
|
669
|
-
fix.retests.length > 1 ? el('details', { class: 'earlier-retests' }, [
|
|
670
|
-
el('summary', { text: `Earlier attempts (${fix.retests.length - 1})` }),
|
|
671
|
-
...fix.retests.slice(0, -1).reverse().map((retest) => retestBlock(retest, issue)),
|
|
672
|
-
]) : null] : []),
|
|
673
|
-
title('Code change'),
|
|
674
|
-
fix.diffStat ? el('pre', { text: fix.diffStat }) : null,
|
|
675
|
-
// Collapsed: the verdict and the screenshots come first; the diff is
|
|
676
|
-
// one click away for the reader who reviews the code.
|
|
677
|
-
fix.diff ? el('details', { class: 'diff-details' }, [
|
|
678
|
-
el('summary', { text: `Show the diff (${fix.diff.split(/^diff --git /m).length - 1} file(s))` }),
|
|
679
|
-
diffBlock(fix.diff),
|
|
680
|
-
]) : null,
|
|
681
|
-
el('div', { class: 'kv' }, [el('span', { text: fix.branch }),
|
|
682
|
-
fix.worktreeRemovedAt
|
|
683
|
-
? el('span', { text: `Worktree removed ${new Date(fix.worktreeRemovedAt).toLocaleString()}` })
|
|
684
|
-
: el('code', { text: fix.worktree })]),
|
|
685
|
-
]) : null,
|
|
686
|
-
]);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/** The first sentence, for a list row. Long agent summaries belong in the detail. */
|
|
690
|
-
function firstSentence(text) {
|
|
691
|
-
if (!text) return '';
|
|
692
|
-
const plain = text.replace(/[*`#>]/g, '').replace(/\s+/g, ' ').trim();
|
|
693
|
-
const match = plain.match(/^(.{20,160}?[.!?])(\s|$)/);
|
|
694
|
-
const sentence = match ? match[1] : plain;
|
|
695
|
-
return sentence.length > 160 ? `${sentence.slice(0, 157)}...` : sentence;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
function issueStatus(issue, fix) {
|
|
699
|
-
if (issue.status === 'dismissed') return issue.closedBy ? `Dismissed by ${issue.closedBy.by}` : 'Dismissed';
|
|
700
|
-
if (issue.status === 'fixed') return issue.closedBy ? `Fixed: ${issue.closedBy.reason}` : 'Fixed';
|
|
701
|
-
if (fix?.status === 'rejected' || issue.fixRejected) return 'Fix rejected by the team';
|
|
702
|
-
if (issue.status === 'new' && issue.regression) return 'Regression';
|
|
703
|
-
if (fix?.status === 'verified') return 'Fix verified in the app';
|
|
704
|
-
if (fix?.status === 'retesting') return 'Retesting the fix';
|
|
705
|
-
if (fix?.status === 'proposed' && fix.retests?.at(-1)?.outcome === 'not-fixed') return 'Fix not verified';
|
|
706
|
-
if (fix && ['proposed', 'verified', 'opened'].includes(fix.status)) return 'Fix proposed';
|
|
707
|
-
// The fixer read the code and found nothing to fix: a human should look.
|
|
708
|
-
if (fix && fix.status === 'declined') return 'Fixer: not a bug?';
|
|
709
|
-
return capital(issue.status.replace('-', ' '));
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
function stepWords(step) {
|
|
713
|
-
const target = step.target?.name || step.target?.text || step.target?.testId || step.target?.selector || 'target';
|
|
714
|
-
if (step.kind === 'tap') return `Tap "${target}"`;
|
|
715
|
-
if (step.kind === 'type') return `Type "${step.value}"${step.submit ? ' and submit' : ''}`;
|
|
716
|
-
if (step.kind === 'press') return `Press ${step.key}`;
|
|
717
|
-
if (step.kind === 'scroll') return `Scroll ${step.direction}`;
|
|
718
|
-
if (step.kind === 'open') return `Open ${step.url}`;
|
|
719
|
-
if (step.kind === 'wait') return `Wait ${step.ms} ms`;
|
|
720
|
-
if (step.kind === 'window') return `Switch to window "${step.match}"`;
|
|
721
|
-
return step.kind === 'back' ? 'Go back' : step.kind;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
function diffBlock(diff) {
|
|
725
|
-
return el('div', { class: 'diff-files' }, diff.split(/(?=^diff --git )/m).filter(Boolean).map((section) => {
|
|
726
|
-
const lines = section.split('\n');
|
|
727
|
-
const file = lines[0]?.match(/^diff --git a\/.* b\/(.*)$/)?.[1] ?? 'Patch';
|
|
728
|
-
return el('section', { class: 'diff-file' }, [el('h3', { text: file }),
|
|
729
|
-
el('pre', { class: 'diff-block' }, lines.map((line) => el('span', {
|
|
730
|
-
class: line.startsWith('+') && !line.startsWith('+++') ? 'added'
|
|
731
|
-
: line.startsWith('-') && !line.startsWith('---') ? 'removed'
|
|
732
|
-
: line.startsWith('@@') ? 'hunk' : '', text: line || ' ',
|
|
733
|
-
})))]);
|
|
734
|
-
}));
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
function fixBadge(fix) {
|
|
738
|
-
const last = fix.retests?.at(-1);
|
|
739
|
-
const label = fix.status === 'verified' ? 'Verified in the app'
|
|
740
|
-
: fix.status === 'retesting' ? 'Retesting…'
|
|
741
|
-
: fix.status === 'declined' ? 'Declined'
|
|
742
|
-
: fix.status === 'failed' ? 'Failed'
|
|
743
|
-
: last && last.outcome !== 'fixed' ? 'Not verified' : 'Proposed';
|
|
744
|
-
const kind = fix.status === 'verified' ? 'pass' : ['declined', 'failed'].includes(fix.status) ? 'fail' : 'warn';
|
|
745
|
-
return el('span', { class: `badge ${kind}`, text: label });
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
function retestBlock(retest, issue) {
|
|
749
|
-
const before = retest.before ?? issue.evidence?.screenshot;
|
|
750
|
-
const pair = (shot) => el('div', { class: 'retest-shot-pair' }, [
|
|
751
|
-
retest.shots?.length ? el('h4', { text: screenName(shot.screenId) }) : null,
|
|
752
|
-
shot.reached === false ? el('span', { class: 'muted', text: 'Not reached' }) : null,
|
|
753
|
-
shot.note ? el('p', { class: 'muted', text: shot.note }) : null,
|
|
754
|
-
el('div', { class: 'retest-shots' }, [
|
|
755
|
-
el('figure', {}, [image(shot.before, 'Before'), el('figcaption', { text: 'Before' })]),
|
|
756
|
-
el('figure', {}, [image(shot.after, 'After (with the fix)'),
|
|
757
|
-
el('figcaption', { text: 'After (with the fix)' })]),
|
|
758
|
-
]),
|
|
759
|
-
]);
|
|
760
|
-
return el('div', { class: 'retest-block' }, [
|
|
761
|
-
el('h3', { text: `Retest ${retest.attempt}: ${retest.outcome}` }),
|
|
762
|
-
el('p', { text: retest.reason }),
|
|
763
|
-
retest.shots?.length
|
|
764
|
-
? retest.shots.map((shot) => pair(shot))
|
|
765
|
-
: pair({ before, after: retest.after, note: retest.note }),
|
|
766
|
-
]);
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
function markdown(source = '') {
|
|
770
|
-
const container = el('div', { class: 'markdown' });
|
|
771
|
-
const lines = source.split('\n');
|
|
772
|
-
let list = null;
|
|
773
|
-
let code = null;
|
|
774
|
-
for (const line of lines) {
|
|
775
|
-
if (line.startsWith('```')) {
|
|
776
|
-
if (code) { container.append(code); code = null; }
|
|
777
|
-
else code = el('pre', {}, [el('code')]);
|
|
778
|
-
list = null;
|
|
779
|
-
continue;
|
|
780
|
-
}
|
|
781
|
-
if (code) { code.firstChild.textContent += `${line}\n`; continue; }
|
|
782
|
-
const match = line.match(/^\s*(?:[-*]|\d+\.) (.*)$/);
|
|
783
|
-
if (match) {
|
|
784
|
-
const tag = /^\s*\d+\./.test(line) ? 'ol' : 'ul';
|
|
785
|
-
if (!list || list.tagName.toLowerCase() !== tag) {
|
|
786
|
-
list = el(tag);
|
|
787
|
-
container.append(list);
|
|
788
|
-
}
|
|
789
|
-
list.append(el('li', {}, inlineMarkdown(match[1])));
|
|
790
|
-
continue;
|
|
791
|
-
}
|
|
792
|
-
list = null;
|
|
793
|
-
if (line.trim()) container.append(el('p', {}, inlineMarkdown(line)));
|
|
794
|
-
}
|
|
795
|
-
if (code) container.append(code);
|
|
796
|
-
return container;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
function inlineMarkdown(line) {
|
|
800
|
-
const parts = [];
|
|
801
|
-
const pattern = /(\*\*([^*]+)\*\*|`([^`]+)`)/g;
|
|
802
|
-
let start = 0;
|
|
803
|
-
for (const match of line.matchAll(pattern)) {
|
|
804
|
-
if (match.index > start) parts.push(document.createTextNode(line.slice(start, match.index)));
|
|
805
|
-
parts.push(el(match[2] ? 'strong' : 'code', { text: match[2] || match[3] }));
|
|
806
|
-
start = match.index + match[0].length;
|
|
807
|
-
}
|
|
808
|
-
if (start < line.length) parts.push(document.createTextNode(line.slice(start)));
|
|
809
|
-
return parts;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
function renderActivity() {
|
|
813
|
-
const list = el('section', { class: 'card panel' }, [title('Activity'),
|
|
814
|
-
...state.sessions.map((session) => el('button', {
|
|
815
|
-
class: `list-row ${session.id === state.selectedSessionId ? 'active' : ''}`,
|
|
816
|
-
onclick: async () => { state.selectedSessionId = session.id; await refresh(); } }, [
|
|
817
|
-
roleIcon(session.role), el('span', { class: 'grow' }, [
|
|
818
|
-
el('strong', { text: firstSentence(session.summary) || `${capital(session.role)} session` }),
|
|
819
|
-
el('small', { class: 'muted',
|
|
820
|
-
text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)} · ${session.steps} steps · ` +
|
|
821
|
-
`${session.tokens ? `${tokens(session.tokens)} · ` : ''}API ${money(session.costUsd)} · `
|
|
822
|
-
+ `${session.screensFound.length} screens · ${session.issues.length} issues`,
|
|
823
|
-
}),
|
|
824
|
-
]),
|
|
825
|
-
])),
|
|
826
|
-
]);
|
|
827
|
-
const detail = state.sessionDetail;
|
|
828
|
-
const timeline = detail ? el('section', { class: 'card panel' }, [
|
|
829
|
-
title('Timeline', `${capital(detail.session.role)} · ${relativeTime(detail.session.startedAt)}`),
|
|
830
|
-
detail.session.summary ? el('div', { class: 'session-summary' }, [markdown(detail.session.summary)]) : null,
|
|
831
|
-
el('div', { class: 'muted session-usage' }, detail.session.tokensByModel
|
|
832
|
-
? Object.entries(detail.session.tokensByModel).map(([model, usage]) =>
|
|
833
|
-
el('div', { title: tokenDetail(usage), text: `${model}: ${tokens(usage)}` }))
|
|
834
|
-
: [el('div', { text: 'No token usage reported for this session.' })]),
|
|
835
|
-
el('label', { class: 'toggle' }, [el('input', {
|
|
836
|
-
type: 'checkbox',
|
|
837
|
-
...(state.showThoughts ? { checked: '' } : {}),
|
|
838
|
-
onchange: (event) => { state.showThoughts = event.target.checked; render(); },
|
|
839
|
-
}), el('span', { text: 'Show agent reasoning' })]),
|
|
840
|
-
el('div', { class: 'event-list' }, detail.events.filter((event) => state.showThoughts || isFeedEvent(event))
|
|
841
|
-
.map(renderEventRow)),
|
|
842
|
-
detail.candidates.length ? title('Candidates') : null,
|
|
843
|
-
...detail.candidates.map((candidate) => el('div', { class: 'candidate' }, [
|
|
844
|
-
el('strong', { text: candidate.summary }),
|
|
845
|
-
el('span', { class: 'muted', text: `${candidate.route?.to || 'unrouted'} · ${candidate.route?.reason || ''}` }),
|
|
846
|
-
])),
|
|
847
|
-
]) : el('section', { class: 'card panel empty', text: 'Select a session.' });
|
|
848
|
-
return el('div', { class: 'master-detail' }, [list, timeline]);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
function duration(session) {
|
|
852
|
-
const end = session.endedAt ? new Date(session.endedAt).getTime() : Date.now();
|
|
853
|
-
const seconds = Math.max(0, Math.round((end - new Date(session.startedAt).getTime()) / 1000));
|
|
854
|
-
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
function renderEventRow(event) {
|
|
858
|
-
return el('div', { class: 'event-row' }, [
|
|
859
|
-
el('time', { text: new Date(event.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }),
|
|
860
|
-
roleIcon(event.role), el('span', { class: 'grow truncate', text: event.summary }),
|
|
861
|
-
event.tokens && event.kind !== 'usage'
|
|
862
|
-
? el('small', { class: 'muted token-chip', title: `${event.model ?? ''} ${tokenDetail(event.tokens)}`.trim(), text: tokens(event.tokens, '') })
|
|
863
|
-
: null,
|
|
864
|
-
event.screenshot ? image(event.screenshot, event.summary, 'event-thumb') : null,
|
|
865
|
-
event.input !== undefined || event.output !== undefined ? el('details', {}, [
|
|
866
|
-
el('summary', { text: 'Tool data' }),
|
|
867
|
-
el('pre', { text: JSON.stringify({ input: event.input, output: event.output }, null, 2) }),
|
|
868
|
-
]) : null,
|
|
869
|
-
]);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
async function openScreen(id) {
|
|
873
|
-
state.view = 'screens';
|
|
874
|
-
state.selectedScreenId = id;
|
|
875
|
-
await refresh();
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
function renderScreens() {
|
|
879
|
-
const graphScreens = state.appmap?.screens ?? [];
|
|
880
|
-
const screens = graphScreens.filter((screen) => !screen.virtual);
|
|
881
|
-
const edges = state.appmap?.edges ?? [];
|
|
882
|
-
const selected = screens.find((screen) => screen.id === state.selectedScreenId);
|
|
883
|
-
if (selected) {
|
|
884
|
-
const routine = state.routines.find((entry) => entry.id === selected.routineId);
|
|
885
|
-
const issues = state.issues.filter((issue) =>
|
|
886
|
-
issue.screenId === selected.id && !['fixed', 'dismissed'].includes(issue.status));
|
|
887
|
-
return el('div', { class: 'card panel screen-detail' }, [
|
|
888
|
-
el('button', { class: 'back', text: '← All screens',
|
|
889
|
-
onclick: () => { state.selectedScreenId = null; render(); } }),
|
|
890
|
-
title(selected.name, `${selected.visits} visits · ${relativeTime(selected.lastSeenAt)}`),
|
|
891
|
-
image(selected.lastScreenshot, selected.name, 'screen-large'),
|
|
892
|
-
el('p', { text: selected.description }),
|
|
893
|
-
title('Routine'),
|
|
894
|
-
el('p', { text: routine
|
|
895
|
-
? `${routine.id} · ${routine.description} · ${routine.steps} steps`
|
|
896
|
-
: selected.routineId || 'No routine' }),
|
|
897
|
-
...[['Comes from', edges.filter((edge) => edge.to === selected.id), 'from'],
|
|
898
|
-
['Goes to', edges.filter((edge) => edge.from === selected.id), 'to']].map(([heading, links, end]) =>
|
|
899
|
-
el('section', { class: 'screen-links' }, [title(heading), ...links.map((edge) =>
|
|
900
|
-
el('button', { class: 'list-row', onclick: () => openScreen(edge[end]) }, [
|
|
901
|
-
el('strong', { text: screenName(edge[end]) }),
|
|
902
|
-
edge.via ? el('small', { class: 'muted', text: edge.via }) : null,
|
|
903
|
-
]))])),
|
|
904
|
-
title('Open issues', `${selected.openIssues}`),
|
|
905
|
-
...issues.map((issue) => el('button', { class: 'list-row', onclick: () => openIssue(issue.id) }, [
|
|
906
|
-
el('strong', { text: issue.title }), severityChip(issue.severity),
|
|
907
|
-
])),
|
|
908
|
-
]);
|
|
909
|
-
}
|
|
910
|
-
const toggle = el('div', { class: 'screen-mode', role: 'group', 'aria-label': 'Screen view' },
|
|
911
|
-
['graph', 'grid'].map((mode) => el('button', { class: screenMode === mode ? 'active' : '',
|
|
912
|
-
text: mode === 'graph' ? 'Graph' : 'Grid', 'aria-pressed': screenMode === mode,
|
|
913
|
-
onclick: () => { screenMode = mode; try { localStorage.setItem('bughunters-screen-mode', mode); } catch { /* disabled */ } render(); },
|
|
914
|
-
})));
|
|
915
|
-
return el('div', {}, [title('Screens', `${screens.length} found`),
|
|
916
|
-
toggle,
|
|
917
|
-
screens.length === 0 ? el('div', { class: 'empty', text: 'No screens found yet.' })
|
|
918
|
-
: screenMode === 'graph' ? renderScreenGraph(graphScreens, edges) : el('div', { class: 'screen-grid' }, screens.map((screen) => el('button', {
|
|
919
|
-
class: 'card screen-card', onclick: () => openScreen(screen.id),
|
|
920
|
-
}, [image(screen.lastScreenshot, screen.name), el('div', { class: 'screen-copy' }, [
|
|
921
|
-
el('strong', { text: screen.name }),
|
|
922
|
-
el('p', { class: 'truncate muted', text: screen.description }),
|
|
923
|
-
el('small', { class: 'muted', text: `${relativeTime(screen.lastSeenAt)} · ${screen.visits} visits` }),
|
|
924
|
-
screen.openIssues ? el('span', { class: 'issue-count', text: `${screen.openIssues} open` }) : null,
|
|
925
|
-
])])))]);
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
function renderScreenGraph(screens, allEdges) {
|
|
929
|
-
const layout = layoutGraph(screens, allEdges, state.appmap?.entryId);
|
|
930
|
-
const key = `${screens.map((screen) => screen.id).join('|')}|${allEdges.length}`;
|
|
931
|
-
const canvas = svgEl('svg', { class: 'screen-graph', role: 'group', 'aria-label': 'Map of app screens' });
|
|
932
|
-
const marker = svgEl('marker', { id: 'graph-arrow', markerWidth: 9, markerHeight: 9, refX: 8, refY: 4.5,
|
|
933
|
-
orient: 'auto', markerUnits: 'userSpaceOnUse' }, [svgEl('path', { d: 'M 0 0 L 9 4.5 L 0 9 Z' })]);
|
|
934
|
-
const mutedMarker = svgEl('marker', { id: 'graph-arrow-muted', markerWidth: 9, markerHeight: 9,
|
|
935
|
-
refX: 8, refY: 4.5, orient: 'auto', markerUnits: 'userSpaceOnUse' },
|
|
936
|
-
[svgEl('path', { d: 'M 0 0 L 9 4.5 L 0 9 Z' })]);
|
|
937
|
-
canvas.append(svgEl('defs', {}, [marker, mutedMarker]));
|
|
938
|
-
const edgeNodes = layout.edges.filter((edge) => showBackLinks || edge.kind !== 'back').map((edge) => {
|
|
939
|
-
const path = svgEl('path', { d: edge.path, class: `graph-edge ${edge.kind}`,
|
|
940
|
-
'data-from': edge.from, 'data-to': edge.to,
|
|
941
|
-
'marker-end': `url(#graph-arrow${['route', 'other', 'back'].includes(edge.kind) ? '-muted' : ''})`,
|
|
942
|
-
'stroke-width': ['route', 'other'].includes(edge.kind) ? 1 : 1.5 + Math.min(4, Math.log2(edge.count || 1)) });
|
|
943
|
-
path.append(svgEl('title', { text: edge.via || edge.kind }));
|
|
944
|
-
return path;
|
|
945
|
-
});
|
|
946
|
-
canvas.append(svgEl('g', { class: 'graph-edges' }, edgeNodes));
|
|
947
|
-
if (layout.unlinkedX !== null) canvas.append(svgEl('text', { x: layout.unlinkedX, y: layout.unlinkedY + 20,
|
|
948
|
-
class: 'graph-unlinked', text: 'Not linked' }));
|
|
949
|
-
const screenById = new Map(screens.map((screen) => [screen.id, screen]));
|
|
950
|
-
let dragMoved = false;
|
|
951
|
-
for (const node of layout.nodes) {
|
|
952
|
-
const screen = screenById.get(node.id);
|
|
953
|
-
if (screen.virtual) {
|
|
954
|
-
canvas.append(svgEl('g', { class: 'graph-start', 'aria-label': 'App start' }, [
|
|
955
|
-
svgEl('rect', { x: node.x, y: node.y, width: node.w, height: node.h, rx: node.h / 2 }),
|
|
956
|
-
svgEl('text', { x: node.x + node.w / 2, y: node.y + node.h / 2 + 5,
|
|
957
|
-
'text-anchor': 'middle', text: 'App start' }),
|
|
958
|
-
]));
|
|
959
|
-
continue;
|
|
960
|
-
}
|
|
961
|
-
const card = svgEl('g', { class: 'graph-node', role: 'button', tabindex: 0,
|
|
962
|
-
'aria-label': `${screen.name}${screen.openIssues ? `, ${screen.openIssues} open issues` : ''}`,
|
|
963
|
-
onclick: () => { if (!dragMoved) openScreen(node.id); },
|
|
964
|
-
onkeydown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openScreen(node.id); } },
|
|
965
|
-
onpointerenter: () => { for (const path of edgeNodes) path.classList.toggle('faded',
|
|
966
|
-
path.getAttribute('data-from') !== node.id && path.getAttribute('data-to') !== node.id); },
|
|
967
|
-
onpointerleave: () => edgeNodes.forEach((path) => path.classList.remove('faded')),
|
|
968
|
-
});
|
|
969
|
-
card.append(svgEl('rect', { x: node.x, y: node.y, width: node.w, height: node.h, rx: 9,
|
|
970
|
-
class: 'graph-card' }));
|
|
971
|
-
if (screen.lastScreenshot) card.append(svgEl('image', { x: node.x + 5, y: node.y + 5,
|
|
972
|
-
width: node.w - 10, height: 104, href: artifact(screen.lastScreenshot), preserveAspectRatio: 'xMidYMin slice' }));
|
|
973
|
-
card.append(svgEl('text', { x: node.x + 10, y: node.y + 134, class: 'graph-name',
|
|
974
|
-
text: screen.name.length > 24 ? `${screen.name.slice(0, 23)}…` : screen.name }));
|
|
975
|
-
if (screen.openIssues) {
|
|
976
|
-
card.append(svgEl('circle', { cx: node.x + node.w - 12, cy: node.y + 12, r: 12, class: 'graph-issue' }));
|
|
977
|
-
card.append(svgEl('text', { x: node.x + node.w - 12, y: node.y + 16,
|
|
978
|
-
'text-anchor': 'middle', class: 'graph-issue-text', text: screen.openIssues }));
|
|
979
|
-
}
|
|
980
|
-
card.append(svgEl('title', { text: screen.name }));
|
|
981
|
-
canvas.append(card);
|
|
982
|
-
}
|
|
983
|
-
let box = graphCamera.key === key ? graphCamera.box : null;
|
|
984
|
-
const setBox = (next) => { box = next; graphCamera.box = next; graphCamera.key = key;
|
|
985
|
-
canvas.setAttribute('viewBox', `${next.x} ${next.y} ${next.w} ${next.h}`); };
|
|
986
|
-
const fit = () => {
|
|
987
|
-
const width = canvas.clientWidth, height = canvas.clientHeight;
|
|
988
|
-
if (!width || !height) return;
|
|
989
|
-
const scale = Math.max((layout.width + 80) / width, (layout.height + 100) / height);
|
|
990
|
-
setBox({ x: (layout.width - width * scale) / 2, y: (layout.height - height * scale) / 2,
|
|
991
|
-
w: width * scale, h: height * scale });
|
|
992
|
-
};
|
|
993
|
-
const zoom = (factor, clientX, clientY) => {
|
|
994
|
-
if (!box) fit();
|
|
995
|
-
const bounds = canvas.getBoundingClientRect();
|
|
996
|
-
const fx = (clientX - bounds.left) / bounds.width, fy = (clientY - bounds.top) / bounds.height;
|
|
997
|
-
const w = Math.max(200, Math.min(layout.width * 5, box.w * factor));
|
|
998
|
-
const h = w * bounds.height / bounds.width;
|
|
999
|
-
setBox({ x: box.x + (box.w - w) * fx, y: box.y + (box.h - h) * fy, w, h });
|
|
1000
|
-
};
|
|
1001
|
-
canvas.addEventListener('wheel', (event) => { event.preventDefault();
|
|
1002
|
-
zoom(Math.exp(event.deltaY * 0.001), event.clientX, event.clientY); }, { passive: false });
|
|
1003
|
-
const pointers = new Map();
|
|
1004
|
-
let lastPinch = 0;
|
|
1005
|
-
canvas.addEventListener('pointerdown', (event) => { dragMoved = false;
|
|
1006
|
-
pointers.set(event.pointerId, [event.clientX, event.clientY]);
|
|
1007
|
-
event.target.setPointerCapture(event.pointerId); });
|
|
1008
|
-
canvas.addEventListener('pointermove', (event) => {
|
|
1009
|
-
if (!pointers.has(event.pointerId)) return;
|
|
1010
|
-
const before = pointers.get(event.pointerId);
|
|
1011
|
-
if (Math.hypot(event.clientX - before[0], event.clientY - before[1]) > 3) dragMoved = true;
|
|
1012
|
-
pointers.set(event.pointerId, [event.clientX, event.clientY]);
|
|
1013
|
-
if (pointers.size === 2) {
|
|
1014
|
-
const [a, b] = [...pointers.values()];
|
|
1015
|
-
const distance = Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
1016
|
-
if (lastPinch) zoom(lastPinch / distance, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2);
|
|
1017
|
-
lastPinch = distance;
|
|
1018
|
-
} else if (box) setBox({ ...box, x: box.x - (event.clientX - before[0]) * box.w / canvas.clientWidth,
|
|
1019
|
-
y: box.y - (event.clientY - before[1]) * box.h / canvas.clientHeight });
|
|
1020
|
-
});
|
|
1021
|
-
const endPointer = (event) => { pointers.delete(event.pointerId); lastPinch = 0; };
|
|
1022
|
-
canvas.addEventListener('pointerup', endPointer);
|
|
1023
|
-
canvas.addEventListener('pointercancel', endPointer);
|
|
1024
|
-
requestAnimationFrame(() => {
|
|
1025
|
-
if (!canvas.isConnected) return;
|
|
1026
|
-
if (box) { setBox(box); return; }
|
|
1027
|
-
if (canvas.clientWidth && canvas.clientHeight) { fit(); return; }
|
|
1028
|
-
const observer = new ResizeObserver(() => {
|
|
1029
|
-
if (!canvas.isConnected) { observer.disconnect(); return; }
|
|
1030
|
-
if (canvas.clientWidth && canvas.clientHeight) { observer.disconnect(); fit(); }
|
|
1031
|
-
});
|
|
1032
|
-
observer.observe(canvas);
|
|
1033
|
-
});
|
|
1034
|
-
return el('div', { class: 'graph-wrap' }, [
|
|
1035
|
-
el('div', { class: 'graph-toolbar' }, [
|
|
1036
|
-
el('button', { text: 'Fit', onclick: fit }),
|
|
1037
|
-
el('button', { text: '+', 'aria-label': 'Zoom in', onclick: () => zoom(.8, canvas.getBoundingClientRect().x + canvas.clientWidth / 2, canvas.getBoundingClientRect().y + canvas.clientHeight / 2) }),
|
|
1038
|
-
el('button', { text: '−', 'aria-label': 'Zoom out', onclick: () => zoom(1.25, canvas.getBoundingClientRect().x + canvas.clientWidth / 2, canvas.getBoundingClientRect().y + canvas.clientHeight / 2) }),
|
|
1039
|
-
el('label', {}, [el('input', { type: 'checkbox', checked: showBackLinks ? '' : null,
|
|
1040
|
-
onchange: (event) => { showBackLinks = event.target.checked; render(); } }), ' Show back links']),
|
|
1041
|
-
]),
|
|
1042
|
-
el('p', { class: 'graph-legend', text: 'Solid = tap · dashed = deep link · grey = replay route' }),
|
|
1043
|
-
canvas,
|
|
1044
|
-
]);
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
function renderMemory() {
|
|
1048
|
-
const lessons = state.memory?.lessons ?? [];
|
|
1049
|
-
const row = (lesson) => el('div', { class: 'memory-row' }, [
|
|
1050
|
-
el('span', { class: 'grow', text: lesson.text }),
|
|
1051
|
-
el('span', { class: 'badge info', text: lesson.scope ?? 'app' }),
|
|
1052
|
-
el('span', { class: 'muted', text: `${lesson.source} · ×${lesson.hits} · ${relativeTime(lesson.lastSeenAt)}` }),
|
|
1053
|
-
]);
|
|
1054
|
-
return el('section', { class: 'card panel' }, [title('Memory'),
|
|
1055
|
-
el('p', { class: 'muted', text: 'Use bughunters memory to edit lessons.' }),
|
|
1056
|
-
...['explorer', 'judge', 'fixer'].map((role) => el('section', { class: 'memory-group' }, [
|
|
1057
|
-
el('h3', { text: capital(role) }),
|
|
1058
|
-
...lessons.filter((lesson) => lesson.role === role && !lesson.retired).map(row),
|
|
1059
|
-
])),
|
|
1060
|
-
el('details', {}, [el('summary', { text: `Retired lessons (${lessons.filter((lesson) => lesson.retired).length})` }),
|
|
1061
|
-
...lessons.filter((lesson) => lesson.retired).map(row)]),
|
|
1062
|
-
]);
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
// ---------------------------------------------------------------- lightbox
|
|
1066
|
-
|
|
1067
|
-
const lightbox = document.getElementById('lightbox');
|
|
1068
|
-
const lightboxImg = document.getElementById('lightbox-img');
|
|
1069
|
-
const lightboxCaption = document.getElementById('lightbox-caption');
|
|
1070
|
-
|
|
1071
|
-
function openLightbox(path, caption) {
|
|
1072
|
-
lightboxImg.src = artifact(path);
|
|
1073
|
-
lightboxCaption.textContent = `${caption} — ${path}`;
|
|
1074
|
-
lightbox.hidden = false;
|
|
1075
|
-
}
|
|
1076
|
-
lightbox.addEventListener('click', () => { lightbox.hidden = true; });
|
|
1077
|
-
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') lightbox.hidden = true; });
|
|
1078
|
-
|
|
1079
|
-
// ---------------------------------------------------------------- boot
|
|
1080
|
-
|
|
1081
|
-
tabs.addEventListener('click', async (event) => {
|
|
1082
|
-
const target = event.target.closest('button');
|
|
1083
|
-
if (!target) return;
|
|
1084
|
-
state.view = target.dataset.view;
|
|
1085
|
-
if (state.view === 'screens') state.selectedScreenId = null;
|
|
1086
|
-
await refresh();
|
|
1087
|
-
});
|
|
1088
|
-
|
|
1089
|
-
function summaryOf(run, findings) {
|
|
1090
|
-
return {
|
|
1091
|
-
exitCode: run.exitCode, status: run.status,
|
|
1092
|
-
findings: { blocking: findings.filter((f) => f.route === 'check').length },
|
|
1093
|
-
};
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
function verdictClass(summary) {
|
|
1097
|
-
if (summary.exitCode === 4) return 'info';
|
|
1098
|
-
if (summary.findings.blocking > 0) return 'fail';
|
|
1099
|
-
if (summary.status === 'incomplete') return 'warn';
|
|
1100
|
-
return 'pass';
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
function verdictLabel(summary) {
|
|
1104
|
-
if (summary.exitCode === 4) return 'could not test';
|
|
1105
|
-
if (summary.findings.blocking > 0) return `${summary.findings.blocking} blocking`;
|
|
1106
|
-
if (summary.status === 'incomplete') return 'incomplete';
|
|
1107
|
-
return 'pass';
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
/** Abbreviate a real SHA; leave a human label like `working-tree` intact. */
|
|
1111
|
-
function shortCommit(commit) {
|
|
1112
|
-
return /^[0-9a-f]{40}$/i.test(commit) ? commit.slice(0, 8) : commit;
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
function formatTime(value) {
|
|
1116
|
-
const date = new Date(value);
|
|
1117
|
-
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
connectLive();
|
|
1121
|
-
setInterval(() => {
|
|
1122
|
-
if (state.overview?.agents.some((agent) => agent.state === 'working')) refresh();
|
|
1123
|
-
}, 5000);
|
|
1124
|
-
refresh({ keepSelection: false }).catch((error) => {
|
|
1125
|
-
view.append(el('div', { class: 'empty', text: String(error) }));
|
|
1126
|
-
});
|