@giamme/forge 0.8.0 → 0.10.0

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +22 -3
  4. package/VERSION +1 -1
  5. package/docs/README.md +26 -0
  6. package/docs/add-ons.md +33 -0
  7. package/docs/artifacts.md +84 -0
  8. package/docs/commands.md +365 -0
  9. package/docs/dataset.md +29 -0
  10. package/docs/decomposed-runs.md +310 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +128 -0
  13. package/docs/fractal.md +181 -0
  14. package/docs/install.md +160 -0
  15. package/docs/memory.md +156 -0
  16. package/docs/models.md +118 -0
  17. package/docs/reviews.md +245 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +20 -0
  21. package/package.json +3 -2
  22. package/references/dataset.md +18 -1
  23. package/references/decompose.md +20 -2
  24. package/references/fractal.md +1 -1
  25. package/references/harnesses.md +18 -6
  26. package/references/solo.md +1 -1
  27. package/references/ui.md +27 -5
  28. package/scripts/forge-config.py +347 -0
  29. package/scripts/forge-control.py +645 -0
  30. package/scripts/forge-dispatch.sh +7 -1
  31. package/scripts/forge-issues.py +806 -0
  32. package/scripts/forge-lib-task.sh +234 -35
  33. package/scripts/forge-parallel-options.sh +3 -1
  34. package/scripts/forge-parallel.sh +3 -2
  35. package/scripts/forge-route.py +184 -27
  36. package/scripts/forge-runs-rm.py +840 -0
  37. package/scripts/forge-runs.py +139 -0
  38. package/scripts/forge-runtime.py +59 -6
  39. package/scripts/forge-schedule.py +227 -11
  40. package/scripts/forge-ui.py +85 -19
  41. package/scripts/forge-version.py +54 -12
  42. package/scripts/forge_dataset/__init__.py +77 -6
  43. package/scripts/forge_dataset/capture.py +5 -2
  44. package/scripts/forge_dataset/harvest.py +23 -1
  45. package/scripts/forge_dataset/importer.py +420 -18
  46. package/scripts/forge_fractal/__init__.py +2 -1
  47. package/scripts/forge_registry.py +168 -16
  48. package/ui/README.md +116 -23
  49. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  50. package/ui/docs/api.md +283 -23
  51. package/ui/forge_ui/__main__.py +10 -2
  52. package/ui/forge_ui/control.py +575 -0
  53. package/ui/forge_ui/export.py +33 -9
  54. package/ui/forge_ui/fractal.py +1 -2
  55. package/ui/forge_ui/reader.py +121 -8
  56. package/ui/forge_ui/server.py +135 -5
  57. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  58. package/ui/forge_ui/static/app.js +358 -131
  59. package/ui/forge_ui/static/cloud3d.js +3 -2
  60. package/ui/forge_ui/static/cloudui.js +8 -5
  61. package/ui/forge_ui/static/controls.js +619 -0
  62. package/ui/forge_ui/static/convo.js +295 -0
  63. package/ui/forge_ui/static/datatable.js +232 -0
  64. package/ui/forge_ui/static/diffview.js +29 -10
  65. package/ui/forge_ui/static/findings3d.js +134 -0
  66. package/ui/forge_ui/static/findingsview.js +412 -0
  67. package/ui/forge_ui/static/graph.js +3 -3
  68. package/ui/forge_ui/static/icons.js +94 -0
  69. package/ui/forge_ui/static/index.html +31 -13
  70. package/ui/forge_ui/static/iso.js +70 -19
  71. package/ui/forge_ui/static/leaderboard.js +44 -48
  72. package/ui/forge_ui/static/panel.js +92 -36
  73. package/ui/forge_ui/static/rundelete.js +388 -0
  74. package/ui/forge_ui/static/runmodel.js +17 -1
  75. package/ui/forge_ui/static/scene3d.js +518 -43
  76. package/ui/forge_ui/static/search.js +2 -1
  77. package/ui/forge_ui/static/settings.js +399 -0
  78. package/ui/forge_ui/static/shell.js +137 -0
  79. package/ui/forge_ui/static/stagectl.js +150 -0
  80. package/ui/forge_ui/static/style.css +1099 -469
  81. package/ui/forge_ui/static/theme.js +25 -0
  82. package/ui/forge_ui/static/uikit.js +161 -0
  83. package/ui/forge_ui/transcripts.py +608 -0
