@giamme/forge 0.8.0 → 0.9.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 (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +3 -2
  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 +69 -0
  8. package/docs/commands.md +315 -0
  9. package/docs/dataset.md +20 -0
  10. package/docs/decomposed-runs.md +268 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +92 -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 +231 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +5 -0
  21. package/package.json +3 -2
  22. package/references/decompose.md +1 -1
  23. package/references/fractal.md +1 -1
  24. package/references/harnesses.md +18 -6
  25. package/references/solo.md +1 -1
  26. package/scripts/forge-dispatch.sh +7 -1
  27. package/scripts/forge-runs.py +121 -0
  28. package/scripts/forge-runtime.py +59 -6
  29. package/scripts/forge-version.py +54 -12
  30. package/scripts/forge_dataset/harvest.py +1 -1
  31. package/scripts/forge_dataset/importer.py +1 -1
  32. package/scripts/forge_fractal/__init__.py +2 -1
  33. package/ui/README.md +80 -20
  34. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  35. package/ui/docs/api.md +99 -17
  36. package/ui/forge_ui/export.py +16 -6
  37. package/ui/forge_ui/fractal.py +1 -2
  38. package/ui/forge_ui/reader.py +95 -7
  39. package/ui/forge_ui/server.py +10 -1
  40. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  41. package/ui/forge_ui/static/app.js +279 -124
  42. package/ui/forge_ui/static/cloud3d.js +3 -2
  43. package/ui/forge_ui/static/cloudui.js +8 -5
  44. package/ui/forge_ui/static/convo.js +295 -0
  45. package/ui/forge_ui/static/datatable.js +232 -0
  46. package/ui/forge_ui/static/diffview.js +29 -10
  47. package/ui/forge_ui/static/findings3d.js +134 -0
  48. package/ui/forge_ui/static/graph.js +3 -3
  49. package/ui/forge_ui/static/icons.js +94 -0
  50. package/ui/forge_ui/static/index.html +25 -13
  51. package/ui/forge_ui/static/iso.js +70 -19
  52. package/ui/forge_ui/static/leaderboard.js +44 -48
  53. package/ui/forge_ui/static/panel.js +68 -32
  54. package/ui/forge_ui/static/runmodel.js +17 -1
  55. package/ui/forge_ui/static/scene3d.js +518 -43
  56. package/ui/forge_ui/static/search.js +2 -1
  57. package/ui/forge_ui/static/shell.js +137 -0
  58. package/ui/forge_ui/static/stagectl.js +150 -0
  59. package/ui/forge_ui/static/style.css +924 -469
  60. package/ui/forge_ui/static/theme.js +25 -0
  61. package/ui/forge_ui/transcripts.py +608 -0
@@ -15,13 +15,21 @@ window.ForgeUI = (() => {
15
15
  mapFilter: Object.assign({ text: '', state: '', repo: '', kind: '', model: '', ageDays: 0, verdict: '' }, store.get('map-filter', {})),
16
16
  showAll: false, mode: store.get('mode', 'plan'), dim: store.get('view', '3d'),
17
17
  overlay: Object.assign({ height: 'none', color: 'status', critical: false, arcs: true, ghosts: true }, store.get('overlay', {})),
18
+ layers: Object.assign({ cables: true, beads: true, lamps: true, roof: true, labels: true, beacons: true, finish: true, districts: true, halos: true, route: true }, store.get('layers', {})),
18
19
  replay: null, playing: null, speed: store.get('speed', 60), stage: null, stageFailed: null, stageLoading: false, timer: null,
19
20
  lastStatuses: new Map(), announced: 0, rendered: null, cloud: null, cloudUI: null, cloudPrefs: store.get('cloud', {}),
20
21
  models: false, modelsData: null, modelsText: null, modelsError: null, modelsPrefs: store.get('models', {}),
21
- files: new Map(), filesPrefs: store.get('files', {}) };
22
+ files: new Map(), filesPrefs: store.get('files', {}), runSort: store.get('run-sort', 'updated'), tablePrefs: store.get('tables', {}),
23
+ findings: new Map() };
22
24
  state.stageMode = state.mode === 'exec' ? 'exec' : 'plan'; // the stage's own mode, kept while Files is shown
23
25
  if (state.mode === 'files' && !window.ForgeFiles) state.mode = state.stageMode;
24
26
  const $ = id => document.getElementById(id);
27
+ const Shell = window.ForgeShell, Table = window.ForgeTable;
28
+ const icon = name => window.ForgeIcons ? window.ForgeIcons.icon(name) : document.createTextNode('');
29
+ const LAMP = { live: 'running', failed: 'fail', done: 'merged', pending: 'pending' };
30
+ const STATE_ORDER = { live: 0, failed: 1, pending: 2, done: 3 };
31
+ // the view on <main> and on <body>: the shell's CSS lays out each view (stage views fill the window)
32
+ function setView(main, view) { main.dataset.view = view; document.body.dataset.view = view; }
25
33
  const display = value => value === null || value === undefined || value === '' ? '—' :
26
34
  typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
27
35
  const list = value => Array.isArray(value) ? value : [];
@@ -47,13 +55,16 @@ window.ForgeUI = (() => {
47
55
  if (sel.kind === 'fractal') return `${base}/tasks/${e(sel.task)}/fractal/${e(sel.node)}`;
48
56
  return base;
49
57
  }
50
- // a diff: {kind: 'task', task, source, file} | {kind: 'final', name, file} | {kind: 'fractal', task, node, file}
58
+ // a diff: {kind: 'task', task, source, file} | {kind: 'final', name, file} | {kind: 'fractal', task, node, file},
59
+ // opened at a file, or at one finding (its id from /findings)
51
60
  function diffHash(key, spec) {
52
- const e = encodeURIComponent, base = '#/runs/' + e(key), file = spec.file ? '/file/' + e(spec.file) : '';
61
+ const e = encodeURIComponent, base = '#/runs/' + e(key), file = spec.finding ? '/finding/' + e(spec.finding) : spec.file ? '/file/' + e(spec.file) : '';
53
62
  if (spec.kind === 'final') return `${base}/final-review/${e(spec.name)}/diff${file}`;
54
63
  if (spec.kind === 'fractal') return `${base}/tasks/${e(spec.task)}/fractal/${e(spec.node)}/diff${file}`;
55
64
  return `${base}/tasks/${e(spec.task)}/diff${spec.source && spec.source !== 'current' ? '/' + e(spec.source) : ''}${file}`;
56
65
  }
66
+ // an execution's conversation, in the stage's place like a diff
67
+ const convoHash = (key, sel) => hashFor(key, sel) + '/conversation';
57
68
  function cloudHash(sel, focus) {
58
69
  if (sel) return '#/cloud/runs/' + encodeURIComponent(sel);
59
70
  return focus ? '#/cloud/projects/' + encodeURIComponent(focus) : '#/cloud';
@@ -66,9 +77,15 @@ window.ForgeUI = (() => {
66
77
  if (!m) return { key: null, sel: null };
67
78
  const d = decodeURIComponent, rest = m[2] || '';
68
79
  let sel = null, r;
69
- if ((r = rest.match(/^\/tasks\/([^/]+)\/diff(?:\/([^/]+))?(?:\/file\/(.+?))?\/?$/))) return { key: d(m[1]), sel: { kind: 'task', id: d(r[1]) }, diff: { kind: 'task', task: d(r[1]), source: r[2] ? d(r[2]) : 'current', file: r[3] ? d(r[3]) : null } };
80
+ if ((r = rest.match(/^\/tasks\/([^/]+)\/diff(?:\/(?!file\/|finding\/)([^/]+))?(?:\/finding\/([^/]+))?(?:\/file\/(.+?))?\/?$/))) return { key: d(m[1]), sel: { kind: 'task', id: d(r[1]) }, diff: { kind: 'task', task: d(r[1]), source: r[2] ? d(r[2]) : 'current', finding: r[3] ? d(r[3]) : null, file: r[4] ? d(r[4]) : null } };
70
81
  if ((r = rest.match(/^\/tasks\/([^/]+)\/fractal\/([^/]+)\/diff(?:\/file\/(.+?))?\/?$/))) return { key: d(m[1]), sel: { kind: 'task', id: d(r[1]) }, diff: { kind: 'fractal', task: d(r[1]), node: d(r[2]), file: r[3] ? d(r[3]) : null } };
71
- if ((r = rest.match(/^\/final-review\/([^/]+)\/diff(?:\/file\/(.+?))?\/?$/))) return { key: d(m[1]), sel: { kind: 'final', name: d(r[1]) }, diff: { kind: 'final', name: d(r[1]), file: r[2] ? d(r[2]) : null } };
82
+ if ((r = rest.match(/^\/final-review\/([^/]+)\/diff(?:\/finding\/([^/]+))?(?:\/file\/(.+?))?\/?$/))) return { key: d(m[1]), sel: { kind: 'final', name: d(r[1]) }, diff: { kind: 'final', name: d(r[1]), finding: r[2] ? d(r[2]) : null, file: r[3] ? d(r[3]) : null } };
83
+ if ((r = rest.match(/^\/(?:tasks\/([^/]+)\/)?executions\/([^/]+)\/conversation\/?$/))) {
84
+ const s = r[1] ? { kind: 'execution', task: d(r[1]), id: d(r[1]), exec: d(r[2]) } : { kind: 'execution', task: null, exec: d(r[2]) };
85
+ const run = s.task && state.cache.get(d(m[1]))?.data, t = run && list(run.tasks).find(x => x.id === s.task);
86
+ if (t && list(t.shadow).some(x => x.execution === s.exec)) s.shadow = true;
87
+ return { key: d(m[1]), sel: s, convo: { task: s.task, exec: s.exec } };
88
+ }
72
89
  if ((r = rest.match(/^\/tasks\/([^/]+)\/executions\/([^/]+)\/?$/))) sel = { kind: 'execution', task: d(r[1]), id: d(r[1]), exec: d(r[2]) };
73
90
  if (sel && sel.kind === 'execution' && sel.task) {
74
91
  const run = state.cache.get(d(m[1]))?.data, t = run && list(run.tasks).find(x => x.id === sel.task);
@@ -111,16 +128,6 @@ window.ForgeUI = (() => {
111
128
  parent.append(node);
112
129
  return node;
113
130
  }
114
- function fields(parent, entries) {
115
- const dl = el('dl', undefined, 'fields');
116
- for (const [label, value, mono] of entries) {
117
- dl.append(el('dt', label));
118
- const dd = el('dd', undefined, mono ? 'mono' : '');
119
- dd.append(value instanceof Node ? value : document.createTextNode(display(value)));
120
- dl.append(dd);
121
- }
122
- parent.append(dl);
123
- }
124
131
  function details(parent, title, value, key) {
125
132
  const node = el('details');
126
133
  node.dataset.keep = key;
@@ -193,11 +200,15 @@ window.ForgeUI = (() => {
193
200
  banner.hidden = !state.errors.size;
194
201
  }
195
202
  function filteredRuns() {
196
- return state.runs.filter(run => RM.matchRun(run, state.mapFilter, wall())).sort((a, b) => (b.updated || 0) - (a.updated || 0));
203
+ const runs = state.runs.filter(run => RM.matchRun(run, state.mapFilter, wall())), byTime = (a, b) => (b.updated || 0) - (a.updated || 0);
204
+ if (state.runSort === 'state') return runs.sort((a, b) => STATE_ORDER[RM.runState(a)] - STATE_ORDER[RM.runState(b)] || byTime(a, b));
205
+ if (state.runSort === 'repo') return runs.sort((a, b) => RM.basename(a.repo).localeCompare(RM.basename(b.repo)) || byTime(a, b));
206
+ return runs.sort(byTime);
197
207
  }
198
208
  function renderList() {
199
209
  const runs = filteredRuns();
200
- $('run-count').textContent = `${runs.length} / ${state.runs.length}`;
210
+ $('run-count').textContent = `${runs.length}/${state.runs.length}`;
211
+ if ($('run-sort').value !== state.runSort) $('run-sort').value = state.runSort;
201
212
  if ($('filter').value !== state.mapFilter.text) $('filter').value = state.mapFilter.text;
202
213
  preserve($('run-list'), () => {
203
214
  if (!runs.length) {
@@ -205,7 +216,7 @@ window.ForgeUI = (() => {
205
216
  empty.append(el('p', state.runs.length ? 'No runs match this filter.' : 'No runs found.'));
206
217
  if (!state.runs.length) {
207
218
  empty.append(el('p', 'Sources: explicit --plan directories, --root directories (up to two levels deep), and the forge run registry at $XDG_CONFIG_HOME/forge/runs/ (default ~/.config/forge/runs/). Every forge run registers itself there.'));
208
- empty.append(el('p', 'Point the server at your runs:'), el('pre', 'python3 -m forge_ui --root <dir>'));
219
+ empty.append(el('p', 'Open runs kept somewhere else:'), el('pre', 'forge ui open <run-dir>', 'cmd'));
209
220
  }
210
221
  $('run-list').append(empty);
211
222
  }
@@ -213,15 +224,18 @@ window.ForgeUI = (() => {
213
224
  const item = link(undefined, run.key);
214
225
  item.className = 'run-item';
215
226
  item.dataset.run = run.key;
227
+ const st = RM.runState(run), lamp = el('i', undefined, 'lamp run-lamp st-' + LAMP[st]);
228
+ lamp.title = { live: 'running', failed: 'has failures', done: 'all done', pending: 'not started' }[st];
216
229
  const goal = el('strong', String(run.goal || 'Untitled run').split('\n')[0], 'run-goal');
217
230
  goal.title = run.goal || 'Untitled run';
218
231
  const solo = run.kind === 'solo';
219
- item.append(goal, el('span', RM.basename(run.repo), 'repo'), el('span', run.run_id, 'mono'));
220
- if (solo) item.append(el('span', 'solo', 'tag'));
221
- const updated = el('span', relative(run.updated), 'muted updated');
222
- if (run.updated != null) updated.dataset.epoch = run.updated;
232
+ const sub = el('span', undefined, 'run-sub');
233
+ const updated = el('span', relative(run.updated).replace(/^updated /, ''), 'updated');
234
+ if (run.updated != null) { updated.dataset.epoch = run.updated; updated.dataset.short = ''; }
223
235
  updated.title = time(run.updated);
224
- item.append(updated);
236
+ sub.append(el('span', RM.basename(run.repo), 'repo'), el('span', run.run_id, 'mono'), updated);
237
+ item.append(lamp, goal, sub);
238
+ if (solo) item.append(el('span', 'solo', 'tag'));
225
239
  const counts = el('div', undefined, 'chips');
226
240
  for (const [status, count] of Object.entries(run.counts || {})) {
227
241
  const badge = chip(status);
@@ -261,6 +275,12 @@ window.ForgeUI = (() => {
261
275
  onOpenRun: key => { location.hash = hashFor(key, null); },
262
276
  onBack: () => { location.hash = '#/'; },
263
277
  onMode: mode => setMode(mode),
278
+ onLayers: L => { state.layers = L; store.set('layers', L); syncLayerKeys(); }, // the stage's I key
279
+ // a finding's mark: the diff that review read, opened at that finding
280
+ onFinding: d => {
281
+ location.hash = d.final ? diffHash(state.key, { kind: 'final', name: d.final, finding: d.finding.id })
282
+ : diffHash(state.key, { kind: 'task', task: d.task, source: d.review && d.review.execution || 'current', finding: d.finding.id });
283
+ },
264
284
  cloud: {
265
285
  onSelect: key => { location.hash = cloudHash(key, state.cloud && state.cloud.focus); },
266
286
  onFocus: pid => { location.hash = cloudHash(null, pid); },
@@ -272,6 +292,7 @@ window.ForgeUI = (() => {
272
292
  })).then(stage => {
273
293
  state.stage = stage; state.stageLoading = false;
274
294
  stage.setOverlay(state.overlay);
295
+ stage.setLayers(state.layers);
275
296
  stage.setReplay(state.replay);
276
297
  renderMain();
277
298
  }, err => {
@@ -351,20 +372,43 @@ window.ForgeUI = (() => {
351
372
  parent.append(wrap);
352
373
  return A;
353
374
  }
375
+ // No run at all yet (a fresh install, or nothing registered): say how to get one, instead of an empty
376
+ // plate under a filter bar with nothing to filter. The first run that registers replaces it with the map.
377
+ function renderFirstRun() {
378
+ const main = $('main');
379
+ if (main.dataset.view === 'first-run') return;
380
+ setView(main, 'first-run'); delete main.dataset.run; // the shell lays out each view by <body data-view>
381
+ main.replaceChildren();
382
+ const v = section(main, undefined, 'first-run'); v.className = 'first-run';
383
+ v.append(el('h1', 'No forge runs yet'),
384
+ el('p', 'forge-ui shows what forge records while it works: every solo run, plan and run registers itself here when it starts, with its tasks, diffs, reviews and verdicts.'),
385
+ el('h2', 'Start a run from your agent harness'),
386
+ el('pre', '/forge "<goal>" --dwarf <model>', 'cmd'),
387
+ el('p', 'Inside any agent CLI where forge is installed (forge install links it into each one it finds). The run shows up here as soon as it starts.', 'muted'),
388
+ el('h2', 'Open runs kept somewhere else'),
389
+ el('pre', 'forge ui open <run-dir>', 'cmd'),
390
+ el('p', 'A run dir is a plan or solo directory forge wrote. Runs register under $XDG_CONFIG_HOME/forge/runs/ (default ~/.config/forge/runs/); FORGE_RUN_REGISTRY=off turns that off.', 'muted'));
391
+ }
354
392
  function renderMap({ rebuildBar = true } = {}) {
355
393
  const main = $('main');
394
+ if (state.listLoaded && !state.runs.length && !SNAP) { renderFirstRun(); return; }
356
395
  if (main.dataset.view !== 'map') {
357
- main.dataset.view = 'map'; delete main.dataset.run;
396
+ setView(main, 'map'); delete main.dataset.run;
358
397
  main.replaceChildren();
359
398
  const v = section(main, undefined, 'map-view'); v.className = 'map-view';
399
+ const box = el('div', undefined, 'module map-head-module'); box.dataset.overlay = '';
360
400
  const head = el('div', undefined, 'map-head');
361
- const toCloud = el('a', 'Open the run cloud', 'top-link'); toCloud.href = '#/cloud';
362
- head.append(el('h1', 'All runs'), el('span', '', 'muted'), toCloud, viewToggle());
363
- toCloud.classList.add('push');
364
- v.append(head);
365
- const bar = el('div'); bar.id = 'map-filters'; v.append(bar);
401
+ const toCloud = el('a', undefined, 'button push'); toCloud.href = '#/cloud'; toCloud.append(icon('cloud'), el('span', 'Run cloud'));
402
+ head.append(el('h1', 'All runs', 'module-title'), el('span', '', 'map-count'), viewToggle(), toCloud);
403
+ const bar = el('div'); bar.id = 'map-filters';
404
+ // on a phone the filters fold behind a key, so the map is the first thing on the screen
405
+ const fk = el('button', undefined, 'filters-key'); fk.type = 'button'; fk.setAttribute('aria-controls', 'map-filters'); fk.setAttribute('aria-expanded', 'false');
406
+ fk.append(icon('filter'), el('span', 'Filters'));
407
+ fk.addEventListener('click', () => { const open = box.dataset.filters !== 'open'; box.dataset.filters = open ? 'open' : 'closed'; fk.setAttribute('aria-expanded', String(open)); });
408
+ head.append(fk);
409
+ box.append(head, bar); v.append(box);
366
410
  const slot = el('div', undefined, 'stage-slot map'); slot.id = 'stage-slot'; v.append(slot);
367
- const cards = el('div'); cards.id = 'map-cards'; v.append(cards);
411
+ const cards = el('div', undefined, 'map-cards-host'); cards.id = 'map-cards'; v.append(cards);
368
412
  rebuildBar = true;
369
413
  }
370
414
  if (rebuildBar) preserve($('map-filters'), () => filterBar($('map-filters')));
@@ -378,7 +422,7 @@ window.ForgeUI = (() => {
378
422
  } else {
379
423
  preserve($('map-cards'), () => { const A = mapCards($('map-cards')); info = { hidden: A.hidden, matches: A.matches, shown: A.modules.length, total: A.total }; });
380
424
  }
381
- const counter = $('map-view').querySelector('.map-head .muted');
425
+ const counter = $('map-view').querySelector('.map-head .map-count');
382
426
  if (info) counter.textContent = `${info.matches} of ${info.shown} shown runs match${info.hidden ? ` · ${info.hidden} older hidden` : ''}`;
383
427
  }
384
428
 
@@ -386,7 +430,7 @@ window.ForgeUI = (() => {
386
430
  function renderCloud() {
387
431
  const main = $('main');
388
432
  if (main.dataset.view !== 'cloud' || !state.cloudUI) {
389
- main.dataset.view = 'cloud'; delete main.dataset.run;
433
+ setView(main, 'cloud'); delete main.dataset.run;
390
434
  main.replaceChildren();
391
435
  const v = el('section', undefined, 'cloud-view'); v.id = 'cloud-view'; v.setAttribute('aria-label', 'Run cloud');
392
436
  const slot = el('div', undefined, 'stage-slot cloud'); slot.id = 'stage-slot'; v.append(slot);
@@ -412,18 +456,19 @@ window.ForgeUI = (() => {
412
456
  function renderModels() {
413
457
  const main = $('main');
414
458
  if (main.dataset.view !== 'models') {
415
- main.dataset.view = 'models'; delete main.dataset.run;
459
+ setView(main, 'models'); delete main.dataset.run;
416
460
  main.replaceChildren();
417
461
  const v = el('section', undefined, 'models-view'); v.id = 'models-view'; main.append(v);
418
462
  }
419
463
  const host = $('models-view');
420
464
  if (!state.modelsData) {
421
- host.replaceChildren(el('h1', 'Models'), el('p', state.modelsError ? `Could not load the model results (${state.modelsError}).` : 'Reading every run\'s attempts and reviews…', state.modelsError ? 'warn' : 'muted'));
465
+ host.replaceChildren(el('h1', 'Models', 'module-title'), el('p', state.modelsError ? `Could not load the model results (${state.modelsError}).` : 'Reading every run\'s attempts and reviews…', state.modelsError ? 'warn' : 'muted'));
422
466
  return;
423
467
  }
424
468
  window.ForgeLeaderboard.render(host, state.modelsData, {
425
469
  runs: state.runs, prefs: state.modelsPrefs, now: () => Date.now() / 1000,
426
470
  savePrefs: p => { state.modelsPrefs = p; store.set('models', p); },
471
+ tablePrefs: id => state.tablePrefs[id], saveTablePrefs: (id, p) => { state.tablePrefs = { ...state.tablePrefs, [id]: p }; store.set('tables', state.tablePrefs); },
427
472
  modelHref: alias => '#/cloud/models/' + encodeURIComponent(alias),
428
473
  });
429
474
  }
@@ -449,6 +494,7 @@ window.ForgeUI = (() => {
449
494
  function syncToggles() {
450
495
  for (const b of document.querySelectorAll('[data-dim]')) b.setAttribute('aria-pressed', String(b.dataset.dim === state.dim));
451
496
  for (const b of document.querySelectorAll('[data-mode]')) b.setAttribute('aria-pressed', String(b.dataset.mode === state.mode));
497
+ syncLayerKeys();
452
498
  }
453
499
  function setMode(mode) {
454
500
  if (mode === state.mode) return;
@@ -457,15 +503,20 @@ window.ForgeUI = (() => {
457
503
  syncToggles();
458
504
  const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
459
505
  }
460
- function toolbar(parent) {
506
+ // the header's last row: the push-button bank that sets what the stage shows, then the replay's time base
507
+ function toolbar(header) {
461
508
  const bar = el('div', undefined, 'run-toolbar'); bar.setAttribute('role', 'toolbar'); bar.setAttribute('aria-label', 'Run view');
462
509
  const modes = el('div', undefined, 'seg'); modes.setAttribute('role', 'group'); modes.setAttribute('aria-label', 'Plan or execution');
463
- for (const [m, label, title] of [['plan', 'Planned waves', 'Tasks in the waves the plan put them in'], ['exec', 'Execution', 'Attempts on wall-clock time, in the lanes they really ran in (e)'],
510
+ for (const [m, label, title] of [['plan', 'Plan', 'Planned waves: the tasks in the waves the plan put them in'], ['exec', 'Execution', 'Attempts on wall-clock time, in the lanes they really ran in (e)'],
464
511
  ['files', 'Files', 'Which tasks planned, changed, drifted into or conflicted on which files']].filter(([m]) => m !== 'files' || window.ForgeFiles)) {
465
512
  const b = el('button', label); b.type = 'button'; b.dataset.mode = m; b.title = title; b.addEventListener('click', () => setMode(m)); modes.append(b);
466
513
  }
467
514
  bar.append(modes, viewToggle());
468
- const ov = el('div', undefined, 'overlays');
515
+ const ov = el('div', undefined, 'overlays popover'); ov.popover = 'auto'; ov.id = 'view-pop'; ov.setAttribute('aria-label', 'What the stage shows');
516
+ ov.append(el('span', 'View', 'plate'));
517
+ const viewBtn = el('button', undefined, 'view-btn'); viewBtn.type = 'button'; viewBtn.popoverTargetElement = ov; viewBtn.setAttribute('aria-haspopup', 'true');
518
+ viewBtn.append(icon('layers'), el('span', 'View'), icon('chevron-up')); viewBtn.title = 'Height, colour, layers and marks';
519
+ ov.addEventListener('toggle', e => { if (e.newState === 'open') { syncLayerKeys(); placePopover(viewBtn, ov); } });
469
520
  const sel = (label, key, entries) => {
470
521
  const l = el('label', undefined, 'filter'); l.append(el('span', label));
471
522
  const s = el('select'); s.dataset.ov = key;
@@ -474,35 +525,83 @@ window.ForgeUI = (() => {
474
525
  };
475
526
  sel('Height', 'height', [['none', 'difficulty'], ['duration', 'duration'], ['tokens', 'tokens'], ['attempts', 'attempts']]);
476
527
  sel('Colour', 'color', [['status', 'status'], ['model', 'model'], ['duration', 'duration'], ['tokens', 'tokens']]);
477
- for (const [key, label, title] of [['critical', 'Critical path', 'The chain of dependencies that bounded the wall time'], ['arcs', 'Review marks', 'Shadow disagreements and Clef rings'], ['ghosts', 'Projected', 'Where the tasks that have not started would run, by forge-schedule\'s rules']]) {
478
- const l = el('label', undefined, 'filter check'); l.title = title;
479
- const c = el('input'); c.type = 'checkbox'; c.checked = !!state.overlay[key]; c.dataset.ov = key;
480
- l.append(c, document.createTextNode(' ' + label)); ov.append(l);
481
- }
528
+ // push buttons, one per part of the drawing: its lamp lights while that part is drawn
529
+ const bankOf = (title, rows, attr) => {
530
+ const g = el('div', undefined, 'layer-bank'); g.setAttribute('role', 'group'); g.setAttribute('aria-label', title);
531
+ for (const [key, label, tip] of rows) {
532
+ const b = el('button', label, 'layer-key'); b.type = 'button'; b.dataset[attr] = key; b.title = tip; g.append(b);
533
+ }
534
+ ov.append(el('span', title, 'plate'), g);
535
+ return g;
536
+ };
537
+ bankOf('Layers', [['cables', 'Cables', 'The cables from each task to the tasks that wait on it'], ['beads', 'Flow', 'Beads flowing down the cables into running tasks'],
538
+ ['lamps', 'Lamps', 'The lamps on each block: one per attempt'], ['roof', 'Roof text', 'Task id, title, models and finding counts on each roof'],
539
+ ['labels', 'Readouts', 'The status line on the front of each block'], ['beacons', 'Findings', 'A mark over the block per review finding, shaped by its severity'],
540
+ ['finish', 'Finish line', 'Integration, verification and the final review'], ['districts', 'Districts', 'The wave and repository names on the plate'],
541
+ ['halos', 'Halos', 'The glow under running and chosen blocks'], ['route', 'Route', 'A chosen task keeps its route lit: what it waits on and what waits on it (I)']], 'layer');
542
+ bankOf('Marks', [['critical', 'Critical path', 'The chain of dependencies that bounded the wall time'], ['arcs', 'Review marks', 'Shadow disagreements and Clef rings'],
543
+ ['ghosts', 'Projected', 'Where the tasks that have not started would run, by forge-schedule\'s rules']], 'ov');
544
+ ov.addEventListener('click', e => {
545
+ const b = e.target.closest('.layer-key'); if (!b) return;
546
+ if (b.dataset.layer) {
547
+ const k = b.dataset.layer; state.layers[k] = !state.layers[k]; store.set('layers', state.layers);
548
+ if (state.stage) state.stage.setLayers({ [k]: state.layers[k] });
549
+ } else {
550
+ const k = b.dataset.ov; state.overlay[k] = !state.overlay[k]; store.set('overlay', state.overlay);
551
+ if (state.stage) state.stage.setOverlay({ [k]: state.overlay[k] });
552
+ const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
553
+ }
554
+ syncLayerKeys();
555
+ });
482
556
  ov.addEventListener('input', e => {
483
557
  const k = e.target.dataset.ov; if (!k) return;
484
- state.overlay[k] = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
558
+ state.overlay[k] = e.target.value;
485
559
  store.set('overlay', state.overlay);
486
560
  if (state.stage) state.stage.setOverlay({ [k]: state.overlay[k] });
487
561
  const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
488
562
  });
489
- bar.append(ov);
563
+ bar.append(viewBtn, ov);
490
564
  const rp = el('div', undefined, 'replay'); rp.id = 'replay';
491
- const play = el('button', '▶ Replay'); play.type = 'button'; play.dataset.act = 'play';
565
+ const play = el('button', undefined, 'icon-btn'); play.type = 'button'; play.dataset.act = 'play'; play.append(icon('play')); play.setAttribute('aria-label', 'Replay'); play.title = 'Replay the run';
492
566
  const range = el('input'); range.type = 'range'; range.min = '0'; range.max = '1000'; range.value = '1000'; range.dataset.act = 'scrub'; range.setAttribute('aria-label', 'Replay time');
493
567
  const speed = el('select'); speed.dataset.act = 'speed'; speed.setAttribute('aria-label', 'Replay speed');
494
568
  for (const v of [10, 60, 600]) { const o = el('option', `×${v}`); o.value = String(v); if (state.speed === v) o.selected = true; speed.append(o); }
495
- const live = el('button', 'Live'); live.type = 'button'; live.dataset.act = 'live';
569
+ const live = el('button'); live.type = 'button'; live.dataset.act = 'live'; live.append(icon('live'), el('span', 'Live'));
496
570
  const at = el('span', '', 'muted mono'); at.dataset.act = 'at';
497
571
  rp.append(play, range, speed, live, at);
498
- rp.addEventListener('click', e => { const a = e.target.dataset.act; if (a === 'play') togglePlay(); if (a === 'live') setReplay(null); });
572
+ rp.addEventListener('click', e => { const a = e.target.closest('[data-act]')?.dataset.act; if (a === 'play') togglePlay(); if (a === 'live') setReplay(null); });
499
573
  rp.addEventListener('input', e => {
500
574
  const a = e.target.dataset.act, run = state.cache.get(state.key)?.data; if (!run) return;
501
575
  if (a === 'scrub') { stopPlay(); const r = RM.timeRange(run, now()); if (r) setReplay(r.start + (r.end - r.start) * Number(e.target.value) / 1000); }
502
576
  if (a === 'speed') { state.speed = Number(e.target.value); store.set('speed', state.speed); }
503
577
  });
504
- bar.append(rp);
505
- parent.append(bar);
578
+ // the header's action slot: the run's record opens in the inspector, the export opens its dialog
579
+ const acts = el('div', undefined, 'run-actions');
580
+ const rec = el('button', undefined, 'details-btn'); rec.type = 'button'; rec.title = 'Settings, request, waves and verification output, in the inspector (2)';
581
+ rec.append(icon('panel-right'), el('span', 'Run details'));
582
+ rec.addEventListener('click', () => { Shell.toggle('insp', true); if (state.sel) select(null); });
583
+ acts.append(rec);
584
+ if (!SNAP) {
585
+ const ex = el('button', undefined, 'export-btn'); ex.type = 'button'; ex.title = 'Download this run as one HTML file to share';
586
+ ex.append(icon('download'), el('span', 'Export…'));
587
+ ex.addEventListener('click', () => { const run = state.cache.get(state.key)?.data; if (run) exportDialog(run); }); acts.append(ex);
588
+ }
589
+ bar.append(rp, acts);
590
+ header.append(bar);
591
+ }
592
+ // the View popover's push buttons: pressed in while their part is drawn; the layers are the 3D stage's own
593
+ function syncLayerKeys() {
594
+ for (const b of document.querySelectorAll('.layer-key')) {
595
+ const on = b.dataset.layer ? !!state.layers[b.dataset.layer] : !!state.overlay[b.dataset.ov];
596
+ b.setAttribute('aria-pressed', String(on));
597
+ if (b.dataset.layer) b.disabled = !use3d();
598
+ }
599
+ }
600
+ // a popover beside the button that opened it: above when the button sits low on the screen
601
+ function placePopover(btn, pop) {
602
+ const r = btn.getBoundingClientRect(), w = pop.offsetWidth, h = pop.offsetHeight, below = window.innerHeight - r.bottom > h + 16;
603
+ pop.style.left = Math.round(Math.max(8, Math.min(window.innerWidth - w - 8, r.left))) + 'px';
604
+ pop.style.top = Math.round(below ? r.bottom + 6 : Math.max(8, r.top - h - 6)) + 'px';
506
605
  }
507
606
  function syncReplay(run) {
508
607
  const rp = $('replay'); if (!rp) return;
@@ -511,8 +610,9 @@ window.ForgeUI = (() => {
511
610
  if (!r) return;
512
611
  const range = rp.querySelector('[data-act=scrub]'), at = rp.querySelector('[data-act=at]');
513
612
  range.value = String(state.replay === null ? 1000 : Math.round((state.replay - r.start) / Math.max(1, r.end - r.start) * 1000));
514
- at.textContent = state.replay === null ? 'live' : `+${RM.fmtDur(state.replay - r.start)} · ${new Date(state.replay * 1000).toLocaleTimeString()}`;
515
- rp.querySelector('[data-act=play]').textContent = state.playing ? '❚❚ Pause' : '▶ Replay';
613
+ at.textContent = state.replay === null ? '' : `+${RM.fmtDur(state.replay - r.start)} · ${new Date(state.replay * 1000).toLocaleTimeString()}`; // live: the Live key's lamp says it
614
+ const playBtn = rp.querySelector('[data-act=play]');
615
+ if (playBtn.dataset.on !== String(!!state.playing)) { playBtn.dataset.on = String(!!state.playing); playBtn.replaceChildren(icon(state.playing ? 'pause' : 'play')); playBtn.setAttribute('aria-label', state.playing ? 'Pause' : 'Replay'); playBtn.title = state.playing ? 'Pause the replay' : 'Replay the run'; }
516
616
  rp.querySelector('[data-act=live]').disabled = state.replay === null;
517
617
  }
518
618
  function setReplay(t) {
@@ -521,19 +621,25 @@ window.ForgeUI = (() => {
521
621
  if (state.stage) state.stage.setReplay(state.replay);
522
622
  const run = state.cache.get(state.key)?.data; if (run) { renderRunBody(run); }
523
623
  }
524
- function stopPlay() { if (state.playing) { clearInterval(state.playing); state.playing = null; } }
624
+ function stopPlay() { if (state.playing) { cancelAnimationFrame(state.playing); state.playing = null; } }
625
+ // the replay runs on the display's frames: the stage moves every frame, the panels around it four times a second
525
626
  function togglePlay() {
526
627
  const run = state.cache.get(state.key)?.data; if (!run) return;
527
628
  if (state.playing) { stopPlay(); syncReplay(run); return; }
528
629
  const r = RM.timeRange(run, now()); if (!r) return;
529
630
  if (state.replay === null || state.replay >= r.end) state.replay = r.start;
530
- state.playing = setInterval(() => {
631
+ let last = performance.now(), painted = 0;
632
+ const step = t0 => {
531
633
  const run2 = state.cache.get(state.key)?.data, r2 = run2 && RM.timeRange(run2, now());
532
634
  if (!r2) { stopPlay(); return; }
533
- const t = Math.min(r2.end, state.replay + state.speed * .2);
534
- setReplay(t);
535
- if (t >= r2.end) { stopPlay(); syncReplay(run2); }
536
- }, 200);
635
+ const t = Math.min(r2.end, state.replay + state.speed * Math.min(.25, (t0 - last) / 1000)); last = t0;
636
+ state.replay = t;
637
+ if (state.stage && use3d()) state.stage.setReplay(t);
638
+ if (t0 - painted > 250 || t >= r2.end || !use3d()) { painted = t0; renderRunBody(run2); } else syncReplay(run2);
639
+ if (t >= r2.end) { state.playing = null; syncReplay(run2); return; }
640
+ state.playing = requestAnimationFrame(step);
641
+ };
642
+ state.playing = requestAnimationFrame(step);
537
643
  syncReplay(run);
538
644
  }
539
645
  // A copy of the run whose statuses are what they were at time t (for the 2D views during a replay).
@@ -543,60 +649,74 @@ window.ForgeUI = (() => {
543
649
  return { ...run, tasks: list(run.tasks).map(t => ({ ...t, status: s.tasks[t.id] ? s.tasks[t.id].status : t.status })) };
544
650
  }
545
651
  function renderHeader(run) {
546
- const head = $('run-head');
652
+ const head = $('run-head').querySelector('.run-head-body');
547
653
  preserve(head, () => {
548
654
  const solo = run.kind === 'solo';
549
- head.append(el('h2', String(run.goal || 'Untitled run').split('\n')[0]));
550
- const meta = el('p', undefined, 'run-meta');
551
- meta.append(el('span', RM.basename(run.repo), 'repo'), el('span', run.run_id, 'mono'), el('span', solo ? 'solo run' : `${run.task_count} tasks · ${list(run.waves).length} waves`),
552
- el('span', relative(run.updated), 'muted'));
553
- const counts = el('span', undefined, 'chips');
554
- for (const [s, n] of Object.entries(run.counts || {})) { const c = chip(s); c.append(document.createTextNode(' ' + n)); counts.append(c); }
555
- meta.append(counts);
556
- if (!SNAP) {
557
- const ex = el('button', 'Export…', 'export-btn'); ex.type = 'button'; ex.title = 'Download this run as one HTML file to share';
558
- ex.addEventListener('click', () => exportDialog(run)); meta.append(ex);
559
- }
560
- head.append(meta);
561
- const more = el('details', undefined, 'run-more'); more.dataset.keep = 'run-more';
562
- more.append(el('summary', 'Run details'));
563
- fields(more, [['Repo', run.repo, true], ['Run ID', run.run_id, true], ['Path', run.path, true], ['Source', run.source],
564
- ['Decompose', run.decompose_level], ['Updated', time(run.updated)], ['Explore', run.settings?.explore], ['Shadow rate', run.settings?.shadow_rate],
565
- ['Shadow reviewers', list(run.settings?.shadow_qa).join(', '), true], ['QA threshold', run.settings?.qa_threshold], ['Max parallel', run.settings?.max_parallel],
566
- ['Final reviewer', run.settings?.final_qa, true], ['Timeout (seconds)', run.settings?.timeout], ['Verification', chip(run.verification?.status || run.verification_status)],
567
- ['Command', run.verification?.command, true]]);
568
- details(more, 'Request', run.request, 'run-request');
569
- if (!solo) details(more, 'Waves', list(run.waves).map((ids, i) => `${i + 1}: ${list(ids).join(', ')}`).join('\n') || '—', 'waves');
570
- if (run.verification?.log_tail != null) details(more, 'Verification output', run.verification.log_tail, 'verification-output');
571
- head.append(more);
655
+ // one line: the run's lamp, its goal, where it ran, and the describer
656
+ const row = el('div', undefined, 'run-title-row');
657
+ const lamp = el('i', undefined, 'lamp st-' + LAMP[RM.runState(run)]);
658
+ const title = el('h2', String(run.goal || 'Untitled run').split('\n')[0]); title.title = run.goal || '';
659
+ const where = el('span', undefined, 'run-where'); where.title = `${run.repo || ''}\n${run.run_id || ''}`;
660
+ where.append(el('span', RM.basename(run.repo), 'repo'), document.createTextNode(' '), el('span', run.run_id, 'mono'));
661
+ if (solo) where.append(document.createTextNode(' · solo run'));
662
+ // the describer: each state's count, the wall time so far, and how long since anything moved
663
+ const desc = el('span', undefined, 'describer');
664
+ for (const [s, n] of Object.entries(run.counts || {})) { const d = el('span', undefined, 'desc-count'); d.append(el('i', undefined, 'lamp st-' + String(s).toLowerCase()), document.createTextNode(` ${s} ${n}`)); desc.append(d); }
665
+ const r = RM.timeRange(run, now());
666
+ if (r) desc.append(el('span', `WALL ${RM.fmtDur(r.end - r.start)}`, 'dim'));
667
+ const upd = el('span', relative(run.updated).replace(/^updated /, ''), 'dim updated'); upd.title = 'Last activity ' + time(run.updated);
668
+ if (run.updated != null) { upd.dataset.epoch = run.updated; upd.dataset.short = ''; }
669
+ desc.append(upd);
670
+ row.append(lamp, title, where, desc); head.append(row);
572
671
  if (list(run.warnings).length) details(head, `Reader warnings (${run.warnings.length})`, run.warnings.join('\n'), 'warnings');
573
672
  if (list(run.truncated).length) details(head, 'Truncated fields', run.truncated.join('\n'), 'truncated');
574
673
  scoped(head, run.key + ':');
575
674
  });
576
675
  }
676
+ const SEV_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
677
+ const DIFF_RANK = { low: 0, medium: 1, high: 2 };
678
+ function taskColumns(run) {
679
+ const sev = f => f && f.worst ? `${f.worst} ×${f[f.worst]}` : f && f.plausible ? `~${f.plausible}` : '—';
680
+ const busy = task => RM.taskTimes(task, null, now()).busy || null;
681
+ return [
682
+ { id: 'id', label: 'ID', mono: true, value: t => t.id, cell: t => link(t.id, run.key, { kind: 'task', id: t.id }) },
683
+ { id: 'title', label: 'Title', value: t => t.title },
684
+ { id: 'difficulty', label: 'Difficulty', value: t => DIFF_RANK[t.difficulty] ?? null, cell: t => display(t.difficulty) },
685
+ { id: 'wave', label: 'Wave', value: t => Number.isFinite(Number(t.wave)) && t.wave !== null && t.wave !== '' ? Number(t.wave) : null, cell: t => display(t.wave) },
686
+ { id: 'status', label: 'Status', value: t => t.status, cell: t => chip(t.status) },
687
+ { id: 'dwarf', label: 'Dwarf', value: t => t.dwarf, cell: t => window.ForgeIcons.modelPlate(t.dwarf) },
688
+ { id: 'qa', label: 'QA', value: t => t.qa, cell: t => window.ForgeIcons.modelPlate(t.qa) },
689
+ { id: 'attempt', label: 'Attempt', value: t => Number(t.attempt) || null, cell: t => display(t.attempt) },
690
+ { id: 'verdict', label: 'Verdict', value: t => t.verdict, cell: t => chip(t.verdict) },
691
+ { id: 'findings', label: 'Findings', title: 'The worst confirmed severity and how many; ~n plausible only',
692
+ value: t => t.findings && t.findings.worst ? SEV_RANK[t.findings.worst] * 1000 - (t.findings[t.findings.worst] || 0) : t.findings && t.findings.plausible ? 4000 : null,
693
+ cell: t => { const f = t.findings, v = sev(f); if (!f || !f.worst) return v; const n = el('span', v, 'sev-chip sev-' + f.worst); return n; } },
694
+ { id: 'busy', label: 'Busy', value: busy, cell: t => RM.fmtDur(busy(t)) },
695
+ { id: 'explored', label: '* Explored / propensity', hidden: true, title: 'Tasks whose model forge drew at random, with the pick\'s propensity',
696
+ value: t => t.explored ? 1 : null,
697
+ cell: t => { const picks = list(t.route).filter(r => r.explored); return t.explored ? '* ' + (picks.length ? picks.map(r => `${display(r.chosen)} p=${display(r.propensity)}`).join('; ') : 'p=—') : '—'; } },
698
+ { id: 'deps', label: 'Deps', hidden: true, value: t => list(t.deps).length || null,
699
+ cell: t => { const deps = el('span', undefined, 'mono'); list(t.deps).forEach((id, i) => { if (i) deps.append(document.createTextNode(', ')); deps.append(link(id, run.key, { kind: 'task', id })); }); if (!deps.hasChildNodes()) deps.textContent = '—'; return deps; } },
700
+ { id: 'branch', label: 'Branch', mono: true, hidden: true, value: t => t.branch },
701
+ ];
702
+ }
577
703
  function renderTable(run) {
578
- const host = $('run-tasks');
704
+ const host = $('run-tasks'); if (!host) return;
705
+ const box = host.closest('.tasks-module'), count = box && box.querySelector('.module-count');
706
+ if (count) count.textContent = run.kind === 'solo' ? '' : String(list(run.tasks).length);
707
+ const tools = box && box.querySelector('.module-tools');
708
+ let slot = tools && tools.querySelector('.dt-tools');
709
+ if (tools && !slot) { slot = el('span', undefined, 'dt-tools'); tools.prepend(slot); }
579
710
  preserve(host, () => {
580
- if (run.kind === 'solo') return;
581
- host.append(el('h2', `Tasks (${list(run.tasks).length})`));
582
- if (!list(run.tasks).length) { host.append(el('p', 'This run has no tasks yet.', 'empty')); return; }
583
- const sev = f => f && f.worst ? `${f.worst} ×${f[f.worst]}` : f && f.plausible ? `~${f.plausible}` : '—';
584
- const rows = list(run.tasks).map(task => {
585
- const picks = list(task.route).filter(r => r.explored);
586
- const exploration = task.explored ? '* ' + (picks.length ? picks.map(r => `${display(r.chosen)} p=${display(r.propensity)}`).join('; ') : 'p=—') : '—';
587
- const deps = el('span', undefined, 'mono');
588
- list(task.deps).forEach((id, i) => { if (i) deps.append(document.createTextNode(', ')); deps.append(link(id, run.key, { kind: 'task', id })); });
589
- if (!deps.hasChildNodes()) deps.textContent = '—';
590
- return [link(task.id, run.key, { kind: 'task', id: task.id }), task.title, task.difficulty, task.wave, chip(task.status),
591
- task.dwarf, task.qa, task.attempt, chip(task.verdict), sev(task.findings), RM.fmtDur(RM.taskTimes(task, null, now()).busy || null), exploration, deps, task.branch];
592
- });
593
- const body = table(host, ['ID', 'Title', 'Difficulty', 'Wave', 'Status', 'Dwarf', 'QA', 'Attempt', 'Verdict', 'Findings', 'Busy', '* Explored / propensity', 'Deps', 'Branch'], rows, 'Tasks');
594
- Array.from(body.children).forEach((row, i) => {
595
- const id = run.tasks[i].id;
596
- row.classList.toggle('selected', !!state.sel && (state.sel.id === id || state.sel.task === id));
597
- row.addEventListener('click', event => {
598
- if (!event.target.closest('a') && !window.getSelection()?.toString()) select({ kind: 'task', id });
599
- });
711
+ if (run.kind === 'solo') { if (slot) slot.replaceChildren(); return; }
712
+ if (!list(run.tasks).length) { if (slot) slot.replaceChildren(); host.append(el('p', 'This run has no tasks yet.', 'empty')); return; }
713
+ Table.render(host, {
714
+ id: 'tasks', label: 'Tasks', columns: taskColumns(run), rows: list(run.tasks), tools: slot, scrollKey: 'Tasks',
715
+ prefs: state.tablePrefs.tasks, savePrefs: p => { state.tablePrefs = { ...state.tablePrefs, tasks: p }; store.set('tables', state.tablePrefs); },
716
+ rowClass: t => state.sel && (state.sel.id === t.id || state.sel.task === t.id) ? 'selected' : '',
717
+ onRow: (t, tr) => tr.addEventListener('click', event => {
718
+ if (!event.target.closest('a, button') && !window.getSelection()?.toString()) select({ kind: 'task', id: t.id });
719
+ }),
600
720
  });
601
721
  scoped(host, run.key + ':');
602
722
  });
@@ -610,6 +730,7 @@ window.ForgeUI = (() => {
610
730
  fetchJSON: path => fetchJSON(path).then(r => r.data),
611
731
  rerender: () => { const r = state.cache.get(state.key)?.data; if (r) renderInspector(r); },
612
732
  hash: s => hashFor(run.key, s), onSelect: s => select(s), diffHash: spec => diffHash(run.key, spec),
733
+ convoHash: s => convoHash(run.key, s),
613
734
  onFractal: (id, data) => { if (state.stage && state.stage.setFractal) state.stage.setFractal(id, data); },
614
735
  });
615
736
  scoped(host, run.key + ':');
@@ -654,6 +775,15 @@ window.ForgeUI = (() => {
654
775
  diffHref: (task, path) => diffHash(run.key, { kind: 'task', task, source: 'current', file: path }),
655
776
  prefs: state.filesPrefs, savePrefs: p => { state.filesPrefs = p; store.set('files', p); } });
656
777
  }
778
+ // every review's findings of the run, for the stage's marks: read again when the run changes
779
+ function syncFindings(run) {
780
+ const key = run.key, c = state.findings.get(key) || {};
781
+ if (c.data && state.stage) state.stage.setFindings(c.data);
782
+ if (c.updated === run.updated || c.pending === run.updated) return;
783
+ c.pending = run.updated; state.findings.set(key, c);
784
+ fetchJSON(`/api/runs/${encodeURIComponent(key)}/findings`).then(r => { c.data = r.data; }, () => {})
785
+ .finally(() => { c.updated = c.pending; c.pending = null; if (state.key === key && state.stage && c.data) state.stage.setFindings(c.data); });
786
+ }
657
787
  function loadFiles(run) {
658
788
  const key = run.key, updated = run.updated, c = state.files.get(key) || {};
659
789
  if (c.pending === updated) return;
@@ -661,14 +791,22 @@ window.ForgeUI = (() => {
661
791
  fetchJSON(`/api/runs/${encodeURIComponent(key)}/files`).then(r => { c.data = r.data; c.error = null; c.at = Date.now(); }, e => { c.error = e.message; })
662
792
  .finally(() => { c.updated = updated; c.pending = null; const cur = state.cache.get(state.key)?.data; if (cur && cur.key === key) renderFiles(cur); });
663
793
  }
664
- // the diff viewer takes the stage's place while it is open
794
+ // the diff viewer, or an execution's conversation, takes the stage's place while it is open
665
795
  function renderDiff(run) {
666
796
  const host = $('diff-view'), view = $('run-view'); if (!host || !view) return;
667
- const open = !!state.diff && !!window.ForgeDiff;
668
- view.classList.toggle('diff-open', open); host.hidden = !open;
669
- if (!open) { host.replaceChildren(); host.diffSig = ''; return; }
670
- window.ForgeDiff.render(host, { run, spec: state.diff, fetchJSON: path => fetchJSON(path).then(r => r.data),
671
- rerender: () => { const r = state.cache.get(state.key)?.data; if (r) renderDiff(r); },
797
+ const kind = state.convo && window.ForgeConvo ? 'convo' : state.diff && window.ForgeDiff ? 'diff' : null;
798
+ view.classList.toggle('diff-open', !!kind); host.hidden = !kind;
799
+ if (host.dataset.kind !== (kind || '')) { host.replaceChildren(); host.diffSig = ''; host.convoFrame = null; host.dataset.kind = kind || ''; }
800
+ if (!kind) return;
801
+ const rerender = () => { const r = state.cache.get(state.key)?.data; if (r) renderDiff(r); };
802
+ if (kind === 'convo') {
803
+ const sel = state.sel, task = sel.task ? list(run.tasks).find(t => t.id === sel.task) : run.kind === 'solo' ? list(run.tasks)[0] : null;
804
+ const brief = list(task && task.executions).find(e => e.id === state.convo.exec) || null;
805
+ window.ForgeConvo.render(host, { run, spec: { task: task ? task.id : null, exec: state.convo.exec }, brief, live: !SNAP,
806
+ fetchJSON: path => fetchJSON(path).then(r => r.data), rerender, diffHash: spec => diffHash(run.key, spec), closeHref: hashFor(run.key, state.sel) });
807
+ return;
808
+ }
809
+ window.ForgeDiff.render(host, { run, spec: state.diff, fetchJSON: path => fetchJSON(path).then(r => r.data), rerender,
672
810
  href: spec => diffHash(run.key, spec), closeHref: hashFor(run.key, state.sel) });
673
811
  }
674
812
  function renderRunBody(run) {
@@ -676,7 +814,7 @@ window.ForgeUI = (() => {
676
814
  syncToggles();
677
815
  syncReplay(run);
678
816
  placeStage($('stage-slot'));
679
- if (use3d() && state.stage) state.stage.showRun(run, { mode: state.stageMode, sel: state.sel });
817
+ if (use3d() && state.stage) { state.stage.showRun(run, { mode: state.stageMode, sel: state.sel }); syncFindings(run); }
680
818
  render2d(run);
681
819
  renderStrip(run);
682
820
  renderFiles(run);
@@ -687,24 +825,32 @@ window.ForgeUI = (() => {
687
825
  if (main.dataset.view !== 'run' || main.dataset.run !== run.key) {
688
826
  stopPlay(); state.replay = null;
689
827
  if (state.stage) state.stage.setReplay(null);
690
- main.dataset.view = 'run'; main.dataset.run = run.key;
828
+ setView(main, 'run'); main.dataset.run = run.key;
691
829
  main.replaceChildren();
692
- const head = el('section', undefined, 'run-header'); head.id = 'run-head'; main.append(head);
693
830
  const view = el('section', undefined, 'run-view'); view.id = 'run-view'; main.append(view);
694
- toolbar(view);
831
+ const head = el('section', undefined, 'run-header'); head.id = 'run-head'; head.dataset.overlay = '';
832
+ head.append(el('div', undefined, 'run-head-body')); view.append(head);
833
+ toolbar(head);
834
+ // what floats below the header (the 2D views) starts under it, however many rows it wraps to
835
+ if (window.ResizeObserver) new ResizeObserver(() => document.body.style.setProperty('--head-h', head.offsetHeight + 'px')).observe(head);
695
836
  const row = el('div', undefined, 'stage-row');
696
837
  const col = el('div', undefined, 'stage-col');
697
838
  const slot = el('div', undefined, 'stage-slot'); slot.id = 'stage-slot';
698
839
  const g2 = el('div', undefined, 'graph'); g2.id = 'graph2d'; g2.dataset.scroll = 'graph';
699
840
  const files = el('div', undefined, 'files-view'); files.id = 'files-view'; files.hidden = true;
700
841
  col.append(slot, g2, files);
701
- const insp = el('aside', undefined, 'inspector'); insp.id = 'inspector'; insp.setAttribute('aria-label', 'Inspector'); insp.tabIndex = -1;
702
- row.append(col, insp); view.append(row);
842
+ const module = el('aside', undefined, 'module insp-module'); module.setAttribute('aria-label', 'Inspector'); module.dataset.overlay = '';
843
+ const insp = el('div', undefined, 'inspector'); insp.id = 'inspector'; insp.tabIndex = -1;
844
+ module.append(Shell.head('insp', 'Inspector'), insp, Shell.splitter('insp'));
845
+ row.append(col, module); view.append(row);
703
846
  const strip = el('div', undefined, 'strip-wrap'); strip.id = 'strip'; view.append(strip);
704
- const diff = el('section', undefined, 'diff-view'); diff.id = 'diff-view'; diff.hidden = true; view.append(diff);
705
- const tasks = el('section', undefined, 'run-tasks'); tasks.id = 'run-tasks'; main.append(tasks);
706
- window.scrollTo(0, 0);
847
+ const diff = el('section', undefined, 'diff-view'); diff.id = 'diff-view'; diff.hidden = true; diff.tabIndex = -1; view.append(diff);
848
+ const box = el('section', undefined, 'module tasks-module'); box.setAttribute('aria-label', 'Tasks'); box.dataset.overlay = '';
849
+ const tasks = el('div', undefined, 'run-tasks'); tasks.id = 'run-tasks';
850
+ box.append(Shell.head('tasks', 'Tasks', ''), tasks, Shell.splitter('tasks'));
851
+ view.append(box);
707
852
  }
853
+ document.body.classList.toggle('no-tasks', run.kind === 'solo');
708
854
  ensureStage();
709
855
  renderHeader(run);
710
856
  renderRunBody(run);
@@ -713,6 +859,7 @@ window.ForgeUI = (() => {
713
859
  }
714
860
  function renderMain() {
715
861
  document.body.classList.toggle('cloud-mode', !!state.cloud);
862
+ if (!state.key) document.body.classList.remove('no-tasks');
716
863
  if (state.cloud) { renderCloud(); return; }
717
864
  if (state.models) { renderModels(); return; }
718
865
  if (!state.key) { renderMap(); return; }
@@ -765,7 +912,7 @@ window.ForgeUI = (() => {
765
912
  return request('/api/runs', result => {
766
913
  if (result.text === state.listText) return;
767
914
  state.runs = list(result.data.runs);
768
- state.listText = result.text;
915
+ state.listText = result.text; state.listLoaded = true;
769
916
  if (window.ForgeNotify) window.ForgeNotify.observe(state.runs);
770
917
  renderList();
771
918
  if (state.cloud) renderCloud();
@@ -780,7 +927,7 @@ window.ForgeUI = (() => {
780
927
  }, 'Could not refresh run', error => {
781
928
  if (state.key !== key || state.cache.has(key)) return;
782
929
  if (error.status === 404) state.errors.delete('/api/runs/' + encodeURIComponent(key));
783
- $('main').dataset.view = 'waiting';
930
+ setView($('main'), 'waiting');
784
931
  $('main').replaceChildren(el('p', error.status === 404 ?
785
932
  'Waiting for this run to appear… checking again while this tab is visible. If its directory was removed, pick another run.' :
786
933
  'Could not load this run. Select another run, or leave refresh enabled to retry.', 'empty'));
@@ -795,9 +942,13 @@ window.ForgeUI = (() => {
795
942
  }
796
943
  const keyChanged = parsed.key !== state.key;
797
944
  state.key = parsed.key;
945
+ const selWas = JSON.stringify(state.sel || null);
798
946
  state.sel = parsed.sel;
799
- const diffWas = JSON.stringify(state.diff || null);
947
+ // a selection is read in the inspector, so choosing one opens it
948
+ if (state.sel && JSON.stringify(state.sel) !== selWas && document.body.dataset.insp !== 'open') Shell.toggle('insp', true);
949
+ const diffWas = JSON.stringify(state.diff || null), convoWas = JSON.stringify(state.convo || null);
800
950
  state.diff = parsed.diff || null;
951
+ state.convo = parsed.convo || null;
801
952
  state.cloud = parsed.cloud || null;
802
953
  state.models = !!parsed.models;
803
954
  if (state.cloud && state.cloud.model) { // a model's runs: the cloud, filtered to it
@@ -805,6 +956,7 @@ window.ForgeUI = (() => {
805
956
  history.replaceState(null, '', '#/cloud'); state.cloud.model = null;
806
957
  }
807
958
  document.body.classList.toggle('cloud-mode', !!state.cloud);
959
+ if (!state.key) document.body.classList.remove('no-tasks');
808
960
  for (const a of document.querySelectorAll('.topbar .top-link')) {
809
961
  const here = a.getAttribute('href') === (state.cloud ? '#/cloud' : state.models ? '#/models' : !state.key ? '#/' : null);
810
962
  if (here) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
@@ -818,11 +970,12 @@ window.ForgeUI = (() => {
818
970
  if (cached) {
819
971
  if (keyChanged) renderRun(cached.data);
820
972
  else { renderRunBody(cached.data); renderTable(cached.data); }
821
- } else { $('main').dataset.view = 'loading'; $('main').replaceChildren(el('p', 'Loading run…', 'empty')); }
973
+ } else { setView($('main'), 'loading'); $('main').replaceChildren(el('p', 'Loading run…', 'empty')); }
822
974
  loadRun(state.key);
823
975
  // a selection made on the stage keeps the keyboard there (← → walk the tasks); one made elsewhere moves it to the inspector
824
- if (state.diff) {
825
- const opened = diffWas === 'null' || JSON.stringify({ ...JSON.parse(diffWas), file: null }) !== JSON.stringify({ ...state.diff, file: null });
976
+ if (state.diff || state.convo) {
977
+ const opened = state.convo ? convoWas !== JSON.stringify(state.convo) :
978
+ diffWas === 'null' || JSON.stringify({ ...JSON.parse(diffWas), file: null }) !== JSON.stringify({ ...state.diff, file: null });
826
979
  if (opened && $('diff-view')) { $('run-view').scrollIntoView({ block: 'start' }); $('diff-view').focus({ preventScroll: true }); }
827
980
  } else if (state.sel && !keyChanged && !stageHost.contains(document.activeElement)) $('inspector')?.focus({ preventScroll: true });
828
981
  }
@@ -840,7 +993,7 @@ window.ForgeUI = (() => {
840
993
  loadList();
841
994
  if (state.key) loadRun(state.key);
842
995
  if (state.models) loadModels();
843
- for (const node of document.querySelectorAll('[data-epoch]')) node.textContent = relative(node.dataset.epoch);
996
+ for (const node of document.querySelectorAll('[data-epoch]')) { const t = relative(node.dataset.epoch); node.textContent = node.dataset.short !== undefined ? t.replace(/^updated /, '') : t; }
844
997
  }
845
998
  // Poll faster while something runs, slower otherwise; never with refresh off, and a hidden tab only to notify.
846
999
  function schedule() {
@@ -911,8 +1064,10 @@ window.ForgeUI = (() => {
911
1064
  if (window.ForgeSearch) window.ForgeSearch.init({ runs: () => state.runs, commands, go: href => { location.hash = href; }, cloudFilter });
912
1065
  if (window.ForgeNotify) window.ForgeNotify.init({ go: href => { location.hash = href; } });
913
1066
  $('filter').addEventListener('input', event => { state.mapFilter.text = event.target.value; filterChanged(true); });
1067
+ $('run-sort').addEventListener('change', event => { state.runSort = event.target.value; store.set('run-sort', state.runSort); renderList(); });
914
1068
  $('refresh').addEventListener('change', event => { state.paused = !event.target.checked; refresh(); });
915
1069
  window.addEventListener('hashchange', navigate);
1070
+ if (Shell) Shell.onChange(() => { if (state.stage && !state.cloud) requestAnimationFrame(() => state.stage.reframe && state.stage.reframe()); });
916
1071
  document.addEventListener('visibilitychange', refresh);
917
1072
  window.addEventListener('resize', () => { const run = !state.cloud && state.cache.get(state.key)?.data; if (run && (!use3d() || !state.stage || state.stageMode === 'exec')) { render2d(run); renderStrip(run); } });
918
1073
  loadList();