@giamme/forge 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +22 -3
  4. package/VERSION +1 -1
  5. package/docs/README.md +26 -0
  6. package/docs/add-ons.md +33 -0
  7. package/docs/artifacts.md +84 -0
  8. package/docs/commands.md +365 -0
  9. package/docs/dataset.md +29 -0
  10. package/docs/decomposed-runs.md +310 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +128 -0
  13. package/docs/fractal.md +181 -0
  14. package/docs/install.md +160 -0
  15. package/docs/memory.md +156 -0
  16. package/docs/models.md +118 -0
  17. package/docs/reviews.md +245 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +20 -0
  21. package/package.json +3 -2
  22. package/references/dataset.md +18 -1
  23. package/references/decompose.md +20 -2
  24. package/references/fractal.md +1 -1
  25. package/references/harnesses.md +18 -6
  26. package/references/solo.md +1 -1
  27. package/references/ui.md +27 -5
  28. package/scripts/forge-config.py +347 -0
  29. package/scripts/forge-control.py +645 -0
  30. package/scripts/forge-dispatch.sh +7 -1
  31. package/scripts/forge-issues.py +806 -0
  32. package/scripts/forge-lib-task.sh +234 -35
  33. package/scripts/forge-parallel-options.sh +3 -1
  34. package/scripts/forge-parallel.sh +3 -2
  35. package/scripts/forge-route.py +184 -27
  36. package/scripts/forge-runs-rm.py +840 -0
  37. package/scripts/forge-runs.py +139 -0
  38. package/scripts/forge-runtime.py +59 -6
  39. package/scripts/forge-schedule.py +227 -11
  40. package/scripts/forge-ui.py +85 -19
  41. package/scripts/forge-version.py +54 -12
  42. package/scripts/forge_dataset/__init__.py +77 -6
  43. package/scripts/forge_dataset/capture.py +5 -2
  44. package/scripts/forge_dataset/harvest.py +23 -1
  45. package/scripts/forge_dataset/importer.py +420 -18
  46. package/scripts/forge_fractal/__init__.py +2 -1
  47. package/scripts/forge_registry.py +168 -16
  48. package/ui/README.md +116 -23
  49. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  50. package/ui/docs/api.md +283 -23
  51. package/ui/forge_ui/__main__.py +10 -2
  52. package/ui/forge_ui/control.py +575 -0
  53. package/ui/forge_ui/export.py +33 -9
  54. package/ui/forge_ui/fractal.py +1 -2
  55. package/ui/forge_ui/reader.py +121 -8
  56. package/ui/forge_ui/server.py +135 -5
  57. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  58. package/ui/forge_ui/static/app.js +358 -131
  59. package/ui/forge_ui/static/cloud3d.js +3 -2
  60. package/ui/forge_ui/static/cloudui.js +8 -5
  61. package/ui/forge_ui/static/controls.js +619 -0
  62. package/ui/forge_ui/static/convo.js +295 -0
  63. package/ui/forge_ui/static/datatable.js +232 -0
  64. package/ui/forge_ui/static/diffview.js +29 -10
  65. package/ui/forge_ui/static/findings3d.js +134 -0
  66. package/ui/forge_ui/static/findingsview.js +412 -0
  67. package/ui/forge_ui/static/graph.js +3 -3
  68. package/ui/forge_ui/static/icons.js +94 -0
  69. package/ui/forge_ui/static/index.html +31 -13
  70. package/ui/forge_ui/static/iso.js +70 -19
  71. package/ui/forge_ui/static/leaderboard.js +44 -48
  72. package/ui/forge_ui/static/panel.js +92 -36
  73. package/ui/forge_ui/static/rundelete.js +388 -0
  74. package/ui/forge_ui/static/runmodel.js +17 -1
  75. package/ui/forge_ui/static/scene3d.js +518 -43
  76. package/ui/forge_ui/static/search.js +2 -1
  77. package/ui/forge_ui/static/settings.js +399 -0
  78. package/ui/forge_ui/static/shell.js +137 -0
  79. package/ui/forge_ui/static/stagectl.js +150 -0
  80. package/ui/forge_ui/static/style.css +1099 -469
  81. package/ui/forge_ui/static/theme.js +25 -0
  82. package/ui/forge_ui/static/uikit.js +161 -0
  83. package/ui/forge_ui/transcripts.py +608 -0
@@ -1,8 +1,8 @@
1
- /* forge-ui: read-only API viewer. All API values enter the DOM as text.
1
+ /* forge-ui: the page. It reads the API; a change to a run goes through the write helpers in uikit.js (see controls.js). All API values enter the DOM as text.
2
2
  Views: the map of every run (#/), the run cloud (#/cloud, full screen: cloudui.js over cloud3d.js), and a
3
3
  run (#/runs/<key>) with its stage (3D, or the 2D graph and chart), the inspector beside it and the task table under it. The stage is one element that moves between views,
4
4
  so the 3D scene (and its WebGL context) survives navigation and refreshes. */
