@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.
- package/CHANGELOG.md +50 -0
- package/README.md +106 -1766
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +84 -0
- package/docs/commands.md +365 -0
- package/docs/dataset.md +29 -0
- package/docs/decomposed-runs.md +310 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +128 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +245 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +20 -0
- package/package.json +3 -2
- package/references/dataset.md +18 -1
- package/references/decompose.md +20 -2
- package/references/fractal.md +1 -1
- package/references/harnesses.md +18 -6
- package/references/solo.md +1 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- package/scripts/forge-dispatch.sh +7 -1
- package/scripts/forge-issues.py +806 -0
- package/scripts/forge-lib-task.sh +234 -35
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +3 -2
- package/scripts/forge-route.py +184 -27
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +139 -0
- package/scripts/forge-runtime.py +59 -6
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge-version.py +54 -12
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +23 -1
- package/scripts/forge_dataset/importer.py +420 -18
- package/scripts/forge_fractal/__init__.py +2 -1
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +116 -23
- package/ui/THIRD_PARTY_NOTICES.md +108 -0
- package/ui/docs/api.md +283 -23
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +33 -9
- package/ui/forge_ui/fractal.py +1 -2
- package/ui/forge_ui/reader.py +121 -8
- package/ui/forge_ui/server.py +135 -5
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +358 -131
- package/ui/forge_ui/static/cloud3d.js +3 -2
- package/ui/forge_ui/static/cloudui.js +8 -5
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +29 -10
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/findingsview.js +412 -0
- package/ui/forge_ui/static/graph.js +3 -3
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +31 -13
- package/ui/forge_ui/static/iso.js +70 -19
- package/ui/forge_ui/static/leaderboard.js +44 -48
- package/ui/forge_ui/static/panel.js +92 -36
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +17 -1
- package/ui/forge_ui/static/scene3d.js +518 -43
- package/ui/forge_ui/static/search.js +2 -1
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1099 -469
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/static/uikit.js +161 -0
- 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(
|
|
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',
|
|
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(
|
|
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
|
-
|
|
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
|
}
|