@@ -0,0 +1,295 @@
1
+ /* forge-ui conversation: what one dwarf or reviewer said and did, read from its agent CLI's log (transcripts.py), shown
2
+ in the stage's place like a diff. Its messages, its thinking folded, each command with its exit and output, each edit
3
+ with its patch and a link to the diff, other tool calls, the final answer and the tokens; a search, the errors one key
4
+ apart, and while the agent runs a poll every few seconds that follows the end. Defines window.ForgeConvo.render(host, ctx).
5
+ All API values enter the DOM as text.
6
+ ctx = { run, spec: {task, exec}, brief (the Execution, when the run lists it), live (false in an export), fetchJSON(path),
7
+ rerender(), closeHref, diffHash(spec) → hash } */
8
+ window.ForgeConvo = (() => {
9
+ 'use strict';
10
+ const list = v => Array.isArray(v) ? v : [];
11
+ const POLL = 2500; // ms between reads of a log being written
12
+ const FOLD = 14; // lines of an output shown before it folds
13
+ const cache = new Map(); // url → {data, error, pending, at, timer}
14
+ const views = new Map(); // url → {query, show, follow, open: Set, err}
15
+ const enc = encodeURIComponent;
16
+ function el(tag, text, cls) {
17
+ const n = document.createElement(tag);
18
+ if (text !== undefined && text !== null) n.textContent = String(text);
19
+ if (cls) n.className = cls;
20
+ return n;
21
+ }
22
+ const icon = name => window.ForgeIcons ? window.ForgeIcons.icon(name) : el('span');
23
+ const chip = (text, st) => el('span', text, 'chip st-' + st);
24
+ const num = v => Number.isFinite(Number(v)) && v !== null ? Number(v).toLocaleString() : '—';
25
+ const ICON = { prompt: 'flag', message: 'chat', reasoning: 'thought', command: 'terminal', edit: 'pencil', tool: 'tool', result: 'check', error: 'cross', text: 'keyboard', gap: 'chevron-down' };
26
+ // which kinds each filter key shows
27
+ const SHOW = [['all', 'All', null], ['said', 'Said', ['prompt', 'message', 'reasoning', 'result', 'error']], ['ran', 'Ran', ['command', 'tool', 'error']],
28
+ ['changed', 'Changed', ['edit']]];
29
+ const urlFor = (run, spec) => `/api/runs/${enc(run.key)}/executions/${enc(spec.exec)}/transcript`;
30
+
31
+ function read(ctx, url, entry) {
32
+ entry.pending = true;
33
+ ctx.fetchJSON(url).then(d => { entry.data = d; entry.error = null; }, e => { entry.error = e; })
34
+ .finally(() => { entry.at = performance.now(); entry.pending = false; ctx.rerender(); });
35
+ }
36
+ function load(ctx, url) {
37
+ let entry = cache.get(url);
38
+ if (!entry) {
39
+ entry = { pending: false, data: null, error: null, at: 0, timer: null };
40
+ cache.set(url, entry);
41
+ for (const old of cache.keys()) if (cache.size > 12 && old !== url) cache.delete(old); // the conversations opened last
42
+ read(ctx, url, entry);
43
+ }
44
+ // a finished log never changes; one still being written is read again when the view comes back to it, and a
45
+ // read that failed is tried again after a while
46
+ else if (ctx.live && !entry.pending && !entry.timer && performance.now() - entry.at > (entry.error ? 10 * POLL : POLL) &&
47
+ (entry.error || (entry.data && entry.data.state === 'running'))) read(ctx, url, entry);
48
+ return entry;
49
+ }
50
+
51
+ /* ---------- the frame: title, what was read, the tools; built once per conversation ---------- */
52
+ function frame(host, ctx, url, v) {
53
+ host.replaceChildren();
54
+ const f = { url, drawn: [], nodes: [] };
55
+ const top = el('div', undefined, 'diff-top convo-top');
56
+ f.title = el('h2'); f.chips = el('div', undefined, 'chips');
57
+ const close = el('a', undefined, 'diff-close button'); close.href = ctx.closeHref; close.append(icon('close'), document.createTextNode('Close'));
58
+ top.append(f.title, f.chips, close);
59
+ f.meta = el('p', undefined, 'diff-meta convo-meta muted');
60
+ const tools = el('div', undefined, 'convo-tools'); tools.setAttribute('role', 'toolbar'); tools.setAttribute('aria-label', 'Conversation');
61
+ const find = el('label', undefined, 'convo-find'); find.append(icon('search'));
62
+ f.search = el('input'); f.search.type = 'search'; f.search.placeholder = 'Search what it said and ran'; f.search.setAttribute('aria-label', 'Search the conversation');
63
+ f.search.value = v.query;
64
+ f.search.addEventListener('input', () => { v.query = f.search.value; filter(f, v); });
65
+ f.search.addEventListener('keydown', e => { if (e.key === 'Escape' && f.search.value) { e.stopPropagation(); f.search.value = ''; v.query = ''; filter(f, v); } });
66
+ find.append(f.search);
67
+ f.count = el('span', undefined, 'convo-count muted'); f.count.setAttribute('aria-live', 'polite');
68
+ f.show = el('div', undefined, 'seg'); f.show.setAttribute('role', 'group'); f.show.setAttribute('aria-label', 'Show');
69
+ for (const [key, label] of SHOW) {
70
+ const b = el('button', label); b.type = 'button'; b.dataset.show = key;
71
+ b.addEventListener('click', () => { v.show = key; filter(f, v); });
72
+ f.show.append(b);
73
+ }
74
+ f.errs = el('span', undefined, 'convo-errs');
75
+ f.err = el('button', 'Next error', 'convo-err'); f.err.type = 'button'; f.err.title = 'Next error (E)'; f.err.setAttribute('aria-keyshortcuts', 'E');
76
+ f.err.append(el('kbd', 'E'));
77
+ f.err.addEventListener('click', () => nextError(f, v));
78
+ f.follow = el('button', 'Follow', 'convo-follow latch'); f.follow.type = 'button'; f.follow.title = 'Keep the newest event in view while it runs';
79
+ f.follow.addEventListener('click', () => { v.follow = !v.follow; f.follow.setAttribute('aria-pressed', String(v.follow)); if (v.follow) end(f); });
80
+ tools.append(find, f.count, f.show, f.errs, f.err, f.follow);
81
+ f.status = el('div', undefined, 'convo-status');
82
+ f.list = el('ol', undefined, 'convo-list'); f.list.setAttribute('aria-label', 'Events');
83
+ host.append(top, f.meta, tools, f.status, f.list);
84
+ host.tabIndex = -1; host.setAttribute('aria-label', 'Conversation: / searches, E goes to the next error');
85
+ host.onkeydown = e => {
86
+ if (e.altKey || e.ctrlKey || e.metaKey || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
87
+ if (e.key === '/') { e.preventDefault(); f.search.focus(); f.search.select(); }
88
+ else if (e.key === 'e' || e.key === 'E') { e.preventDefault(); nextError(f, v); }
89
+ };
90
+ // a fold opened or closed stays so through a rebuild
91
+ f.list.addEventListener('toggle', e => { const d = e.target; if (d.dataset && d.dataset.fold) { if (d.open) v.open.add(d.dataset.fold); else v.open.delete(d.dataset.fold); } }, true);
92
+ return f;
93
+ }
94
+
95
+ function header(f, ctx, D) {
96
+ const spec = ctx.spec, run = ctx.run, b = ctx.brief || {};
97
+ const owner = D && D.owner || {}, role = (D && D.role) || b.role || String(spec.exec).split('-')[0];
98
+ const where = owner.kind === 'final_review' ? 'Final review' : spec.task === 'solo' || run.kind === 'solo' ? 'Solo run' :
99
+ owner.kind === 'shadow' ? `Task ${String(owner.id).split('/')[0]} · shadow` : spec.task ? `Task ${spec.task}` : 'Run';
100
+ const attempt = D && D.attempt != null ? D.attempt : b.attempt;
101
+ f.title.replaceChildren(el('span', 'Conversation · ', 'muted'), document.createTextNode(`${where} · ${role === 'qa' ? 'reviewer' : role}${attempt != null ? ' · attempt ' + attempt : ''}`));
102
+ const chips = [];
103
+ const s = (D && D.spec) || (b.model && b.model.spec);
104
+ if (s && window.ForgeIcons) chips.push(window.ForgeIcons.modelPlate(s));
105
+ // one lamp: running, else a review's verdict, else how it ended
106
+ const state = (D && D.state) || b.state;
107
+ if (state === 'running') chips.push(chip('running', 'running'));
108
+ else if (b.verdict) chips.push(chip('verdict ' + b.verdict, String(b.verdict).toLowerCase()));
109
+ else if (b.id && window.ForgeRunModel) { const st = window.ForgeRunModel.execStatus(b); chips.push(chip(st, String(st).toLowerCase())); }
110
+ f.chips.replaceChildren(...chips);
111
+ if (!D) { f.meta.replaceChildren(); return; }
112
+ // where it was read from, then the numbers in a describer window
113
+ const t = D.tokens, from = { log: 'read from its log', session: "read from claude's session file", none: 'no log' }[D.source] || '';
114
+ const n = list(D.events).filter(e => e.kind !== 'gap').length;
115
+ const desc = el('span', undefined, 'describer');
116
+ if (t) desc.append(el('span', `${num(t.input)} in`), ...(t.cached ? [el('span', `${num(t.cached)} cached`, 'dim')] : []), el('span', `${num(t.output)} out`));
117
+ if (D.cost != null) desc.append(el('span', '$' + Number(D.cost).toFixed(D.cost < 1 ? 3 : 2)));
118
+ desc.append(el('span', `${n} event${n === 1 ? '' : 's'}`));
119
+ if (D.session) desc.append(el('span', `session ${D.session}`, 'dim'));
120
+ f.meta.replaceChildren(el('span', [D.harness, from].filter(Boolean).join(' · ')), desc);
121
+ }
122
+
123
+ /* ---------- one event ---------- */
124
+ const lines = s => String(s || '').split('\n').length;
125
+ function out(parent, text, key, v, { title = 'Output', open = false, cls = '' } = {}) {
126
+ if (text == null || text === '') return;
127
+ const pre = el('pre', text, 'ev-pre ' + cls);
128
+ if (lines(text) <= FOLD && String(text).length < 1600) { parent.append(pre); return; }
129
+ const d = el('details', undefined, 'ev-fold'); d.dataset.fold = key;
130
+ d.open = open || v.open.has(key);
131
+ d.append(el('summary', `${title} · ${lines(text).toLocaleString()} lines`), pre); parent.append(d);
132
+ }
133
+ function patch(parent, text, key, v) {
134
+ const pre = el('pre', undefined, 'ev-pre ev-patch');
135
+ for (const line of String(text).split('\n')) pre.append(el('span', line, line[0] === '+' ? 'add' : line[0] === '-' ? 'del' : null));
136
+ if (lines(text) <= FOLD) { parent.append(pre); return; }
137
+ const d = el('details', undefined, 'ev-fold'); d.dataset.fold = key; d.open = v.open.has(key);
138
+ d.append(el('summary', `Patch · ${lines(text).toLocaleString()} lines`), pre); parent.append(d);
139
+ }
140
+ // a path in the worktree the agent ran in (or the run's repo) as the diff names it
141
+ function rel(D, path, run) {
142
+ for (const r of [D.repo, run && run.repo]) {
143
+ const root = String(r || '').replace(/\/+$/, '');
144
+ if (root && path.startsWith(root + '/')) return path.slice(root.length + 1);
145
+ }
146
+ return path;
147
+ }
148
+ function eventNode(ctx, D, ev, i, v, t0) {
149
+ const kind = ICON[ev.kind] ? ev.kind : 'text';
150
+ const li = el('li', undefined, `ev ev--${kind}${ev.error ? ' is-error' : ''}${ev.running ? ' is-running' : ''}`);
151
+ li.id = 'ev-' + i; li.tabIndex = -1; li.dataset.kind = kind;
152
+ const gut = el('div', undefined, 'ev-gut'); gut.setAttribute('aria-hidden', 'true'); gut.append(icon(ICON[kind]));
153
+ const body = el('div', undefined, 'ev-body');
154
+ const head = el('div', undefined, 'ev-head');
155
+ const when = Number.isFinite(ev.t) && Number.isFinite(t0) ? el('span', '+' + clock(ev.t - t0), 'ev-time') : null;
156
+ const key = i + ':';
157
+ if (kind === 'gap') { li.append(el('p', `… ${num(ev.count)} events left out: the log is too long to show whole …`, 'muted ev-gap')); return li; }
158
+ if (kind === 'prompt' || kind === 'reasoning') {
159
+ const d = el('details', undefined, 'ev-fold'); d.dataset.fold = key + 'x'; d.open = v.open.has(key + 'x');
160
+ const sum = el('summary'); sum.append(el('span', kind === 'prompt' ? 'The task as it got it' : 'Thinking', 'ev-label'), el('span', String(ev.text || '').split('\n').find(s => s.trim()) || '', 'ev-peek'));
161
+ if (when) sum.append(when);
162
+ d.append(sum, el('div', ev.text, 'ev-text' + (kind === 'reasoning' ? ' ev-thought' : '')));
163
+ body.append(d);
164
+ } else if (kind === 'message') {
165
+ head.append(el('span', D.role === 'qa' ? 'Reviewer' : 'Dwarf', 'ev-label')); if (when) head.append(when);
166
+ body.append(head, el('div', ev.text, 'ev-text'));
167
+ } else if (kind === 'command') {
168
+ const st = ev.running ? 'running' : ev.error ? 'fail' : ev.exit === 0 || ev.error === false ? 'pass' : ev.exit != null ? 'fail' : 'none';
169
+ const line = el('span', undefined, 'ev-cmdline'); line.append(el('i', undefined, 'lamp st-' + st), el('code', ev.command || '(no command)', 'ev-cmd'));
170
+ head.append(line);
171
+ if (ev.exit != null) head.append(el('span', 'exit ' + ev.exit, 'tag' + (ev.exit ? ' tag-bad' : '')));
172
+ else if (ev.running) head.append(el('span', 'running', 'tag'));
173
+ if (when) head.append(when);
174
+ body.append(head); out(body, ev.output, key + 'o', v, { open: !!ev.error });
175
+ } else if (kind === 'edit') {
176
+ head.append(el('span', 'Edited', 'ev-label'));
177
+ if (ev.tool && ev.tool.toLowerCase() !== 'edit') head.append(el('span', ev.tool, 'mono muted ev-toolname'));
178
+ for (const p of list(ev.files)) {
179
+ const r = rel(D, String(p), ctx.run);
180
+ if (ctx.diffHash && ctx.spec.task) { const a = el('a', r, 'mono ev-file'); a.href = ctx.diffHash({ kind: 'task', task: ctx.spec.task, source: ctx.spec.exec, file: r }); a.title = 'This file in the diff its review read'; head.append(a); }
181
+ else head.append(el('span', r, 'mono ev-file'));
182
+ }
183
+ if (list(ev.change).length) head.append(el('span', [...new Set(ev.change)].join(', '), 'tag'));
184
+ if (ev.error) head.append(el('span', 'failed', 'tag tag-bad'));
185
+ if (when) head.append(when);
186
+ body.append(head);
187
+ if (ev.patch) patch(body, ev.patch, key + 'p', v); else out(body, ev.input, key + 'i', v, { title: 'Input' });
188
+ out(body, ev.output, key + 'o', v, { open: !!ev.error });
189
+ } else if (kind === 'tool') {
190
+ head.append(el('span', 'Tool', 'ev-label'), el('span', ev.tool || '?', 'mono ev-toolname'));
191
+ if (ev.target) head.append(el('span', rel(D, String(ev.target), ctx.run), 'mono ev-file'));
192
+ if (ev.error) head.append(el('span', 'failed', 'tag tag-bad'));
193
+ if (when) head.append(when);
194
+ body.append(head); out(body, ev.input, key + 'i', v, { title: 'Input' }); out(body, ev.output, key + 'o', v, { open: !!ev.error });
195
+ } else if (kind === 'result') {
196
+ head.append(el('span', ev.error ? 'Ended with an error' : 'Final answer', 'ev-label'));
197
+ const t = ev.tokens; if (t) head.append(el('span', `${num(t.input)} in · ${num(t.output)} out`, 'muted ev-tokens'));
198
+ if (when) head.append(when);
199
+ body.append(head);
200
+ if (!ev.same) body.append(el('div', ev.text, 'ev-text'));
201
+ } else if (kind === 'error') {
202
+ head.append(el('span', 'Error', 'ev-label')); if (when) head.append(when);
203
+ body.append(head, el('div', ev.text, 'ev-text warn'));
204
+ } else {
205
+ head.append(el('span', ev.stream === 'stderr' ? 'stderr' : 'Log', 'ev-label')); if (when) head.append(when);
206
+ body.append(head); out(body, ev.text, key + 't', v, { title: 'Lines' });
207
+ }
208
+ li.append(gut, body);
209
+ li.dataset.find = [ev.text, ev.command, ev.output, ev.input, ev.patch, ev.tool, ev.target, list(ev.files).join(' ')].filter(Boolean).join('\n').toLowerCase();
210
+ return li;
211
+ }
212
+ function clock(s) { s = Math.max(0, Math.round(s)); const h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, x = s % 60; return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); }
213
+
214
+ /* ---------- the list: rebuilt from the first event that changed, so a live log only appends ---------- */
215
+ function events(f, ctx, D, v) {
216
+ const evs = list(D.events), sigs = evs.map(e => JSON.stringify(e));
217
+ let i = 0;
218
+ while (i < f.drawn.length && i < sigs.length && f.drawn[i] === sigs[i]) i++;
219
+ const grew = sigs.length > f.drawn.length || i < f.drawn.length;
220
+ for (const n of f.nodes.splice(i)) n.remove();
221
+ const t0 = evs.map(e => e.t).find(Number.isFinite);
222
+ for (let k = i; k < evs.length; k++) { const n = eventNode(ctx, D, evs[k], k, v, t0); f.nodes.push(n); f.list.append(n); }
223
+ // a final answer that repeats the agent's last message marks that message instead
224
+ const r = evs.findIndex(e => e.kind === 'result' && e.same);
225
+ let m = r - 1; while (m >= 0 && evs[m].kind !== 'message') m--;
226
+ const said = m >= 0 && f.nodes[m];
227
+ if (said && !said.querySelector('.ev-final')) said.querySelector('.ev-head').append(el('span', 'final answer', 'tag ev-final'));
228
+ f.drawn = sigs;
229
+ return grew && i < sigs.length;
230
+ }
231
+ function filter(f, v) {
232
+ const q = v.query.trim().toLowerCase(), kinds = (SHOW.find(s => s[0] === v.show) || SHOW[0])[2];
233
+ let shown = 0;
234
+ for (const n of f.nodes) {
235
+ const kind = n.dataset.kind;
236
+ const on = (!kinds || kinds.includes(kind)) && (!q || (n.dataset.find || '').includes(q));
237
+ n.hidden = !on; if (on && kind !== 'gap') shown++;
238
+ }
239
+ for (const b of f.show.children) b.setAttribute('aria-pressed', String(b.dataset.show === v.show));
240
+ const total = f.nodes.filter(n => n.dataset.kind !== 'gap').length;
241
+ f.count.textContent = q || kinds ? `${shown} of ${total}` : '';
242
+ const errs = f.nodes.filter(n => n.classList.contains('is-error')).length;
243
+ f.errs.replaceChildren(errs ? chip(`${errs} error${errs === 1 ? '' : 's'}`, 'fail') : el('span', 'no errors', 'muted'));
244
+ f.err.hidden = !errs;
245
+ }
246
+ function nextError(f, v) {
247
+ const errs = f.nodes.filter(n => n.classList.contains('is-error'));
248
+ if (!errs.length) return;
249
+ v.err = (v.err == null ? -1 : v.err) + 1;
250
+ if (v.err >= errs.length) v.err = 0;
251
+ const n = errs[v.err];
252
+ if (n.hidden) { v.query = ''; v.show = 'all'; f.search.value = ''; filter(f, v); }
253
+ for (const d of n.querySelectorAll('details')) d.open = true;
254
+ n.scrollIntoView({ block: 'center' }); n.focus({ preventScroll: true });
255
+ n.classList.remove('flash'); void n.offsetWidth; n.classList.add('flash');
256
+ }
257
+ function end(f) { const last = f.nodes[f.nodes.length - 1]; if (last) last.scrollIntoView({ block: 'end' }); }
258
+
259
+ /* ---------- the view ---------- */
260
+ function render(host, ctx) {
261
+ const url = urlFor(ctx.run, ctx.spec), entry = load(ctx, url);
262
+ let v = views.get(url);
263
+ if (!v) { v = { query: '', show: 'all', follow: true, open: new Set(), err: null }; views.set(url, v); }
264
+ let f = host.convoFrame;
265
+ const fresh = !f || f.url !== url;
266
+ if (fresh) { f = frame(host, ctx, url, v); host.convoFrame = f; }
267
+ const D = entry.data;
268
+ header(f, ctx, D);
269
+ if (!D) {
270
+ f.status.replaceChildren(el('p', entry.error ? (entry.error.snapshot ? 'This conversation is not in this export: it was made without prompts and texts.' :
271
+ `Could not load this conversation (${entry.error.status === 404 ? 'not found' : entry.error.message}).`) : 'Reading the log…', entry.error ? 'warn' : 'muted'));
272
+ return;
273
+ }
274
+ const running = D.state === 'running' && ctx.live !== false;
275
+ f.follow.hidden = !running; f.follow.setAttribute('aria-pressed', String(v.follow));
276
+ const status = [];
277
+ const ws = list(D.warnings);
278
+ if (ws.length) status.push(el('p', ws[0], 'warn'));
279
+ if (ws.length > 1) { const d = el('details', undefined, 'diff-warnings'); d.append(el('summary', `${ws.length - 1} more warning${ws.length === 2 ? '' : 's'}`), el('pre', ws.slice(1).join('\n'))); status.push(d); }
280
+ if (D.format === 'text' && list(D.events).length) status.push(el('p', `${D.harness || 'This harness'} left plain text, not a stream of events: its log is shown as it is.`, 'muted'));
281
+ if (!list(D.events).length) status.push(el('p', running ? 'Nothing logged yet: the agent has just started.' : D.source === 'none' ? 'No log was kept for this execution.' : 'Its log holds no conversation.', 'muted convo-empty'));
282
+ f.status.replaceChildren(...status);
283
+ const grew = events(f, ctx, D, v);
284
+ filter(f, v);
285
+ if (running && v.follow && (grew || fresh)) requestAnimationFrame(() => end(f));
286
+ // a log still being written: read it again shortly, while this view shows it
287
+ if (running && !entry.timer) {
288
+ entry.timer = setTimeout(() => {
289
+ entry.timer = null;
290
+ if (host.convoFrame === f && host.isConnected && !host.hidden && !entry.pending) read(ctx, url, entry);
291
+ }, POLL);
292
+ }
293
+ }
294
+ return { render, urlFor, cache };
295
+ })();
@@ -0,0 +1,232 @@
1
+ /* forge-ui data tables: sortable, reorderable columns that can be hidden, remembered per table.
2
+ A column is { id, label, title?, cls?, mono?, value(row) → number|string|null (sort key), cell(row) → Node|<td>|text,
3
+ sortable? (default true), hideable? (default true; the first column never hides), hidden? (starts hidden until shown) }.
4
+ Click a header to sort (ascending, descending, off); drag a header, or Alt+←/→ on it, to move it; the Columns menu shows
5
+ or hides columns and resets the table. Empty values always sort last.
6
+ window.ForgeTable = { render(parent, opts) → root, normPrefs, sortRows, compare, move } and module.exports for node tests.
7
+ opts = { id, label, columns, rows, prefs, savePrefs(prefs), rowClass(row)?, onRow(row, tr)?, scrollKey?, tools? (an element
8
+ that receives the Columns button instead of a toolbar row) }. */
9
+ (function (root, factory) {
10
+ const api = factory();
11
+ if (typeof module === 'object' && module.exports) module.exports = api;
12
+ else root.ForgeTable = api;
13
+ })(typeof self !== 'undefined' ? self : this, () => {
14
+ 'use strict';
15
+ const list = v => Array.isArray(v) ? v : [];
16
+ const empty = v => v === null || v === undefined || v === '' || (typeof v === 'number' && !Number.isFinite(v));
17
+
18
+ /* ---------- pure: preferences, ordering, sorting ---------- */
19
+ // Saved preferences against today's columns: unknown ids dropped, new columns slotted in at their default place
20
+ // (and hidden when they start hidden).
21
+ function normPrefs(prefs, columns) {
22
+ prefs = prefs && typeof prefs === 'object' ? prefs : {};
23
+ const ids = list(columns).map(c => c.id), known = new Set(ids);
24
+ const saved = list(prefs.order).filter((id, i, a) => known.has(id) && a.indexOf(id) === i);
25
+ const order = saved.slice();
26
+ ids.forEach((id, i) => {
27
+ if (order.includes(id)) return;
28
+ // after the nearest column that precedes it by default and is already placed
29
+ let at = 0;
30
+ for (let j = i - 1; j >= 0; j--) { const k = order.indexOf(ids[j]); if (k >= 0) { at = k + 1; break; } }
31
+ order.splice(at, 0, id);
32
+ });
33
+ const hideable = new Set(list(columns).filter((c, i) => i > 0 && c.hideable !== false).map(c => c.id));
34
+ const fresh = list(columns).filter(c => c.hidden && !saved.includes(c.id)).map(c => c.id);
35
+ const hidden = list(prefs.hidden).concat(fresh).filter((id, i, a) => hideable.has(id) && a.indexOf(id) === i);
36
+ const col = list(columns).find(c => prefs.sort && c.id === prefs.sort.id && c.sortable !== false);
37
+ const sort = col && (prefs.sort.dir === 'asc' || prefs.sort.dir === 'desc') ? { id: col.id, dir: prefs.sort.dir } : null;
38
+ return { order, hidden, sort };
39
+ }
40
+ // Numbers by value, text in natural order ("task-2" before "task-10"), numbers before text, empty last.
41
+ function compare(a, b) {
42
+ const ea = empty(a), eb = empty(b);
43
+ if (ea || eb) return ea === eb ? 0 : ea ? 1 : -1;
44
+ const na = typeof a === 'number', nb = typeof b === 'number';
45
+ if (na && nb) return a - b;
46
+ if (na !== nb) return na ? -1 : 1;
47
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
48
+ }
49
+ // A stable sort by one column; empty values stay at the bottom whichever way it runs.
50
+ function sortRows(rows, column, dir) {
51
+ const items = list(rows).map((row, i) => ({ row, i, v: column && column.value ? column.value(row) : null }));
52
+ if (!column || (dir !== 'asc' && dir !== 'desc')) return items.map(x => x.row);
53
+ const sign = dir === 'desc' ? -1 : 1;
54
+ items.sort((x, y) => {
55
+ const ex = empty(x.v), ey = empty(y.v);
56
+ if (ex || ey) return ex === ey ? x.i - y.i : ex ? 1 : -1;
57
+ return sign * compare(x.v, y.v) || x.i - y.i;
58
+ });
59
+ return items.map(x => x.row);
60
+ }
61
+ // The order with `id` moved to index `to` (clamped).
62
+ function move(order, id, to) {
63
+ const out = list(order).filter(x => x !== id);
64
+ if (out.length === list(order).length) return list(order).slice();
65
+ out.splice(Math.max(0, Math.min(out.length, to)), 0, id);
66
+ return out;
67
+ }
68
+ const nextSort = (sort, id) => !sort || sort.id !== id ? { id, dir: 'asc' } : sort.dir === 'asc' ? { id, dir: 'desc' } : null;
69
+
70
+ /* ---------- DOM ---------- */
71
+ const menusOpen = new Set(); // tables whose Columns menu was open, kept across re-renders
72
+ function el(tag, text, cls) {
73
+ const n = document.createElement(tag);
74
+ if (text !== undefined && text !== null) n.textContent = String(text);
75
+ if (cls) n.className = cls;
76
+ return n;
77
+ }
78
+ const icon = (name) => window.ForgeIcons ? window.ForgeIcons.icon(name) : document.createTextNode('');
79
+
80
+ function render(parent, opts) {
81
+ const columns = list(opts.columns), byId = new Map(columns.map(c => [c.id, c]));
82
+ let prefs = normPrefs(opts.prefs, columns);
83
+ const save = next => { prefs = next; if (opts.savePrefs) opts.savePrefs({ order: next.order, hidden: next.hidden, sort: next.sort }); redraw(); };
84
+ let current = null;
85
+
86
+ function menu() {
87
+ const btn = el('button', undefined, 'dt-columns-btn'); btn.type = 'button';
88
+ btn.append(icon('columns'), el('span', 'Columns'));
89
+ btn.title = 'Show, hide and reset this table\'s columns';
90
+ const pop = el('div', undefined, 'dt-menu popover'); pop.popover = 'auto'; pop.id = 'dt-menu-' + opts.id;
91
+ pop.setAttribute('aria-label', `Columns of ${opts.label || opts.id}`);
92
+ btn.popoverTargetElement = pop; btn.setAttribute('aria-haspopup', 'true');
93
+ const head = el('div', undefined, 'dt-menu-head'); head.append(el('span', 'Columns', 'plate'));
94
+ const reset = el('button', 'Reset', 'dt-reset'); reset.type = 'button';
95
+ reset.title = 'Default order, every column shown, no sort';
96
+ reset.addEventListener('click', () => save(normPrefs({}, columns)));
97
+ head.append(reset); pop.append(head);
98
+ const ul = el('ul', undefined, 'dt-menu-list');
99
+ prefs.order.forEach((id, i) => {
100
+ const c = byId.get(id), li = el('li'), lab = el('label'), box = el('input');
101
+ box.type = 'checkbox'; box.checked = !prefs.hidden.includes(id);
102
+ box.disabled = columns[0].id === id || c.hideable === false;
103
+ box.addEventListener('change', () => save({ ...prefs, hidden: box.checked ? prefs.hidden.filter(x => x !== id) : prefs.hidden.concat(id) }));
104
+ lab.append(box, el('span', c.label));
105
+ const up = el('button', undefined, 'dt-step'); up.type = 'button'; up.append(icon('chevron-up')); up.disabled = i === 0;
106
+ up.setAttribute('aria-label', `Move ${c.label} left`); up.addEventListener('click', () => save({ ...prefs, order: move(prefs.order, id, i - 1) }));
107
+ const down = el('button', undefined, 'dt-step'); down.type = 'button'; down.append(icon('chevron-down')); down.disabled = i === prefs.order.length - 1;
108
+ down.setAttribute('aria-label', `Move ${c.label} right`); down.addEventListener('click', () => save({ ...prefs, order: move(prefs.order, id, i + 1) }));
109
+ li.append(lab, up, down); ul.append(li);
110
+ });
111
+ pop.append(ul);
112
+ pop.addEventListener('toggle', e => {
113
+ if (e.newState === 'open') {
114
+ menusOpen.add(opts.id);
115
+ const r = btn.getBoundingClientRect(), w = pop.offsetWidth || 260;
116
+ pop.style.top = Math.round(r.bottom + 6) + 'px';
117
+ pop.style.left = Math.round(Math.max(8, Math.min(window.innerWidth - w - 8, r.right - w))) + 'px';
118
+ } else menusOpen.delete(opts.id);
119
+ });
120
+ return [btn, pop];
121
+ }
122
+
123
+ function header(shown) {
124
+ const thead = el('thead'), tr = el('tr');
125
+ shown.forEach((c, i) => {
126
+ const th = el('th', undefined, c.cls || ''); th.scope = 'col'; th.dataset.col = c.id;
127
+ if (c.title) th.title = c.title;
128
+ const sorted = prefs.sort && prefs.sort.id === c.id;
129
+ if (sorted) th.setAttribute('aria-sort', prefs.sort.dir === 'asc' ? 'ascending' : 'descending');
130
+ th.draggable = true;
131
+ const lead = c.sortable === false ? el('span', undefined, 'th-label') : el('button', undefined, 'th-btn');
132
+ if (c.sortable !== false) {
133
+ lead.type = 'button';
134
+ lead.addEventListener('click', () => save({ ...prefs, sort: nextSort(prefs.sort, c.id) }));
135
+ }
136
+ lead.append(el('span', c.label, 'th-text'));
137
+ if (c.sortable !== false) {
138
+ const mark = el('span', undefined, 'th-sort' + (sorted ? ' on' : ''));
139
+ mark.append(icon(sorted ? (prefs.sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : 'sort'));
140
+ lead.append(mark);
141
+ }
142
+ lead.addEventListener('keydown', e => {
143
+ if (!e.altKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
144
+ e.preventDefault();
145
+ const at = prefs.order.indexOf(c.id), to = e.key === 'ArrowLeft' ? at - 1 : at + 1;
146
+ if (to < 0 || to >= prefs.order.length) return;
147
+ save({ ...prefs, order: move(prefs.order, c.id, to) });
148
+ current.querySelector(`th[data-col="${CSS.escape(c.id)}"] .th-btn, th[data-col="${CSS.escape(c.id)}"] .th-label`)?.focus();
149
+ });
150
+ th.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', c.id); e.dataTransfer.effectAllowed = 'move'; th.classList.add('dragging'); });
151
+ th.addEventListener('dragend', () => { th.classList.remove('dragging'); for (const n of thead.querySelectorAll('.drop-before, .drop-after')) n.classList.remove('drop-before', 'drop-after'); });
152
+ th.addEventListener('dragover', e => {
153
+ e.preventDefault(); e.dataTransfer.dropEffect = 'move';
154
+ const r = th.getBoundingClientRect(), after = e.clientX > r.left + r.width / 2;
155
+ th.classList.toggle('drop-after', after); th.classList.toggle('drop-before', !after);
156
+ });
157
+ th.addEventListener('dragleave', () => th.classList.remove('drop-before', 'drop-after'));
158
+ th.addEventListener('drop', e => {
159
+ e.preventDefault();
160
+ const id = e.dataTransfer.getData('text/plain'); if (!byId.has(id) || id === c.id) return;
161
+ const r = th.getBoundingClientRect(), after = e.clientX > r.left + r.width / 2;
162
+ const rest = prefs.order.filter(x => x !== id), at = rest.indexOf(c.id) + (after ? 1 : 0);
163
+ save({ ...prefs, order: move(prefs.order, id, at) });
164
+ });
165
+ th.append(lead);
166
+ if (i === 0) th.classList.add('dt-first');
167
+ tr.append(th);
168
+ });
169
+ thead.append(tr);
170
+ return thead;
171
+ }
172
+
173
+ function build() {
174
+ const node = el('div', undefined, 'dt');
175
+ node.dataset.table = opts.id;
176
+ const shown = prefs.order.filter(id => !prefs.hidden.includes(id)).map(id => byId.get(id));
177
+ const [btn, pop] = menu();
178
+ if (opts.tools) opts.tools.replaceChildren(btn);
179
+ else { const bar = el('div', undefined, 'dt-bar'); if (prefs.sort || prefs.hidden.length) bar.append(status()); bar.append(btn); node.append(bar); }
180
+ node.append(pop);
181
+ const wrap = el('div', undefined, 'table-scroll');
182
+ if (opts.scrollKey) wrap.dataset.scroll = opts.scrollKey;
183
+ wrap.tabIndex = 0; wrap.setAttribute('role', 'region'); wrap.setAttribute('aria-label', opts.label || opts.id);
184
+ const table = el('table', undefined, opts.className || '');
185
+ if (opts.label) table.append(el('caption', opts.label, 'visually-hidden'));
186
+ table.append(header(shown));
187
+ const body = el('tbody');
188
+ const col = prefs.sort && byId.get(prefs.sort.id);
189
+ for (const row of sortRows(opts.rows, col, prefs.sort && prefs.sort.dir)) {
190
+ const tr = el('tr', undefined, opts.rowClass ? opts.rowClass(row) || '' : '');
191
+ for (const c of shown) {
192
+ let v = c.cell ? c.cell(row) : c.value ? c.value(row) : null;
193
+ let td;
194
+ if (v instanceof Node && v.nodeName === 'TD') td = v;
195
+ else {
196
+ td = el('td', undefined, [c.cls, c.mono ? 'mono' : ''].filter(Boolean).join(' '));
197
+ td.append(v instanceof Node ? v : document.createTextNode(empty(v) ? '—' : String(v)));
198
+ }
199
+ td.dataset.col = c.id;
200
+ tr.append(td);
201
+ }
202
+ if (opts.onRow) opts.onRow(row, tr);
203
+ body.append(tr);
204
+ }
205
+ table.append(body); wrap.append(table);
206
+ // a fade on each edge that has more table past it
207
+ const frame = el('div', undefined, 'dt-frame'); frame.append(wrap); node.append(frame);
208
+ const edges = () => { const end = wrap.scrollWidth - wrap.clientWidth; frame.classList.toggle('more-left', wrap.scrollLeft > 1); frame.classList.toggle('more-right', wrap.scrollLeft < end - 1); };
209
+ wrap.addEventListener('scroll', edges, { passive: true });
210
+ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(edges).observe(wrap);
211
+ if (menusOpen.has(opts.id)) queueMicrotask(() => { if (pop.isConnected && pop.showPopover) try { pop.showPopover(); } catch (_) { /* already open */ } });
212
+ return node;
213
+ }
214
+ function status() {
215
+ const s = el('span', undefined, 'dt-status muted');
216
+ const bits = [];
217
+ if (prefs.sort) bits.push(`sorted by ${byId.get(prefs.sort.id).label}, ${prefs.sort.dir === 'asc' ? 'ascending' : 'descending'}`);
218
+ if (prefs.hidden.length) bits.push(`${prefs.hidden.length} hidden`);
219
+ s.textContent = bits.join(' · ');
220
+ return s;
221
+ }
222
+ function redraw() {
223
+ const next = build();
224
+ current.replaceWith(next); current = next;
225
+ }
226
+ current = build();
227
+ parent.append(current);
228
+ return current;
229
+ }
230
+
231
+ return { render, normPrefs, sortRows, compare, move, nextSort };
232
+ });
@@ -1,12 +1,11 @@
1
1
  /* forge-ui diff viewer: a task's diff (current, previous, committed, or exactly what one reviewer read), a final
2
2
  review's combined diff or a Fractal node's candidate, with each review finding shown under the line it points
3
3
  at. Defines window.ForgeDiff.render(host, ctx). All API values enter the DOM as text.
4
- ctx = { run, spec: {kind: 'task'|'final'|'fractal', task, source, file, name, node}, fetchJSON(path),
4
+ ctx = { run, spec: {kind: 'task'|'final'|'fractal', task, source, file, name, node, finding}, fetchJSON(path),
5
5
  rerender(), href(spec) → hash, closeHref }. */
6
6
  window.ForgeDiff = (() => {
7
7
  'use strict';
8
8
  const list = v => Array.isArray(v) ? v : [];
9
- const SEV = { P0: 'fail', P1: 'conflict', P2: 'timeout', P3: 'unknown' };
10
9
  const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
11
10
  const BUDGET = 4000; // lines drawn before the rest of the files wait for a click
12
11
  const cache = new Map(); // url → {data, error, pending, fresh}
@@ -18,6 +17,7 @@ window.ForgeDiff = (() => {
18
17
  return n;
19
18
  }
20
19
  const chip = (text, st) => el('span', text, 'chip st-' + st);
20
+ const sevChip = (text, sev) => el('span', text, 'chip sev-chip sev-' + (/^P[0-3]$/.test(sev) ? sev : 'none'));
21
21
  const enc = encodeURIComponent;
22
22
  const TASK_FILES = new Set(['current', 'prev', 'committed', 'existing']);
23
23
  function urlFor(run, spec) {
@@ -47,8 +47,9 @@ window.ForgeDiff = (() => {
47
47
  /* ---------- one finding, as a row under its line or in a list ---------- */
48
48
  function findingBox(f, where) {
49
49
  const box = el('div', undefined, 'diff-finding sev-' + (f.severity || 'P3'));
50
+ if (f.id) box.dataset.finding = f.id; // a mark on the stage opens the diff at this finding
50
51
  const head = el('div', undefined, 'diff-finding-head');
51
- head.append(chip(f.severity || '?', SEV[f.severity] || 'unknown'));
52
+ head.append(sevChip(f.severity || '?', f.severity));
52
53
  if (f.status) head.append(el('span', f.status.toLowerCase(), 'tag'));
53
54
  const r = f.review || {};
54
55
  head.append(el('span', `${r.kind || 'review'}${r.attempt != null ? ' · attempt ' + r.attempt : ''}${r.spec ? ' · ' + r.spec : ''}`, 'muted'));
@@ -66,7 +67,7 @@ window.ForgeDiff = (() => {
66
67
  const st = { modified: 'running', added: 'merged', deleted: 'fail', renamed: 'nochanges', copied: 'nochanges' }[f.status] || 'unknown';
67
68
  head.append(chip(f.status, st));
68
69
  const name = el('a', f.status === 'renamed' || f.status === 'copied' ? `${f.old} → ${f.new}` : pathOf(f), 'mono diff-path');
69
- name.href = ctx.href({ ...ctx.spec, file: pathOf(f) });
70
+ name.href = ctx.href({ ...ctx.spec, finding: null, file: pathOf(f) });
70
71
  head.append(name, el('span', `+${f.additions} −${f.deletions}`, 'diff-stat'));
71
72
  if (f.binary) head.append(el('span', 'binary', 'tag'));
72
73
  if (f.truncated) head.append(el('span', 'cut short', 'tag'));
@@ -118,15 +119,20 @@ window.ForgeDiff = (() => {
118
119
  function render(host, ctx) {
119
120
  const run = ctx.run, spec = ctx.spec, url = urlFor(run, spec), entry = load(ctx, url);
120
121
  // a poll that brought nothing new leaves thousands of rows alone
121
- const sig = [url, entry.at || 0, !!entry.error, spec.file, opened.size].join('|');
122
+ const sig = [url, entry.at || 0, !!entry.error, spec.file, spec.finding, opened.size].join('|');
122
123
  if (host.diffSig === sig) return;
123
124
  host.diffSig = sig;
125
+ // the finding the keyboard is on keeps it through the rebuild (a poll, a fold)
126
+ const had = document.activeElement && host.contains(document.activeElement) && document.activeElement.closest('[data-finding]');
127
+ const keep = had ? had.dataset.finding : null;
124
128
  host.replaceChildren();
125
129
  const D = entry.data;
126
130
  const top = el('div', undefined, 'diff-top');
127
131
  const what = spec.kind === 'final' ? 'Final review' : spec.kind === 'fractal' ? `${spec.task} · Fractal node ${spec.node}` : spec.task === 'solo' && run.kind === 'solo' ? 'Solo run' : `Task ${spec.task}`;
128
132
  const title = el('h2'); title.append(el('span', 'Diff · ', 'muted'), document.createTextNode(what)); top.append(title);
129
- const close = el('a', 'Close ✕', 'diff-close'); close.href = ctx.closeHref; top.append(close);
133
+ const close = el('a', undefined, 'diff-close button'); close.href = ctx.closeHref;
134
+ if (window.ForgeIcons) close.append(window.ForgeIcons.icon('close'));
135
+ close.append(document.createTextNode('Close')); top.append(close);
130
136
  host.append(top);
131
137
  if (!D) {
132
138
  host.append(el('p', entry.error ? (entry.error.snapshot ? 'This diff is not in this export.' : `Could not load this diff (${entry.error.status === 404 ? 'not found' : entry.error.message}).`) : 'Loading the diff…', entry.error ? 'warn' : 'muted'));
@@ -140,7 +146,7 @@ window.ForgeDiff = (() => {
140
146
  const cur = spec.kind === 'final' ? spec.name : spec.kind === 'fractal' ? 'candidate' : spec.source || 'current';
141
147
  for (const s of sources) {
142
148
  const a = el('a', s.label || s.id, 'diff-source'); a.setAttribute('aria-current', String(s.id === cur));
143
- a.href = spec.kind === 'final' ? ctx.href({ kind: 'final', name: s.id }) : ctx.href({ ...spec, source: s.id, file: null });
149
+ a.href = spec.kind === 'final' ? ctx.href({ kind: 'final', name: s.id }) : ctx.href({ ...spec, source: s.id, file: null, finding: null });
144
150
  if (s.verdict) a.append(document.createTextNode(' '), chip(s.verdict, String(s.verdict).toLowerCase()));
145
151
  a.title = `${s.bytes != null ? s.bytes + ' bytes' : ''}${s.kind ? ' · ' + s.kind : ''}`;
146
152
  bar.append(a);
@@ -166,13 +172,13 @@ window.ForgeDiff = (() => {
166
172
  const nav = el('nav', undefined, 'diff-files'); nav.setAttribute('aria-label', 'Files in this diff');
167
173
  const ul = el('ul');
168
174
  files.forEach((f, fi) => {
169
- const li = el('li'), a = el('a', undefined, 'diff-file-link'); a.href = ctx.href({ ...spec, file: pathOf(f) }); a.dataset.fi = String(fi);
175
+ const li = el('li'), a = el('a', undefined, 'diff-file-link'); a.href = ctx.href({ ...spec, finding: null, file: pathOf(f) }); a.dataset.fi = String(fi);
170
176
  const p = pathOf(f), cut = p.lastIndexOf('/');
171
177
  a.append(el('span', cut >= 0 ? p.slice(0, cut + 1) : '', 'diff-dir'), el('span', cut >= 0 ? p.slice(cut + 1) : p, 'diff-base'));
172
178
  const stat = el('span', undefined, 'diff-stat'); stat.append(el('span', `+${f.additions}`, 'diff-add'), document.createTextNode(' '), el('span', `−${f.deletions}`, 'diff-del'));
173
179
  a.append(stat);
174
180
  const ps = byFile.get(fi) || [];
175
- if (ps.length) { const w = worst(ps.map(x => x.f)); a.append(chip(`${ps.length}`, SEV[w] || 'unknown')); a.title = `${ps.length} finding${ps.length === 1 ? '' : 's'}, worst ${w}`; }
181
+ if (ps.length) { const w = worst(ps.map(x => x.f)); a.append(sevChip(`${ps.length}`, w)); a.title = `${ps.length} finding${ps.length === 1 ? '' : 's'}, worst ${w}`; }
176
182
  if (spec.file && spec.file === p) a.setAttribute('aria-current', 'true');
177
183
  li.append(a); ul.append(li);
178
184
  });
@@ -207,7 +213,20 @@ window.ForgeDiff = (() => {
207
213
  fileEls[i].scrollIntoView({ block: 'start' }); e.preventDefault();
208
214
  }
209
215
  };
210
- if (spec.file) {
216
+ const flash = n => { n.classList.add('flash'); setTimeout(() => n.classList.remove('flash'), 1600); };
217
+ const back = keep && order.find(n => n.dataset.finding === keep);
218
+ if (back) back.focus({ preventScroll: true });
219
+ if (spec.finding) {
220
+ const target = order.find(n => n.dataset.finding === spec.finding);
221
+ if (target && host.dataset.scrolledTo !== url + '\n#' + spec.finding) {
222
+ host.dataset.scrolledTo = url + '\n#' + spec.finding; fIdx = order.indexOf(target);
223
+ requestAnimationFrame(() => {
224
+ const live = target.isConnected ? target : [...host.querySelectorAll('.diff-finding[id]')].find(n => n.dataset.finding === spec.finding);
225
+ if (!live) return;
226
+ live.scrollIntoView({ block: 'center' }); flash(live); live.focus({ preventScroll: true });
227
+ });
228
+ }
229
+ } else if (spec.file) {
211
230
  const target = fileEls.find(s => s.dataset.path === spec.file);
212
231
  if (target && host.dataset.scrolledTo !== url + '\n' + spec.file) { host.dataset.scrolledTo = url + '\n' + spec.file; requestAnimationFrame(() => target.scrollIntoView({ block: 'start' })); }
213
232
  }