5
- window.ForgeUI = (() => {
5
+ window.ForgeApp = (() => {
6
6
  'use strict';
7
7
  const RM = window.ForgeRunModel;
8
8
  // an exported page (forge_ui/export.py): one run and the API responses it needs, no server behind it
@@ -15,13 +15,22 @@ 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', {}),
21
+ settings: false, lastKey: null, settingsRun: null, settingsPickedAt: null, settingsView: null,
20
22
  models: false, modelsData: null, modelsText: null, modelsError: null, modelsPrefs: store.get('models', {}),
21
- files: new Map(), filesPrefs: store.get('files', {}) };
23
+ files: new Map(), findingsCount: new Map(), filesPrefs: store.get('files', {}), runSort: store.get('run-sort', 'updated'), tablePrefs: store.get('tables', {}),
24
+ findings: new Map() };
22
25
  state.stageMode = state.mode === 'exec' ? 'exec' : 'plan'; // the stage's own mode, kept while Files is shown
23
26
  if (state.mode === 'files' && !window.ForgeFiles) state.mode = state.stageMode;
24
27
  const $ = id => document.getElementById(id);
28
+ const Shell = window.ForgeShell, Table = window.ForgeTable;
29
+ const icon = name => window.ForgeIcons ? window.ForgeIcons.icon(name) : document.createTextNode('');
30
+ const LAMP = { live: 'running', failed: 'fail', done: 'merged', pending: 'pending' };
31
+ const STATE_ORDER = { live: 0, failed: 1, pending: 2, done: 3 };
32
+ // the view on <main> and on <body>: the shell's CSS lays out each view (stage views fill the window)
33
+ function setView(main, view) { main.dataset.view = view; document.body.dataset.view = view; }
25
34
  const display = value => value === null || value === undefined || value === '' ? '—' :
26
35
  typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
27
36
  const list = value => Array.isArray(value) ? value : [];
@@ -47,13 +56,16 @@ window.ForgeUI = (() => {
47
56
  if (sel.kind === 'fractal') return `${base}/tasks/${e(sel.task)}/fractal/${e(sel.node)}`;
48
57
  return base;
49
58
  }
50
- // a diff: {kind: 'task', task, source, file} | {kind: 'final', name, file} | {kind: 'fractal', task, node, file}
59
+ // a diff: {kind: 'task', task, source, file} | {kind: 'final', name, file} | {kind: 'fractal', task, node, file},
60
+ // opened at a file, or at one finding (its id from /findings)
51
61
  function diffHash(key, spec) {
52
- const e = encodeURIComponent, base = '#/runs/' + e(key), file = spec.file ? '/file/' + e(spec.file) : '';
62
+ const e = encodeURIComponent, base = '#/runs/' + e(key), file = spec.finding ? '/finding/' + e(spec.finding) : spec.file ? '/file/' + e(spec.file) : '';
53
63
  if (spec.kind === 'final') return `${base}/final-review/${e(spec.name)}/diff${file}`;
54
64
  if (spec.kind === 'fractal') return `${base}/tasks/${e(spec.task)}/fractal/${e(spec.node)}/diff${file}`;
55
65
  return `${base}/tasks/${e(spec.task)}/diff${spec.source && spec.source !== 'current' ? '/' + e(spec.source) : ''}${file}`;
56
66
  }
67
+ // an execution's conversation, in the stage's place like a diff
68
+ const convoHash = (key, sel) => hashFor(key, sel) + '/conversation';
57
69
  function cloudHash(sel, focus) {
58
70
  if (sel) return '#/cloud/runs/' + encodeURIComponent(sel);
59
71
  return focus ? '#/cloud/projects/' + encodeURIComponent(focus) : '#/cloud';
@@ -62,13 +74,21 @@ window.ForgeUI = (() => {
62
74
  const c = hash.match(/^#\/cloud(?:\/(runs|projects|models)\/([^/]+))?\/?$/);
63
75
  if (c) return { key: null, sel: null, cloud: { sel: c[1] === 'runs' ? decodeURIComponent(c[2]) : null, focus: c[1] === 'projects' ? decodeURIComponent(c[2]) : null, model: c[1] === 'models' ? decodeURIComponent(c[2]) : null } };
64
76
  if (/^#\/models\/?$/.test(hash)) return { key: null, sel: null, models: true };
77
+ if (/^#\/settings\/?$/.test(hash)) return { key: null, sel: null, settings: true };
65
78
  const m = hash.match(/^#\/runs\/([^/]+)(\/.*)?$/);
66
79
  if (!m) return { key: null, sel: null };
67
80
  const d = decodeURIComponent, rest = m[2] || '';
81
+ if (/^\/findings\/?$/.test(rest)) return { key: d(m[1]), sel: null, findings: true };
68
82
  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 } };
83
+ 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
84
  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 } };
85
+ 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 } };
86
+ if ((r = rest.match(/^\/(?:tasks\/([^/]+)\/)?executions\/([^/]+)\/conversation\/?$/))) {
87
+ 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]) };
88
+ const run = s.task && state.cache.get(d(m[1]))?.data, t = run && list(run.tasks).find(x => x.id === s.task);
89
+ if (t && list(t.shadow).some(x => x.execution === s.exec)) s.shadow = true;
90
+ return { key: d(m[1]), sel: s, convo: { task: s.task, exec: s.exec } };
91
+ }
72
92
  if ((r = rest.match(/^\/tasks\/([^/]+)\/executions\/([^/]+)\/?$/))) sel = { kind: 'execution', task: d(r[1]), id: d(r[1]), exec: d(r[2]) };
73
93
  if (sel && sel.kind === 'execution' && sel.task) {
74
94
  const run = state.cache.get(d(m[1]))?.data, t = run && list(run.tasks).find(x => x.id === sel.task);
@@ -111,16 +131,6 @@ window.ForgeUI = (() => {
111
131
  parent.append(node);
112
132
  return node;
113
133
  }
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
134
  function details(parent, title, value, key) {
125
135
  const node = el('details');
126
136
  node.dataset.keep = key;
@@ -164,8 +174,10 @@ window.ForgeUI = (() => {
164
174
  const active = parent.contains(document.activeElement) ? document.activeElement : null;
165
175
  const focusHref = active?.getAttribute('href');
166
176
  const own = [parent.scrollLeft, parent.scrollTop];
177
+ const held = window.ForgeControls ? window.ForgeControls.hold(parent) : null; // a control being edited rides through the rebuild
167
178
  parent.replaceChildren();
168
179
  build();
180
+ if (held) held.restore();
169
181
  if (!restore) return;
170
182
  for (const node of parent.querySelectorAll('details[data-keep]')) if (open.has(node.dataset.keep)) node.open = open.get(node.dataset.keep);
171
183
  for (const node of parent.querySelectorAll('[data-scroll]')) {
@@ -193,11 +205,15 @@ window.ForgeUI = (() => {
193
205
  banner.hidden = !state.errors.size;
194
206
  }
195
207
  function filteredRuns() {
196
- return state.runs.filter(run => RM.matchRun(run, state.mapFilter, wall())).sort((a, b) => (b.updated || 0) - (a.updated || 0));
208
+ const runs = state.runs.filter(run => RM.matchRun(run, state.mapFilter, wall())), byTime = (a, b) => (b.updated || 0) - (a.updated || 0);
209
+ if (state.runSort === 'state') return runs.sort((a, b) => STATE_ORDER[RM.runState(a)] - STATE_ORDER[RM.runState(b)] || byTime(a, b));
210
+ if (state.runSort === 'repo') return runs.sort((a, b) => RM.basename(a.repo).localeCompare(RM.basename(b.repo)) || byTime(a, b));
211
+ return runs.sort(byTime);
197
212
  }
198
213
  function renderList() {
199
214
  const runs = filteredRuns();
200
- $('run-count').textContent = `${runs.length} / ${state.runs.length}`;
215
+ $('run-count').textContent = `${runs.length}/${state.runs.length}`;
216
+ if ($('run-sort').value !== state.runSort) $('run-sort').value = state.runSort;
201
217
  if ($('filter').value !== state.mapFilter.text) $('filter').value = state.mapFilter.text;
202
218
  preserve($('run-list'), () => {
203
219
  if (!runs.length) {
@@ -205,7 +221,7 @@ window.ForgeUI = (() => {
205
221
  empty.append(el('p', state.runs.length ? 'No runs match this filter.' : 'No runs found.'));
206
222
  if (!state.runs.length) {
207
223
  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>'));
224
+ empty.append(el('p', 'Open runs kept somewhere else:'), el('pre', 'forge ui open <run-dir>', 'cmd'));
209
225
  }
210
226
  $('run-list').append(empty);
211
227
  }
@@ -213,15 +229,18 @@ window.ForgeUI = (() => {
213
229
  const item = link(undefined, run.key);
214
230
  item.className = 'run-item';
215
231
  item.dataset.run = run.key;
232
+ const st = RM.runState(run), lamp = el('i', undefined, 'lamp run-lamp st-' + LAMP[st]);
233
+ lamp.title = { live: 'running', failed: 'has failures', done: 'all done', pending: 'not started' }[st];
216
234
  const goal = el('strong', String(run.goal || 'Untitled run').split('\n')[0], 'run-goal');
217
235
  goal.title = run.goal || 'Untitled run';
218
236
  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;
237
+ const sub = el('span', undefined, 'run-sub');
238
+ const updated = el('span', relative(run.updated).replace(/^updated /, ''), 'updated');
239
+ if (run.updated != null) { updated.dataset.epoch = run.updated; updated.dataset.short = ''; }
223
240
  updated.title = time(run.updated);
224
- item.append(updated);
241
+ sub.append(el('span', RM.basename(run.repo), 'repo'), el('span', run.run_id, 'mono'), updated);
242
+ item.append(lamp, goal, sub);
243
+ if (solo) item.append(el('span', 'solo', 'tag'));
225
244
  const counts = el('div', undefined, 'chips');
226
245
  for (const [status, count] of Object.entries(run.counts || {})) {
227
246
  const badge = chip(status);
@@ -261,6 +280,12 @@ window.ForgeUI = (() => {
261
280
  onOpenRun: key => { location.hash = hashFor(key, null); },
262
281
  onBack: () => { location.hash = '#/'; },
263
282
  onMode: mode => setMode(mode),
283
+ onLayers: L => { state.layers = L; store.set('layers', L); syncLayerKeys(); }, // the stage's I key
284
+ // a finding's mark: the diff that review read, opened at that finding
285
+ onFinding: d => {
286
+ location.hash = d.final ? diffHash(state.key, { kind: 'final', name: d.final, finding: d.finding.id })
287
+ : diffHash(state.key, { kind: 'task', task: d.task, source: d.review && d.review.execution || 'current', finding: d.finding.id });
288
+ },
264
289
  cloud: {
265
290
  onSelect: key => { location.hash = cloudHash(key, state.cloud && state.cloud.focus); },
266
291
  onFocus: pid => { location.hash = cloudHash(null, pid); },
@@ -272,6 +297,7 @@ window.ForgeUI = (() => {
272
297
  })).then(stage => {
273
298
  state.stage = stage; state.stageLoading = false;
274
299
  stage.setOverlay(state.overlay);
300
+ stage.setLayers(state.layers);
275
301
  stage.setReplay(state.replay);
276
302
  renderMain();
277
303
  }, err => {
@@ -351,20 +377,43 @@ window.ForgeUI = (() => {
351
377
  parent.append(wrap);
352
378
  return A;
353
379
  }
380
+ // No run at all yet (a fresh install, or nothing registered): say how to get one, instead of an empty
381
+ // plate under a filter bar with nothing to filter. The first run that registers replaces it with the map.
382
+ function renderFirstRun() {
383
+ const main = $('main');
384
+ if (main.dataset.view === 'first-run') return;
385
+ setView(main, 'first-run'); delete main.dataset.run; // the shell lays out each view by <body data-view>
386
+ main.replaceChildren();
387
+ const v = section(main, undefined, 'first-run'); v.className = 'first-run';
388
+ v.append(el('h1', 'No forge runs yet'),
389
+ 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.'),
390
+ el('h2', 'Start a run from your agent harness'),
391
+ el('pre', '/forge "<goal>" --dwarf <model>', 'cmd'),
392
+ 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'),
393
+ el('h2', 'Open runs kept somewhere else'),
394
+ el('pre', 'forge ui open <run-dir>', 'cmd'),
395
+ 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'));
396
+ }
354
397
  function renderMap({ rebuildBar = true } = {}) {
355
398
  const main = $('main');
399
+ if (state.listLoaded && !state.runs.length && !SNAP) { renderFirstRun(); return; }
356
400
  if (main.dataset.view !== 'map') {
357
- main.dataset.view = 'map'; delete main.dataset.run;
401
+ setView(main, 'map'); delete main.dataset.run;
358
402
  main.replaceChildren();
359
403
  const v = section(main, undefined, 'map-view'); v.className = 'map-view';
404
+ const box = el('div', undefined, 'module map-head-module'); box.dataset.overlay = '';
360
405
  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);
406
+ const toCloud = el('a', undefined, 'button push'); toCloud.href = '#/cloud'; toCloud.append(icon('cloud'), el('span', 'Run cloud'));
407
+ head.append(el('h1', 'All runs', 'module-title'), el('span', '', 'map-count'), viewToggle(), toCloud);
408
+ const bar = el('div'); bar.id = 'map-filters';
409
+ // on a phone the filters fold behind a key, so the map is the first thing on the screen
410
+ const fk = el('button', undefined, 'filters-key'); fk.type = 'button'; fk.setAttribute('aria-controls', 'map-filters'); fk.setAttribute('aria-expanded', 'false');
411
+ fk.append(icon('filter'), el('span', 'Filters'));
412
+ fk.addEventListener('click', () => { const open = box.dataset.filters !== 'open'; box.dataset.filters = open ? 'open' : 'closed'; fk.setAttribute('aria-expanded', String(open)); });
413
+ head.append(fk);
414
+ box.append(head, bar); v.append(box);
366
415
  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);
416
+ const cards = el('div', undefined, 'map-cards-host'); cards.id = 'map-cards'; v.append(cards);
368
417
  rebuildBar = true;
369
418
  }
370
419
  if (rebuildBar) preserve($('map-filters'), () => filterBar($('map-filters')));
@@ -378,7 +427,7 @@ window.ForgeUI = (() => {
378
427
  } else {
379
428
  preserve($('map-cards'), () => { const A = mapCards($('map-cards')); info = { hidden: A.hidden, matches: A.matches, shown: A.modules.length, total: A.total }; });
380
429
  }
381
- const counter = $('map-view').querySelector('.map-head .muted');
430
+ const counter = $('map-view').querySelector('.map-head .map-count');
382
431
  if (info) counter.textContent = `${info.matches} of ${info.shown} shown runs match${info.hidden ? ` · ${info.hidden} older hidden` : ''}`;
383
432
  }
384
433
 
@@ -386,7 +435,7 @@ window.ForgeUI = (() => {
386
435
  function renderCloud() {
387
436
  const main = $('main');
388
437
  if (main.dataset.view !== 'cloud' || !state.cloudUI) {
389
- main.dataset.view = 'cloud'; delete main.dataset.run;
438
+ setView(main, 'cloud'); delete main.dataset.run;
390
439
  main.replaceChildren();
391
440
  const v = el('section', undefined, 'cloud-view'); v.id = 'cloud-view'; v.setAttribute('aria-label', 'Run cloud');
392
441
  const slot = el('div', undefined, 'stage-slot cloud'); slot.id = 'stage-slot'; v.append(slot);
@@ -412,18 +461,19 @@ window.ForgeUI = (() => {
412
461
  function renderModels() {
413
462
  const main = $('main');
414
463
  if (main.dataset.view !== 'models') {
415
- main.dataset.view = 'models'; delete main.dataset.run;
464
+ setView(main, 'models'); delete main.dataset.run;
416
465
  main.replaceChildren();
417
466
  const v = el('section', undefined, 'models-view'); v.id = 'models-view'; main.append(v);
418
467
  }
419
468
  const host = $('models-view');
420
469
  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'));
470
+ 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
471
  return;
423
472
  }
424
473
  window.ForgeLeaderboard.render(host, state.modelsData, {
425
474
  runs: state.runs, prefs: state.modelsPrefs, now: () => Date.now() / 1000,
426
475
  savePrefs: p => { state.modelsPrefs = p; store.set('models', p); },
476
+ tablePrefs: id => state.tablePrefs[id], saveTablePrefs: (id, p) => { state.tablePrefs = { ...state.tablePrefs, [id]: p }; store.set('tables', state.tablePrefs); },
427
477
  modelHref: alias => '#/cloud/models/' + encodeURIComponent(alias),
428
478
  });
429
479
  }
@@ -436,6 +486,41 @@ window.ForgeUI = (() => {
436
486
  }, 'Could not refresh the model results', error => { state.modelsError = error.message; if (state.models) renderModels(); });
437
487
  }
438
488
 
489
+ /* ---------- the settings (#/settings) ---------- */
490
+ function renderSettings() {
491
+ const main = $('main');
492
+ if (main.dataset.view === 'settings') { state.settingsView?.poll(); return; } // already drawn: its unsaved edits stay
493
+ setView(main, 'settings'); delete main.dataset.run;
494
+ main.replaceChildren();
495
+ const v = el('section', undefined, 'settings-view'); v.id = 'settings-view'; main.append(v);
496
+ state.settingsView = window.ForgeSettings.render(v, {
497
+ // a pick holds only while the current run is the one it was made from: the default is the current or most recent run
498
+ key: state.settingsRun && state.lastKey === state.settingsPickedAt ? state.settingsRun : state.lastKey,
499
+ runs: () => state.runs, onPick: key => { state.settingsRun = key; state.settingsPickedAt = state.lastKey; },
500
+ });
501
+ }
502
+
503
+ /* ---------- a run's open findings (#/runs/<key>/findings) ---------- */
504
+ function renderFindings() {
505
+ const main = $('main');
506
+ if (main.dataset.view === 'findings' && main.dataset.run === state.key) return; // already drawn: a toggle elsewhere must not reload it
507
+ setView(main, 'findings'); main.dataset.run = state.key;
508
+ main.replaceChildren();
509
+ const v = el('section', undefined, 'findings-view'); v.id = 'findings-view'; main.append(v);
510
+ window.ForgeFindings.render(v, {
511
+ key: state.key, run: state.cache.get(state.key)?.data, back: '#/runs/' + encodeURIComponent(state.key),
512
+ tablePrefs: id => state.tablePrefs[id], saveTablePrefs: (id, p) => { state.tablePrefs = { ...state.tablePrefs, [id]: p }; store.set('tables', state.tablePrefs); },
513
+ onCount: (n, scope) => { if (scope !== 'run') return; state.findingsCount.set(state.key, n); },
514
+ });
515
+ }
516
+ function findingsLink(key) {
517
+ const a = el('a', undefined, 'details-btn findings-link'); a.href = '#/runs/' + encodeURIComponent(key) + '/findings';
518
+ a.title = 'The findings this run left unfixed: dismiss them or file them as issues';
519
+ const n = state.findingsCount.get(key);
520
+ a.append(icon('panel-right'), el('span', 'Open findings'), el('span', n === undefined ? '' : String(n), 'findings-count'));
521
+ return a;
522
+ }
523
+
439
524
  /* ---------- a run ---------- */
440
525
  function viewToggle() {
441
526
  const g = el('div', undefined, 'seg'); g.setAttribute('role', 'group'); g.setAttribute('aria-label', 'Rendering');
@@ -449,6 +534,7 @@ window.ForgeUI = (() => {
449
534
  function syncToggles() {
450
535
  for (const b of document.querySelectorAll('[data-dim]')) b.setAttribute('aria-pressed', String(b.dataset.dim === state.dim));
451
536
  for (const b of document.querySelectorAll('[data-mode]')) b.setAttribute('aria-pressed', String(b.dataset.mode === state.mode));
537
+ syncLayerKeys();
452
538
  }
453
539
  function setMode(mode) {
454
540
  if (mode === state.mode) return;
@@ -457,15 +543,20 @@ window.ForgeUI = (() => {
457
543
  syncToggles();
458
544
  const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
459
545
  }
460
- function toolbar(parent) {
546
+ // the header's last row: the push-button bank that sets what the stage shows, then the replay's time base
547
+ function toolbar(header) {
461
548
  const bar = el('div', undefined, 'run-toolbar'); bar.setAttribute('role', 'toolbar'); bar.setAttribute('aria-label', 'Run view');
462
549
  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)'],
550
+ 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
551
  ['files', 'Files', 'Which tasks planned, changed, drifted into or conflicted on which files']].filter(([m]) => m !== 'files' || window.ForgeFiles)) {
465
552
  const b = el('button', label); b.type = 'button'; b.dataset.mode = m; b.title = title; b.addEventListener('click', () => setMode(m)); modes.append(b);
466
553
  }
467
554
  bar.append(modes, viewToggle());
468
- const ov = el('div', undefined, 'overlays');
555
+ const ov = el('div', undefined, 'overlays popover'); ov.popover = 'auto'; ov.id = 'view-pop'; ov.setAttribute('aria-label', 'What the stage shows');
556
+ ov.append(el('span', 'View', 'plate'));
557
+ const viewBtn = el('button', undefined, 'view-btn'); viewBtn.type = 'button'; viewBtn.popoverTargetElement = ov; viewBtn.setAttribute('aria-haspopup', 'true');
558
+ viewBtn.append(icon('layers'), el('span', 'View'), icon('chevron-up')); viewBtn.title = 'Height, colour, layers and marks';
559
+ ov.addEventListener('toggle', e => { if (e.newState === 'open') { syncLayerKeys(); placePopover(viewBtn, ov); } });
469
560
  const sel = (label, key, entries) => {
470
561
  const l = el('label', undefined, 'filter'); l.append(el('span', label));
471
562
  const s = el('select'); s.dataset.ov = key;
@@ -474,35 +565,101 @@ window.ForgeUI = (() => {
474
565
  };
475
566
  sel('Height', 'height', [['none', 'difficulty'], ['duration', 'duration'], ['tokens', 'tokens'], ['attempts', 'attempts']]);
476
567
  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
- }
568
+ // push buttons, one per part of the drawing: its lamp lights while that part is drawn
569
+ const bankOf = (title, rows, attr) => {
570
+ const g = el('div', undefined, 'layer-bank'); g.setAttribute('role', 'group'); g.setAttribute('aria-label', title);
571
+ for (const [key, label, tip] of rows) {
572
+ const b = el('button', label, 'layer-key'); b.type = 'button'; b.dataset[attr] = key; b.title = tip; g.append(b);
573
+ }
574
+ ov.append(el('span', title, 'plate'), g);
575
+ return g;
576
+ };
577
+ 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'],
578
+ ['lamps', 'Lamps', 'The lamps on each block: one per attempt'], ['roof', 'Roof text', 'Task id, title, models and finding counts on each roof'],
579
+ ['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'],
580
+ ['finish', 'Finish line', 'Integration, verification and the final review'], ['districts', 'Districts', 'The wave and repository names on the plate'],
581
+ ['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');
582
+ bankOf('Marks', [['critical', 'Critical path', 'The chain of dependencies that bounded the wall time'], ['arcs', 'Review marks', 'Shadow disagreements and Clef rings'],
583
+ ['ghosts', 'Projected', 'Where the tasks that have not started would run, by forge-schedule\'s rules']], 'ov');
584
+ ov.addEventListener('click', e => {
585
+ const b = e.target.closest('.layer-key'); if (!b) return;
586
+ if (b.dataset.layer) {
587
+ const k = b.dataset.layer; state.layers[k] = !state.layers[k]; store.set('layers', state.layers);
588
+ if (state.stage) state.stage.setLayers({ [k]: state.layers[k] });
589
+ } else {
590
+ const k = b.dataset.ov; state.overlay[k] = !state.overlay[k]; store.set('overlay', state.overlay);
591
+ if (state.stage) state.stage.setOverlay({ [k]: state.overlay[k] });
592
+ const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
593
+ }
594
+ syncLayerKeys();
595
+ });
482
596
  ov.addEventListener('input', e => {
483
597
  const k = e.target.dataset.ov; if (!k) return;
484
- state.overlay[k] = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
598
+ state.overlay[k] = e.target.value;
485
599
  store.set('overlay', state.overlay);
486
600
  if (state.stage) state.stage.setOverlay({ [k]: state.overlay[k] });
487
601
  const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
488
602
  });
489
- bar.append(ov);
603
+ bar.append(viewBtn, ov);
490
604
  const rp = el('div', undefined, 'replay'); rp.id = 'replay';
491
- const play = el('button', '▶ Replay'); play.type = 'button'; play.dataset.act = 'play';
605
+ 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
606
  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
607
  const speed = el('select'); speed.dataset.act = 'speed'; speed.setAttribute('aria-label', 'Replay speed');
494
608
  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';
609
+ const live = el('button'); live.type = 'button'; live.dataset.act = 'live'; live.append(icon('live'), el('span', 'Live'));
496
610
  const at = el('span', '', 'muted mono'); at.dataset.act = 'at';
497
611
  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); });
612
+ rp.addEventListener('click', e => { const a = e.target.closest('[data-act]')?.dataset.act; if (a === 'play') togglePlay(); if (a === 'live') setReplay(null); });
499
613
  rp.addEventListener('input', e => {
500
614
  const a = e.target.dataset.act, run = state.cache.get(state.key)?.data; if (!run) return;
501
615
  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
616
  if (a === 'speed') { state.speed = Number(e.target.value); store.set('speed', state.speed); }
503
617
  });
504
- bar.append(rp);
505
- parent.append(bar);
618
+ // the header's action slot: the run's record opens in the inspector, the export opens its dialog
619
+ const acts = el('div', undefined, 'run-actions');
620
+ const rec = el('button', undefined, 'details-btn'); rec.type = 'button'; rec.title = 'Settings, request, waves and verification output, in the inspector (2)';
621
+ rec.append(icon('panel-right'), el('span', 'Run details'));
622
+ rec.addEventListener('click', () => { Shell.toggle('insp', true); if (state.sel) select(null); });
623
+ acts.append(rec);
624
+ if (!SNAP && window.ForgeFindings) acts.append(findingsLink(state.key));
625
+ if (!SNAP) {
626
+ const ex = el('button', undefined, 'export-btn'); ex.type = 'button'; ex.title = 'Download this run as one HTML file to share';
627
+ ex.append(icon('download'), el('span', 'Export…'));
628
+ ex.addEventListener('click', () => { const run = state.cache.get(state.key)?.data; if (run) exportDialog(run); }); acts.append(ex);
629
+ }
630
+ // the delete key stands apart from the routine ones, and only a server that takes writes shows it
631
+ if (!SNAP && window.ForgeRunDelete && window.ForgeUI && window.ForgeUI.controlsEnabled) {
632
+ window.ForgeUI.controlsEnabled().then(on => {
633
+ if (!on || acts.querySelector('.del-key')) return;
634
+ const del = el('button', undefined, 'del-key'); del.type = 'button'; del.title = 'Delete this run, after a dry run and a typed confirmation';
635
+ del.append(el('i', undefined, 'lamp st-fail'), el('span', 'Delete run…'));
636
+ del.addEventListener('click', () => { const run = state.cache.get(state.key)?.data; if (run) window.ForgeRunDelete.open(run, { onDeleted: runDeleted }); });
637
+ acts.append(el('span', undefined, 'del-gap'), del);
638
+ });
639
+ }
640
+ bar.append(rp, acts);
641
+ header.append(bar);
642
+ }
643
+ // after a delete: the run is gone, so the page returns to the run list and drops what it held of the run
644
+ function runDeleted(run) {
645
+ state.cache.delete(run.key);
646
+ if (location.hash !== '#/') location.hash = '#/';
647
+ state.listText = null;
648
+ loadList();
649
+ }
650
+ // the View popover's push buttons: pressed in while their part is drawn; the layers are the 3D stage's own
651
+ function syncLayerKeys() {
652
+ for (const b of document.querySelectorAll('.layer-key')) {
653
+ const on = b.dataset.layer ? !!state.layers[b.dataset.layer] : !!state.overlay[b.dataset.ov];
654
+ b.setAttribute('aria-pressed', String(on));
655
+ if (b.dataset.layer) b.disabled = !use3d();
656
+ }
657
+ }
658
+ // a popover beside the button that opened it: above when the button sits low on the screen
659
+ function placePopover(btn, pop) {
660
+ const r = btn.getBoundingClientRect(), w = pop.offsetWidth, h = pop.offsetHeight, below = window.innerHeight - r.bottom > h + 16;
661
+ pop.style.left = Math.round(Math.max(8, Math.min(window.innerWidth - w - 8, r.left))) + 'px';
662
+ pop.style.top = Math.round(below ? r.bottom + 6 : Math.max(8, r.top - h - 6)) + 'px';
506
663
  }
507
664
  function syncReplay(run) {
508
665
  const rp = $('replay'); if (!rp) return;
@@ -511,8 +668,9 @@ window.ForgeUI = (() => {
511
668
  if (!r) return;
512
669
  const range = rp.querySelector('[data-act=scrub]'), at = rp.querySelector('[data-act=at]');
513
670
  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';
671
+ 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
672
+ const playBtn = rp.querySelector('[data-act=play]');
673
+ 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
674
  rp.querySelector('[data-act=live]').disabled = state.replay === null;
517
675
  }
518
676
  function setReplay(t) {
@@ -521,19 +679,25 @@ window.ForgeUI = (() => {
521
679
  if (state.stage) state.stage.setReplay(state.replay);
522
680
  const run = state.cache.get(state.key)?.data; if (run) { renderRunBody(run); }
523
681
  }
524
- function stopPlay() { if (state.playing) { clearInterval(state.playing); state.playing = null; } }
682
+ function stopPlay() { if (state.playing) { cancelAnimationFrame(state.playing); state.playing = null; } }
683
+ // the replay runs on the display's frames: the stage moves every frame, the panels around it four times a second
525
684
  function togglePlay() {
526
685
  const run = state.cache.get(state.key)?.data; if (!run) return;
527
686
  if (state.playing) { stopPlay(); syncReplay(run); return; }
528
687
  const r = RM.timeRange(run, now()); if (!r) return;
529
688
  if (state.replay === null || state.replay >= r.end) state.replay = r.start;
530
- state.playing = setInterval(() => {
689
+ let last = performance.now(), painted = 0;
690
+ const step = t0 => {
531
691
  const run2 = state.cache.get(state.key)?.data, r2 = run2 && RM.timeRange(run2, now());
532
692
  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);
693
+ const t = Math.min(r2.end, state.replay + state.speed * Math.min(.25, (t0 - last) / 1000)); last = t0;
694
+ state.replay = t;
695
+ if (state.stage && use3d()) state.stage.setReplay(t);
696
+ if (t0 - painted > 250 || t >= r2.end || !use3d()) { painted = t0; renderRunBody(run2); } else syncReplay(run2);
697
+ if (t >= r2.end) { state.playing = null; syncReplay(run2); return; }
698
+ state.playing = requestAnimationFrame(step);
699
+ };
700
+ state.playing = requestAnimationFrame(step);
537
701
  syncReplay(run);
538
702
  }
539
703
  // A copy of the run whose statuses are what they were at time t (for the 2D views during a replay).
@@ -543,60 +707,74 @@ window.ForgeUI = (() => {
543
707
  return { ...run, tasks: list(run.tasks).map(t => ({ ...t, status: s.tasks[t.id] ? s.tasks[t.id].status : t.status })) };
544
708
  }
545
709
  function renderHeader(run) {
546
- const head = $('run-head');
710
+ const head = $('run-head').querySelector('.run-head-body');
547
711
  preserve(head, () => {
548
712
  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);
713
+ // one line: the run's lamp, its goal, where it ran, and the describer
714
+ const row = el('div', undefined, 'run-title-row');
715
+ const lamp = el('i', undefined, 'lamp st-' + LAMP[RM.runState(run)]);
716
+ const title = el('h2', String(run.goal || 'Untitled run').split('\n')[0]); title.title = run.goal || '';
717
+ const where = el('span', undefined, 'run-where'); where.title = `${run.repo || ''}\n${run.run_id || ''}`;
718
+ where.append(el('span', RM.basename(run.repo), 'repo'), document.createTextNode(' '), el('span', run.run_id, 'mono'));
719
+ if (solo) where.append(document.createTextNode(' · solo run'));
720
+ // the describer: each state's count, the wall time so far, and how long since anything moved
721
+ const desc = el('span', undefined, 'describer');
722
+ 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); }
723
+ const r = RM.timeRange(run, now());
724
+ if (r) desc.append(el('span', `WALL ${RM.fmtDur(r.end - r.start)}`, 'dim'));
725
+ const upd = el('span', relative(run.updated).replace(/^updated /, ''), 'dim updated'); upd.title = 'Last activity ' + time(run.updated);
726
+ if (run.updated != null) { upd.dataset.epoch = run.updated; upd.dataset.short = ''; }
727
+ desc.append(upd);
728
+ row.append(lamp, title, where, desc); head.append(row);
572
729
  if (list(run.warnings).length) details(head, `Reader warnings (${run.warnings.length})`, run.warnings.join('\n'), 'warnings');
573
730
  if (list(run.truncated).length) details(head, 'Truncated fields', run.truncated.join('\n'), 'truncated');
574
731
  scoped(head, run.key + ':');
575
732
  });
576
733
  }
734
+ const SEV_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
735
+ const DIFF_RANK = { low: 0, medium: 1, high: 2 };
736
+ function taskColumns(run) {
737
+ const sev = f => f && f.worst ? `${f.worst} ×${f[f.worst]}` : f && f.plausible ? `~${f.plausible}` : '—';
738
+ const busy = task => RM.taskTimes(task, null, now()).busy || null;
739
+ return [
740
+ { id: 'id', label: 'ID', mono: true, value: t => t.id, cell: t => link(t.id, run.key, { kind: 'task', id: t.id }) },
741
+ { id: 'title', label: 'Title', value: t => t.title },
742
+ { id: 'difficulty', label: 'Difficulty', value: t => DIFF_RANK[t.difficulty] ?? null, cell: t => display(t.difficulty) },
743
+ { 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) },
744
+ { id: 'status', label: 'Status', value: t => t.status, cell: t => chip(t.status) },
745
+ { id: 'dwarf', label: 'Dwarf', value: t => t.dwarf, cell: t => window.ForgeIcons.modelPlate(t.dwarf) },
746
+ { id: 'qa', label: 'QA', value: t => t.qa, cell: t => window.ForgeIcons.modelPlate(t.qa) },
747
+ { id: 'attempt', label: 'Attempt', value: t => Number(t.attempt) || null, cell: t => display(t.attempt) },
748
+ { id: 'verdict', label: 'Verdict', value: t => t.verdict, cell: t => chip(t.verdict) },
749
+ { id: 'findings', label: 'Findings', title: 'The worst confirmed severity and how many; ~n plausible only',
750
+ 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,
751
+ 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; } },
752
+ { id: 'busy', label: 'Busy', value: busy, cell: t => RM.fmtDur(busy(t)) },
753
+ { id: 'explored', label: '* Explored / propensity', hidden: true, title: 'Tasks whose model forge drew at random, with the pick\'s propensity',
754
+ value: t => t.explored ? 1 : null,
755
+ 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=—') : '—'; } },
756
+ { id: 'deps', label: 'Deps', hidden: true, value: t => list(t.deps).length || null,
757
+ 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; } },
758
+ { id: 'branch', label: 'Branch', mono: true, hidden: true, value: t => t.branch },
759
+ ];
760
+ }
577
761
  function renderTable(run) {
578
- const host = $('run-tasks');
762
+ const host = $('run-tasks'); if (!host) return;
763
+ const box = host.closest('.tasks-module'), count = box && box.querySelector('.module-count');
764
+ if (count) count.textContent = run.kind === 'solo' ? '' : String(list(run.tasks).length);
765
+ const tools = box && box.querySelector('.module-tools');
766
+ let slot = tools && tools.querySelector('.dt-tools');
767
+ if (tools && !slot) { slot = el('span', undefined, 'dt-tools'); tools.prepend(slot); }
579
768
  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
- });
769
+ if (run.kind === 'solo') { if (slot) slot.replaceChildren(); return; }
770
+ if (!list(run.tasks).length) { if (slot) slot.replaceChildren(); host.append(el('p', 'This run has no tasks yet.', 'empty')); return; }
771
+ Table.render(host, {
772
+ id: 'tasks', label: 'Tasks', columns: taskColumns(run), rows: list(run.tasks), tools: slot, scrollKey: 'Tasks',
773
+ prefs: state.tablePrefs.tasks, savePrefs: p => { state.tablePrefs = { ...state.tablePrefs, tasks: p }; store.set('tables', state.tablePrefs); },
774
+ rowClass: t => state.sel && (state.sel.id === t.id || state.sel.task === t.id) ? 'selected' : '',
775
+ onRow: (t, tr) => tr.addEventListener('click', event => {
776
+ if (!event.target.closest('a, button') && !window.getSelection()?.toString()) select({ kind: 'task', id: t.id });
777
+ }),
600
778
  });
601
779
  scoped(host, run.key + ':');
602
780
  });
@@ -610,6 +788,7 @@ window.ForgeUI = (() => {
610
788
  fetchJSON: path => fetchJSON(path).then(r => r.data),
611
789
  rerender: () => { const r = state.cache.get(state.key)?.data; if (r) renderInspector(r); },
612
790
  hash: s => hashFor(run.key, s), onSelect: s => select(s), diffHash: spec => diffHash(run.key, spec),
791
+ convoHash: s => convoHash(run.key, s),
613
792
  onFractal: (id, data) => { if (state.stage && state.stage.setFractal) state.stage.setFractal(id, data); },
614
793
  });
615
794
  scoped(host, run.key + ':');
@@ -654,6 +833,15 @@ window.ForgeUI = (() => {
654
833
  diffHref: (task, path) => diffHash(run.key, { kind: 'task', task, source: 'current', file: path }),
655
834
  prefs: state.filesPrefs, savePrefs: p => { state.filesPrefs = p; store.set('files', p); } });
656
835
  }
836
+ // every review's findings of the run, for the stage's marks: read again when the run changes
837
+ function syncFindings(run) {
838
+ const key = run.key, c = state.findings.get(key) || {};
839
+ if (c.data && state.stage) state.stage.setFindings(c.data);
840
+ if (c.updated === run.updated || c.pending === run.updated) return;
841
+ c.pending = run.updated; state.findings.set(key, c);
842
+ fetchJSON(`/api/runs/${encodeURIComponent(key)}/findings`).then(r => { c.data = r.data; }, () => {})
843
+ .finally(() => { c.updated = c.pending; c.pending = null; if (state.key === key && state.stage && c.data) state.stage.setFindings(c.data); });
844
+ }
657
845
  function loadFiles(run) {
658
846
  const key = run.key, updated = run.updated, c = state.files.get(key) || {};
659
847
  if (c.pending === updated) return;
@@ -661,14 +849,22 @@ window.ForgeUI = (() => {
661
849
  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
850
  .finally(() => { c.updated = updated; c.pending = null; const cur = state.cache.get(state.key)?.data; if (cur && cur.key === key) renderFiles(cur); });
663
851
  }
664
- // the diff viewer takes the stage's place while it is open
852
+ // the diff viewer, or an execution's conversation, takes the stage's place while it is open
665
853
  function renderDiff(run) {
666
854
  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); },
855
+ const kind = state.convo && window.ForgeConvo ? 'convo' : state.diff && window.ForgeDiff ? 'diff' : null;
856
+ view.classList.toggle('diff-open', !!kind); host.hidden = !kind;
857
+ if (host.dataset.kind !== (kind || '')) { host.replaceChildren(); host.diffSig = ''; host.convoFrame = null; host.dataset.kind = kind || ''; }
858
+ if (!kind) return;
859
+ const rerender = () => { const r = state.cache.get(state.key)?.data; if (r) renderDiff(r); };
860
+ if (kind === 'convo') {
861
+ const sel = state.sel, task = sel.task ? list(run.tasks).find(t => t.id === sel.task) : run.kind === 'solo' ? list(run.tasks)[0] : null;
862
+ const brief = list(task && task.executions).find(e => e.id === state.convo.exec) || null;
863
+ window.ForgeConvo.render(host, { run, spec: { task: task ? task.id : null, exec: state.convo.exec }, brief, live: !SNAP,
864
+ fetchJSON: path => fetchJSON(path).then(r => r.data), rerender, diffHash: spec => diffHash(run.key, spec), closeHref: hashFor(run.key, state.sel) });
865
+ return;
866
+ }
867
+ window.ForgeDiff.render(host, { run, spec: state.diff, fetchJSON: path => fetchJSON(path).then(r => r.data), rerender,
672
868
  href: spec => diffHash(run.key, spec), closeHref: hashFor(run.key, state.sel) });
673
869
  }
674
870
  function renderRunBody(run) {
@@ -676,7 +872,7 @@ window.ForgeUI = (() => {
676
872
  syncToggles();
677
873
  syncReplay(run);
678
874
  placeStage($('stage-slot'));
679
- if (use3d() && state.stage) state.stage.showRun(run, { mode: state.stageMode, sel: state.sel });
875
+ if (use3d() && state.stage) { state.stage.showRun(run, { mode: state.stageMode, sel: state.sel }); syncFindings(run); }
680
876
  render2d(run);
681
877
  renderStrip(run);
682
878
  renderFiles(run);
@@ -687,24 +883,32 @@ window.ForgeUI = (() => {
687
883
  if (main.dataset.view !== 'run' || main.dataset.run !== run.key) {
688
884
  stopPlay(); state.replay = null;
689
885
  if (state.stage) state.stage.setReplay(null);
690
- main.dataset.view = 'run'; main.dataset.run = run.key;
886
+ setView(main, 'run'); main.dataset.run = run.key;
691
887
  main.replaceChildren();
692
- const head = el('section', undefined, 'run-header'); head.id = 'run-head'; main.append(head);
693
888
  const view = el('section', undefined, 'run-view'); view.id = 'run-view'; main.append(view);
694
- toolbar(view);
889
+ const head = el('section', undefined, 'run-header'); head.id = 'run-head'; head.dataset.overlay = '';
890
+ head.append(el('div', undefined, 'run-head-body')); view.append(head);
891
+ toolbar(head);
892
+ // what floats below the header (the 2D views) starts under it, however many rows it wraps to
893
+ if (window.ResizeObserver) new ResizeObserver(() => document.body.style.setProperty('--head-h', head.offsetHeight + 'px')).observe(head);
695
894
  const row = el('div', undefined, 'stage-row');
696
895
  const col = el('div', undefined, 'stage-col');
697
896
  const slot = el('div', undefined, 'stage-slot'); slot.id = 'stage-slot';
698
897
  const g2 = el('div', undefined, 'graph'); g2.id = 'graph2d'; g2.dataset.scroll = 'graph';
699
898
  const files = el('div', undefined, 'files-view'); files.id = 'files-view'; files.hidden = true;
700
899
  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);
900
+ const module = el('aside', undefined, 'module insp-module'); module.setAttribute('aria-label', 'Inspector'); module.dataset.overlay = '';
901
+ const insp = el('div', undefined, 'inspector'); insp.id = 'inspector'; insp.tabIndex = -1;
902
+ module.append(Shell.head('insp', 'Inspector'), insp, Shell.splitter('insp'));
903
+ row.append(col, module); view.append(row);
703
904
  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);
905
+ const diff = el('section', undefined, 'diff-view'); diff.id = 'diff-view'; diff.hidden = true; diff.tabIndex = -1; view.append(diff);
906
+ const box = el('section', undefined, 'module tasks-module'); box.setAttribute('aria-label', 'Tasks'); box.dataset.overlay = '';
907
+ const tasks = el('div', undefined, 'run-tasks'); tasks.id = 'run-tasks';
908
+ box.append(Shell.head('tasks', 'Tasks', ''), tasks, Shell.splitter('tasks'));
909
+ view.append(box);
707
910
  }
911
+ document.body.classList.toggle('no-tasks', run.kind === 'solo');
708
912
  ensureStage();
709
913
  renderHeader(run);
710
914
  renderRunBody(run);
@@ -713,8 +917,11 @@ window.ForgeUI = (() => {
713
917
  }
714
918
  function renderMain() {
715
919
  document.body.classList.toggle('cloud-mode', !!state.cloud);
920
+ if (!state.key) document.body.classList.remove('no-tasks');
716
921
  if (state.cloud) { renderCloud(); return; }
717
922
  if (state.models) { renderModels(); return; }
923
+ if (state.settings) { renderSettings(); return; }
924
+ if (state.key && state.findingsPage) { renderFindings(); return; }
718
925
  if (!state.key) { renderMap(); return; }
719
926
  const cached = state.cache.get(state.key);
720
927
  if (cached) renderRun(cached.data);
@@ -765,22 +972,23 @@ window.ForgeUI = (() => {
765
972
  return request('/api/runs', result => {
766
973
  if (result.text === state.listText) return;
767
974
  state.runs = list(result.data.runs);
768
- state.listText = result.text;
975
+ state.listText = result.text; state.listLoaded = true;
769
976
  if (window.ForgeNotify) window.ForgeNotify.observe(state.runs);
770
977
  renderList();
771
978
  if (state.cloud) renderCloud();
772
- else if (!state.key) renderMap({ rebuildBar: $('map-filters') ? !$('map-filters').contains(document.activeElement) : true });
979
+ else if (state.settings) state.settingsView?.poll(); // the picker follows the run list; the page stays
980
+ else if (!state.key && !state.models) renderMap({ rebuildBar: $('map-filters') ? !$('map-filters').contains(document.activeElement) : true });
773
981
  }, 'Could not refresh runs');
774
982
  }
775
983
  function loadRun(key) {
776
984
  return request('/api/runs/' + encodeURIComponent(key), result => {
777
985
  const changed = state.cache.get(key)?.text !== result.text;
778
986
  state.cache.set(key, result);
779
- if (state.key === key && changed) renderRun(result.data);
987
+ if (state.key === key && changed && !state.findingsPage) renderRun(result.data);
780
988
  }, 'Could not refresh run', error => {
781
- if (state.key !== key || state.cache.has(key)) return;
989
+ if (state.key !== key || state.cache.has(key) || state.findingsPage) return;
782
990
  if (error.status === 404) state.errors.delete('/api/runs/' + encodeURIComponent(key));
783
- $('main').dataset.view = 'waiting';
991
+ setView($('main'), 'waiting');
784
992
  $('main').replaceChildren(el('p', error.status === 404 ?
785
993
  'Waiting for this run to appear… checking again while this tab is visible. If its directory was removed, pick another run.' :
786
994
  'Could not load this run. Select another run, or leave refresh enabled to retry.', 'empty'));
@@ -793,36 +1001,47 @@ window.ForgeUI = (() => {
793
1001
  history.replaceState(null, '', '#/runs/' + encodeURIComponent(SNAP.meta.key));
794
1002
  parsed = parseHash(location.hash);
795
1003
  }
796
- const keyChanged = parsed.key !== state.key;
1004
+ const keyChanged = parsed.key !== state.key || (!!state.findingsPage && !parsed.findings); // the findings page replaced the run's DOM
797
1005
  state.key = parsed.key;
1006
+ const selWas = JSON.stringify(state.sel || null);
798
1007
  state.sel = parsed.sel;
799
- const diffWas = JSON.stringify(state.diff || null);
1008
+ // a selection is read in the inspector, so choosing one opens it
1009
+ if (state.sel && JSON.stringify(state.sel) !== selWas && document.body.dataset.insp !== 'open') Shell.toggle('insp', true);
1010
+ const diffWas = JSON.stringify(state.diff || null), convoWas = JSON.stringify(state.convo || null);
800
1011
  state.diff = parsed.diff || null;
1012
+ state.convo = parsed.convo || null;
801
1013
  state.cloud = parsed.cloud || null;
802
1014
  state.models = !!parsed.models;
1015
+ state.settings = !!parsed.settings && !!window.ForgeSettings && !SNAP;
1016
+ if (parsed.key) state.lastKey = parsed.key;
1017
+ state.findingsPage = !!parsed.findings && !!window.ForgeFindings;
803
1018
  if (state.cloud && state.cloud.model) { // a model's runs: the cloud, filtered to it
804
1019
  cloudFilter({ models: { aliases: [state.cloud.model], role: 'any' } });
805
1020
  history.replaceState(null, '', '#/cloud'); state.cloud.model = null;
806
1021
  }
807
1022
  document.body.classList.toggle('cloud-mode', !!state.cloud);
1023
+ if (!state.key) document.body.classList.remove('no-tasks');
808
1024
  for (const a of document.querySelectorAll('.topbar .top-link')) {
809
- const here = a.getAttribute('href') === (state.cloud ? '#/cloud' : state.models ? '#/models' : !state.key ? '#/' : null);
1025
+ const here = a.getAttribute('href') === (state.cloud ? '#/cloud' : state.models ? '#/models' : state.settings ? '#/settings' : !state.key ? '#/' : null);
810
1026
  if (here) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
811
1027
  }
812
1028
  renderErrors();
813
1029
  markSelection();
814
1030
  if (state.cloud) { renderCloud(); return; }
815
1031
  if (state.models) { renderModels(); loadModels(); return; }
1032
+ if (state.settings) { renderSettings(); return; }
1033
+ if (state.key && state.findingsPage) { renderFindings(); return; }
816
1034
  if (!state.key) { renderMap(); return; }
817
1035
  const cached = state.cache.get(state.key);
818
1036
  if (cached) {
819
1037
  if (keyChanged) renderRun(cached.data);
820
1038
  else { renderRunBody(cached.data); renderTable(cached.data); }
821
- } else { $('main').dataset.view = 'loading'; $('main').replaceChildren(el('p', 'Loading run…', 'empty')); }
1039
+ } else { setView($('main'), 'loading'); $('main').replaceChildren(el('p', 'Loading run…', 'empty')); }
822
1040
  loadRun(state.key);
823
1041
  // 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 });
1042
+ if (state.diff || state.convo) {
1043
+ const opened = state.convo ? convoWas !== JSON.stringify(state.convo) :
1044
+ diffWas === 'null' || JSON.stringify({ ...JSON.parse(diffWas), file: null }) !== JSON.stringify({ ...state.diff, file: null });
826
1045
  if (opened && $('diff-view')) { $('run-view').scrollIntoView({ block: 'start' }); $('diff-view').focus({ preventScroll: true }); }
827
1046
  } else if (state.sel && !keyChanged && !stageHost.contains(document.activeElement)) $('inspector')?.focus({ preventScroll: true });
828
1047
  }
@@ -840,7 +1059,8 @@ window.ForgeUI = (() => {
840
1059
  loadList();
841
1060
  if (state.key) loadRun(state.key);
842
1061
  if (state.models) loadModels();
843
- for (const node of document.querySelectorAll('[data-epoch]')) node.textContent = relative(node.dataset.epoch);
1062
+ if (state.settings) state.settingsView?.poll();
1063
+ 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
1064
  }
845
1065
  // Poll faster while something runs, slower otherwise; never with refresh off, and a hidden tab only to notify.
846
1066
  function schedule() {
@@ -855,12 +1075,14 @@ window.ForgeUI = (() => {
855
1075
  { id: 'cmd:cloud', label: 'Open the run cloud', sub: 'every run in 3D, grouped by project', ids: ['cloud'], href: '#/cloud' },
856
1076
  { id: 'cmd:map', label: 'Open the map of all runs', sub: 'the front page', ids: ['map', 'home'], href: '#/' },
857
1077
  { id: 'cmd:models', label: 'Open the model leaderboard', sub: 'pass rates, retries, time and tokens per model', ids: ['models', 'leaderboard'], href: '#/models' },
1078
+ ...(window.ForgeSettings ? [{ id: 'cmd:settings', label: 'Open the settings', sub: 'model registry, Jev, and the .forge files of a repository', ids: ['settings', 'config', 'verify', 'jev'], href: '#/settings' }] : []),
858
1079
  { id: 'cmd:notify', label: 'Notifications', sub: 'what finished or failed, and browser notifications', ids: ['alerts', 'bell'], action: () => window.ForgeNotify && window.ForgeNotify.open() },
859
1080
  { id: 'cmd:dim', label: state.dim === '3d' ? 'Switch to 2D' : 'Switch to 3D', sub: state.dim === '3d' ? 'graphs and charts, no WebGL' : 'loads three.js from cdn.jsdelivr.net', ids: ['2d', '3d'],
860
1081
  action: () => { state.dim = state.dim === '3d' ? '2d' : '3d'; store.set('view', state.dim); if (state.dim === '3d') state.stageFailed = null; renderMain(); } },
861
1082
  { id: 'cmd:refresh', label: state.paused ? 'Resume live refresh' : 'Pause live refresh', sub: 'polling of the runs', ids: ['refresh', 'pause'],
862
1083
  action: () => { $('refresh').checked = state.paused; state.paused = !state.paused; refresh(); } },
863
1084
  ];
1085
+ if (state.key && !SNAP && window.ForgeFindings) out.push({ id: 'cmd:findings', label: 'Open findings of this run…', sub: 'dismiss them or file them as GitHub issues', ids: ['findings', 'issues', 'dismiss'], href: '#/runs/' + encodeURIComponent(state.key) + '/findings' });
864
1086
  if (run && !SNAP) out.push({ id: 'cmd:export', label: 'Export this run as one HTML file…', sub: 'to share; opens without forge-ui', ids: ['export', 'share'], action: () => exportDialog(run) });
865
1087
  if (SNAP) return out.filter(c => c.id === 'cmd:dim').concat(run ? [{ id: 'cmd:mode', label: state.mode === 'plan' ? 'Show the execution' : 'Show the planned waves', sub: '', ids: ['exec', 'plan'], action: () => setMode(state.mode === 'plan' ? 'exec' : 'plan') }] : []);
866
1088
  if (run && window.ForgeFiles) out.push({ id: 'cmd:files', label: 'Show which tasks touched which files', sub: 'the Files view of this run', ids: ['files', 'heatmap'], action: () => setMode('files') });
@@ -911,8 +1133,10 @@ window.ForgeUI = (() => {
911
1133
  if (window.ForgeSearch) window.ForgeSearch.init({ runs: () => state.runs, commands, go: href => { location.hash = href; }, cloudFilter });
912
1134
  if (window.ForgeNotify) window.ForgeNotify.init({ go: href => { location.hash = href; } });
913
1135
  $('filter').addEventListener('input', event => { state.mapFilter.text = event.target.value; filterChanged(true); });
1136
+ $('run-sort').addEventListener('change', event => { state.runSort = event.target.value; store.set('run-sort', state.runSort); renderList(); });
914
1137
  $('refresh').addEventListener('change', event => { state.paused = !event.target.checked; refresh(); });
915
1138
  window.addEventListener('hashchange', navigate);
1139
+ if (Shell) Shell.onChange(() => { if (state.stage && !state.cloud) requestAnimationFrame(() => state.stage.reframe && state.stage.reframe()); });
916
1140
  document.addEventListener('visibilitychange', refresh);
917
1141
  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
1142
  loadList();
@@ -922,3 +1146,6 @@ window.ForgeUI = (() => {
922
1146
  init();
923
1147
  return { refresh, state };
924
1148
  })();
1149
+ // window.ForgeUI is uikit.js's shared helpers; the page's own handles (state, refresh) join it, where the browser
1150
+ // tests and ui/tools/readme_media.py read them.
1151
+ window.ForgeUI = Object.assign(window.ForgeUI || {}, window.ForgeApp);