cornfield 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/config.js CHANGED
@@ -152,7 +152,7 @@ export const DEFAULTS = Object.freeze({
152
152
  personas: { roles: null, parent: 'root' },
153
153
  cloud: { parent: 'root' },
154
154
  releaseNotes: { breakingLabel: 'breaking' },
155
- agents: { authors: [], labels: [], branchPrefixes: [], bots: true, transport: 'forge', maxOpen: DEFAULT_MAX_OPEN, profiles: [], runners: [], quietAfter: DEFAULT_QUIET_AFTER, limits: {}, on: {} },
155
+ agents: { authors: [], labels: [], branchPrefixes: [], bots: true, transport: 'forge', maxOpen: DEFAULT_MAX_OPEN, profiles: [], runners: [], quietAfter: DEFAULT_QUIET_AFTER, limits: {}, on: {}, refuseCollisions: false },
156
156
  hooks: [],
157
157
  hookPoll: { specs: '5m' },
158
158
  threads: { label: 'idea' },
@@ -165,7 +165,7 @@ export const DEFAULTS = Object.freeze({
165
165
  const KEYS = Object.keys(DEFAULTS);
166
166
  /** The keys of `proposals` when it is an object: the folder, and whether an approval also lands the spec PR (opt-in, off unless true) */
167
167
  export const PROPOSALS_KEYS = Object.freeze(['dir', 'approveLands']);
168
- const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots', 'transport', 'maxOpen', 'profiles', 'runners', 'quietAfter', 'limits', 'on'];
168
+ const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots', 'transport', 'maxOpen', 'profiles', 'runners', 'quietAfter', 'limits', 'on', 'refuseCollisions'];
169
169
  const PROFILE_NAME = new RegExp(PROFILE_SOURCE);
170
170
  const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
171
171
  const isText = (v) => typeof v === 'string' && v.trim() !== '';
@@ -363,6 +363,7 @@ export const triggersOf = (config) => Object.entries(isObject(config.agents?.on)
363
363
  function agentRunProblems(a) {
364
364
  const out = [];
365
365
  if (a.transport !== undefined && !AGENT_TRANSPORTS.includes(a.transport)) out.push(`agents.transport is ${AGENT_TRANSPORTS.map((t) => JSON.stringify(t)).join(', ')}: where a request goes: "forge" posts it as a comment (the default), "push" sends it as an agent.requested hook event, "pull" keeps it for a runner to claim (docs/agents.md#transports).`);
366
+ if (a.refuseCollisions !== undefined && typeof a.refuseCollisions !== 'boolean') out.push('agents.refuseCollisions is true or false: whether a new request that names a node an open run is already changing is refused (false unless set).');
366
367
  if (a.maxOpen !== undefined && !(Number.isInteger(a.maxOpen) && a.maxOpen >= 1 && a.maxOpen <= MAX_OPEN_CEILING)) out.push(`agents.maxOpen is a whole number from 1 to ${MAX_OPEN_CEILING}: the most open runs cornfield asks for at once (${DEFAULT_MAX_OPEN} unless set).`);
367
368
  if (a.profiles !== undefined) {
368
369
  if (!Array.isArray(a.profiles) || a.profiles.length > MAX_PROFILES || !a.profiles.every((p) => typeof p === 'string' && PROFILE_NAME.test(p)) || new Set(a.profiles).size !== a.profiles.length) {
@@ -796,11 +796,13 @@ button.hx-proposal-item:focus-visible { outline: 2px solid var(--hx-indigo); out
796
796
  .hx-forge * { touch-action: pan-y; }
797
797
  .hx-forge.forge-open { pointer-events: auto; }
798
798
  @media (min-width: 768px) {
799
- .hx-forge { position: absolute; top: 0; left: 0; bottom: 0; width: min(400px, calc(100% - 24px)); border-left: 0; transform: translateX(-100%); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); }
799
+ /* The panel's width is the stage's `--hx-forge-w`, so what makes room for it reads the same number (the Runs view's compare widens it, below) */
800
+ .hx-stage { --hx-forge-w: min(400px, calc(100% - 24px)); }
801
+ .hx-forge { position: absolute; top: 0; left: 0; bottom: 0; width: var(--hx-forge-w); border-left: 0; transform: translateX(-100%); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); }
800
802
  .hx-forge.forge-open { transform: translateX(0); }
801
- /* The proposal bar and the player make room for it */
802
- .hx-stage:has(.forge-open) .hx-proposal-bar { left: 412px; width: min(720px, calc(100% - 436px)); }
803
- .hx-stage:has(.forge-open):has(.drawer-open) .hx-proposal-bar { width: min(720px, calc(100% - 940px)); }
803
+ /* The proposal bar and the player make room for it: 12px past its edge, and the node panel's 480px (+24) when that is open too */
804
+ .hx-stage:has(.forge-open) .hx-proposal-bar { left: calc(var(--hx-forge-w) + 12px); width: min(720px, calc(100% - var(--hx-forge-w) - 36px)); }
805
+ .hx-stage:has(.forge-open):has(.drawer-open) .hx-proposal-bar { width: min(720px, calc(100% - var(--hx-forge-w) - 540px)); }
804
806
  }
805
807
  @media (max-width: 767px) {
806
808
  .hx-forge:not(.forge-open) { display: none; }
@@ -924,3 +926,46 @@ a.hx-forge-row:hover, button.hx-forge-row:hover { background: var(--hx-control-h
924
926
  .hx-thread-compose { font-family: inherit; min-height: 8em; }
925
927
  .hx-thread-open { display: block; max-width: 100%; text-align: left; overflow-wrap: anywhere; }
926
928
  #forge-idea[hidden], #idea-form[hidden], #idea-message[hidden] { display: none; }
929
+
930
+ /* ---- agent runs on the map (explorer/runs-map.js, issue #82) ---- */
931
+ .hx-run-overlay { position: absolute; inset: 0; z-index: 2; overflow: hidden; pointer-events: none; }
932
+ .hx-run-overlay[hidden] { display: none; }
933
+ .hx-run-mark { position: absolute; width: 0; height: 0; --r: 14px; }
934
+ .hx-run-ring { position: absolute; left: calc(var(--r) * -1); top: calc(var(--r) * -1); width: calc(var(--r) * 2); height: calc(var(--r) * 2); box-sizing: border-box; border-radius: 50%; border: 2px solid var(--hx-teal-text); }
935
+ /* In flight: the ring pulses outward and fades; when motion is reduced it is a still, slightly thicker ring */
936
+ .hx-run-mark--flight .hx-run-ring { animation: hx-run-pulse 1.8s ease-out infinite; }
937
+ @keyframes hx-run-pulse { 0% { transform: scale(0.9); opacity: 1; } 100% { transform: scale(1.55); opacity: 0.1; } }
938
+ .hx-run-overlay.is-static .hx-run-ring { animation: none; transform: none; opacity: 1; border-width: 3px; }
939
+ @media (prefers-reduced-motion: reduce) { .hx-run-ring { animation: none !important; transform: none; opacity: 1; border-width: 3px; } }
940
+ /* Collision: dashed, a sign and words; the same ring whether or not colour is seen */
941
+ .hx-run-mark--collision .hx-run-ring { border: 3px dashed var(--hx-amber); animation: none; }
942
+ .hx-run-tag { position: absolute; left: 0; top: calc(var(--r) + 4px); transform: translateX(-50%); white-space: nowrap; padding: 1px 7px; border-radius: 9999px; border: 1px solid var(--hx-amber); background: var(--hx-well); color: var(--hx-amber); font-size: 11px; font-weight: 700; }
943
+ .hx-run-swatch { display: inline-block; width: 14px; height: 14px; flex: none; box-sizing: border-box; border-radius: 50%; border: 2px solid var(--hx-teal-text); }
944
+ .hx-run-swatch--collision { border: 2px dashed var(--hx-amber); }
945
+
946
+ /* The Runs view (forge-panel.js, runs-view.js): each run, its limits beside what was reported, its timeline; the compare puts profiles side by side */
947
+ .hx-runs-list { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
948
+ .hx-run { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border: 1px solid var(--hx-line); border-radius: 10px; background: var(--hx-control); overflow-wrap: anywhere; min-width: 0; }
949
+ .hx-run-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; }
950
+ .hx-run-actions { display: flex; flex-wrap: wrap; gap: 6px; }
951
+ .hx-run-collide { margin: 0; padding: 2px 8px; border: 1px dashed var(--hx-amber); border-radius: 8px; color: var(--hx-amber); font-weight: 600; }
952
+ .hx-run-limits { display: grid; grid-template-columns: auto 1fr; gap: 0 10px; margin: 0; font-size: 11px; }
953
+ .hx-run-limits dt { color: var(--hx-slate-300); font-weight: 600; }
954
+ .hx-run-limits dd { margin: 0; color: var(--hx-text-2); }
955
+ .hx-run-timeline { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0 0 0 18px; font-size: 11px; }
956
+ .hx-run-timeline li { color: var(--hx-text-2); }
957
+ .hx-run-timeline time { color: var(--hx-slate-300); margin-right: 6px; }
958
+ .hx-run-note { display: block; color: var(--hx-slate-300); white-space: pre-wrap; }
959
+ .hx-run-compare { border: 1px solid var(--hx-line); border-left: 4px solid var(--hx-teal-strong); border-radius: 10px; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
960
+ .hx-run-compare-title { margin: 0; font-size: 12px; font-weight: 700; color: var(--hx-text-1); }
961
+ .hx-run-columns { display: grid; grid-template-columns: 1fr; gap: 8px; margin: 0; padding: 0; list-style: none; }
962
+ @media (min-width: 1100px) {
963
+ .hx-stage:has(.hx-forge .hx-run-compare) { --hx-forge-w: min(780px, calc(100% - 24px)); }
964
+ .hx-run-columns { grid-template-columns: repeat(2, minmax(0, 1fr)); }
965
+ }
966
+ /* With the node panel open as well, a 780px compare only fits beside it from 1600px: below that the panel keeps its width and the
967
+ profiles stack, so neither panel nor the proposal bar between them is covered */
968
+ @media (min-width: 1100px) and (max-width: 1599px) {
969
+ .hx-stage:has(.hx-forge .hx-run-compare):has(.drawer-open) { --hx-forge-w: min(400px, calc(100% - 24px)); }
970
+ .hx-stage:has(.drawer-open) .hx-run-columns { grid-template-columns: 1fr; }
971
+ }
@@ -69,8 +69,10 @@ import { relativeTime } from './review-model.js';
69
69
  import { LAYER_DEFS, toggleLayer } from './layers.js';
70
70
  import { WORK_URL, groupWork, filterGroups, groupWords, workStatus, markWords } from './agents-model.js';
71
71
  import {
72
- RUNS_URL, runsUrl, stopUrl, KINDS, aboutWords, statusWords, statusWhy, runMeta, canStop, runsStatus, triggerNotice, startBody, startWords, stopWords, confirmStartWords,
72
+ RUNS_URL, runsUrl, stopUrl, KINDS, aboutWords, statusWords, statusWhy, runMeta, canStop, runsStatus, triggerNotice, startBody, startWords, stopWords, confirmStartWords, litOfRun, changeWords,
73
73
  } from './runs-model.js';
74
+ import { runsHtml } from './runs-view.js';
75
+ import { setRunsOnMap, clearRunsOnMap } from './runs-map.js';
74
76
  import {
75
77
  STATES, defaultFilters, listUrl, pullUrl, serverState, filterItems, facetsOf, stateWords, metaWords, checkWords, checksWords, litFor, errorWords,
76
78
  landingUrl, cancelLandingUrl, landingWords, mergeBlock, rerunWhy, confirmMerge, mergeBody, actionUrl, actionWords,
@@ -116,6 +118,8 @@ const ui = {
116
118
  notes: freshNotes(), // the Release notes view
117
119
  runs: null, // { data, error } of the Agents tab's runs: what /api/agent-runs answered
118
120
  runNote: null, // { text, ok }: what the last Stop came to
121
+ runSel: null, // the run lit on the map (Light on the map), by id
122
+ runTimelines: new Set(), // the runs whose timeline is open
119
123
  agent: null, // { opener, refs, posting } while the Start an agent dialog is open
120
124
  };
121
125
 
@@ -130,6 +134,9 @@ function freshIdea() {
130
134
  }
131
135
 
132
136
  let wired = false;
137
+ let runsTimer = 0;
138
+ /** How often the runs are read again while one is open */
139
+ export const RUNS_POLL_MS = 60_000;
133
140
  let threadToken = 0;
134
141
  let indexToken = 0;
135
142
  let indexLoading = false;
@@ -188,11 +195,46 @@ async function loadWork(refresh, token) {
188
195
  }
189
196
 
190
197
  /** Loads the runs for the Agents tab (a refresh also has cornfield look at the forge for their reports first); a failure is a line, never a blank panel */
191
- async function loadRuns(refresh) {
198
+ async function loadRuns(refresh, { say = true } = {}) {
199
+ const before = ui.runs && ui.runs.data ? ui.runs.data.runs : null;
192
200
  const res = await request(runsUrl(refresh), refresh);
193
201
  ui.runs = res.ok && Array.isArray(res.body.runs) ? { data: res.body, error: '' } : { data: ui.runs && ui.runs.data, error: errorWords(res.status, res.body) };
202
+ if (!(ui.runs && ui.runs.data)) return;
203
+ // The map shows the open runs' nodes; a run lit on the map follows its nodes; what changed is said, once the first answer is in
204
+ setRunsOnMap(ui.runs.data.runs);
205
+ syncRunsPoll();
206
+ if (ui.runSel) relightRun();
207
+ const said = say ? changeWords(before, ui.runs.data.runs) : [];
208
+ if (said.length) announce(said.join('. '));
194
209
  }
195
210
 
211
+ /**
212
+ * Looks at the runs again in `RUNS_POLL_MS`, but only while one is open: a run's answer that has none open disarms it, and the next answer
213
+ * with one open (a run requested here, or a look from elsewhere) arms it again. One timer at most; never while the page is hidden or put
214
+ * away (`pagehide`). So the marks on the map and the words follow the runs.
215
+ */
216
+ function syncRunsPoll() {
217
+ const open = !!(ui.runs && ui.runs.data && ui.runs.data.open > 0);
218
+ if (!open || pageAway) {
219
+ clearTimeout(runsTimer);
220
+ runsTimer = 0;
221
+ return;
222
+ }
223
+ if (runsTimer) return;
224
+ runsTimer = setTimeout(async () => {
225
+ runsTimer = 0;
226
+ if (!(typeof document !== 'undefined' && document.hidden)) {
227
+ await loadRuns(false);
228
+ paint();
229
+ }
230
+ syncRunsPoll();
231
+ }, RUNS_POLL_MS);
232
+ }
233
+ /** The page is being put away (closed, or into the back-forward cache): no look until it is shown again */
234
+ let pageAway = false;
235
+ function onPageHide() { pageAway = true; syncRunsPoll(); }
236
+ function onPageShow() { pageAway = false; syncRunsPoll(); }
237
+
196
238
  /** Loads the list the kind and state name (again, past the server's cache, on a refresh) */
197
239
  async function loadList(refresh = false, quiet = false) {
198
240
  const token = ++listToken;
@@ -293,7 +335,10 @@ export function clearPull() {
293
335
  /** What the selected pull request lights, worked out again: for the nodes now on the map */
294
336
  function relight() {
295
337
  ui.nodesSeen = state.nodes;
296
- if (ui.threadDetail) {
338
+ if (ui.runSel) {
339
+ const run = runById(ui.runSel);
340
+ state.forgeLit = run ? litOfRun(run) : null;
341
+ } else if (ui.threadDetail) {
297
342
  ui.litThread = litForThread(ui.threadDetail.thread, state.nodes);
298
343
  state.forgeLit = { changed: ui.litThread.changed, failing: ui.litThread.failing };
299
344
  } else if (ui.deployItem) {
@@ -359,6 +404,7 @@ export function clearDeploy() {
359
404
 
360
405
  /** Forgets the selected thread without painting: another thing is being selected */
361
406
  function dropThread() {
407
+ ui.runSel = null;
362
408
  threadToken++;
363
409
  ui.thread = null;
364
410
  ui.threadDetail = null;
@@ -370,6 +416,7 @@ function dropThread() {
370
416
  async function selectThread(n) {
371
417
  const token = ++threadToken;
372
418
  pullToken++;
419
+ ui.runSel = null;
373
420
  ui.selected = null;
374
421
  ui.detail = null;
375
422
  ui.detailError = '';
@@ -924,6 +971,8 @@ function findAgain(el) {
924
971
  if (el.dataset.forgeRerun) return `[data-forge-rerun="${el.dataset.forgeRerun}"]`;
925
972
  if (el.dataset.forgeAgent) return `[data-forge-agent="${el.dataset.forgeAgent}"]`;
926
973
  if (el.dataset.forgeStop) return `[data-forge-stop="${el.dataset.forgeStop}"]`;
974
+ if (el.dataset.runSelect) return `[data-run-select="${el.dataset.runSelect}"]`;
975
+ if (el.dataset.runTimeline) return `[data-run-timeline="${el.dataset.runTimeline}"]`;
927
976
  return null;
928
977
  }
929
978
 
@@ -1010,20 +1059,51 @@ function paintRuns() {
1010
1059
  }
1011
1060
  const list = $('forge-runs-list');
1012
1061
  if (!data) { list.innerHTML = ''; return; }
1013
- list.innerHTML = data.runs.length ? data.runs.map(runHtml).join('') : '<li class="hx-forge-empty hx-faint">No agent has been asked for yet. Start one from an issue or a proposal.</li>';
1062
+ list.innerHTML = data.runs.length ? runsHtml(viewContext(), data.runs) : '<li class="hx-forge-empty hx-faint">No agent has been asked for yet. Start one from an issue or a proposal.</li>';
1063
+ }
1064
+
1065
+ /** What the Runs view needs to know of the page */
1066
+ function viewContext() {
1067
+ const f = ui.config && ui.config.forge;
1068
+ const base = f && safeUrl(f.url) ? f.url.replace(/\/+$/, '') : null;
1069
+ return {
1070
+ esc: escHtml, safeUrl, forgeName: forgeName(), forgeType: f ? f.type : 'github', repoUrl: base && f.repo ? `${base}/${f.repo}` : null,
1071
+ now: now(), nodes: state.nodes, selected: ui.runSel, timelines: ui.runTimelines, busy: !!ui.busy,
1072
+ };
1073
+ }
1074
+
1075
+ const runById = (id) => ((ui.runs && ui.runs.data && ui.runs.data.runs) || []).find((r) => r.runId === id) || null;
1076
+
1077
+ /** The nodes of a selected run, worked out again (the runs were read again, or the map changed) */
1078
+ function relightRun() {
1079
+ if (!runById(ui.runSel)) ui.runSel = null;
1080
+ relight();
1081
+ }
1082
+
1083
+ /** Lights the nodes of a run on the map, like a pull request; the same button lets go of it */
1084
+ function selectRun(id) {
1085
+ const run = runById(id);
1086
+ if (!run) return;
1087
+ if (ui.runSel === id) {
1088
+ ui.runSel = null;
1089
+ relight();
1090
+ announce(`Run ${id} no longer lit on the map`);
1091
+ return;
1092
+ }
1093
+ const had = ui.runSel;
1094
+ pullToken++;
1095
+ dropThread();
1096
+ Object.assign(ui, { selected: null, detail: null, detailError: '', lit: null, landing: null, note: null, deploy: null, deployItem: null, litDeploy: null });
1097
+ ui.runSel = id;
1098
+ relight();
1099
+ const n = (run.nodes || []).length;
1100
+ announce(n ? `Run ${id}: ${n} ${n === 1 ? 'node' : 'nodes'} lit on the map${had ? '' : ''}` : `Run ${id} has no node on the map yet`);
1014
1101
  }
1015
1102
 
1016
- function runHtml(run) {
1017
- const links = [
1018
- safeUrl(run.comment && run.comment.url) ? `<a class="hx-link" href="${escHtml(run.comment.url)}" target="_blank" rel="noreferrer">Request on ${escHtml(forgeName())} <span class="hx-sr-only">(new tab)</span></a>` : '',
1019
- run.pull && safeUrl(run.pull.url) ? `<a class="hx-link" href="${escHtml(run.pull.url)}" target="_blank" rel="noreferrer">Pull request #${run.pull.number} <span class="hx-sr-only">(new tab)</span></a>${run.pull.trusted ? '' : ' <span class="hx-faint">(not from a runner, so it moves nothing)</span>'}` : '',
1020
- run.report && safeUrl(run.report.url) ? `<a class="hx-link" href="${escHtml(run.report.url)}" target="_blank" rel="noreferrer">Runner\'s logs <span class="hx-sr-only">(new tab)</span></a>` : '',
1021
- ].filter(Boolean).join(' · ');
1022
- const why = statusWhy(run);
1023
- const note = run.report && run.report.note ? `<p class="hx-forge-meta">Runner: ${escHtml(run.report.note)}</p>` : '';
1024
- const stop = canStop(run) ? `<button type="button" class="hx-btn hx-forge-act" data-forge-stop="${escHtml(run.runId)}" aria-label="Stop run ${escHtml(run.runId)}"${ui.busy ? ' disabled' : ''}>Stop</button>` : '';
1025
- return `<li class="hx-forge-file"><span class="hx-forge-state hx-forge-state--${escHtml(run.status === 'done' || run.status === 'merged' ? 'success' : run.status === 'failed' ? 'failure' : run.status === 'quiet' || run.status === 'stop-requested' ? 'queued' : 'running')}">${escHtml(statusWords(run))}</span> <span class="hx-name">${escHtml(run.kind)} on ${escHtml(aboutWords(run.refs))}</span> ${stop}
1026
- <p class="hx-forge-meta">${escHtml(runMeta(run, now()))}</p>${why ? `<p class="hx-forge-why hx-faint">${escHtml(why)}</p>` : ''}${note}${links ? `<p class="hx-forge-meta">${links}</p>` : ''}</li>`;
1103
+ /** Opens or closes the timeline of a run (it stays open when the list is drawn again) */
1104
+ function toggleTimeline(id) {
1105
+ if (ui.runTimelines.has(id)) ui.runTimelines.delete(id); else ui.runTimelines.add(id);
1106
+ paint();
1027
1107
  }
1028
1108
 
1029
1109
  /** The panel's lines for the Release notes view: the status, a failure, the rate limit; the rest is paintNotes */
@@ -1045,11 +1125,11 @@ function drawNotes() {
1045
1125
  function paintButton() {
1046
1126
  const btn = $('forge-btn');
1047
1127
  if (!btn) return;
1048
- const lit = ui.selected !== null || !!ui.deployItem || ui.thread !== null;
1128
+ const lit = ui.selected !== null || !!ui.deployItem || ui.thread !== null || !!ui.runSel;
1049
1129
  const tag = $('forge-btn-lit');
1050
1130
  tag.hidden = !lit;
1051
- tag.textContent = ui.thread !== null ? `thread #${ui.thread}` : ui.deployItem ? 'deploy' : lit ? `#${ui.selected}` : '';
1052
- btn.setAttribute('aria-label', ui.thread !== null ? `Issues and pull requests, thread ${ui.thread} selected` : ui.deployItem ? 'Issues and pull requests, a deploy selected' : lit ? `Issues and pull requests, pull request ${ui.selected} selected` : 'Issues and pull requests');
1131
+ tag.textContent = ui.runSel ? 'run' : ui.thread !== null ? `thread #${ui.thread}` : ui.deployItem ? 'deploy' : lit ? `#${ui.selected}` : '';
1132
+ btn.setAttribute('aria-label', ui.runSel ? 'Issues and pull requests, a run lit on the map' : ui.thread !== null ? `Issues and pull requests, thread ${ui.thread} selected` : ui.deployItem ? 'Issues and pull requests, a deploy selected' : lit ? `Issues and pull requests, pull request ${ui.selected} selected` : 'Issues and pull requests');
1053
1133
  btn.setAttribute('aria-expanded', String(ui.open));
1054
1134
  }
1055
1135
 
@@ -1380,7 +1460,7 @@ async function stopRun(runId) {
1380
1460
  ui.busy = '';
1381
1461
  ui.runNote = { text: stopWords(res.status, res.body, runId), ok: res.ok };
1382
1462
  announce(ui.runNote.text);
1383
- await loadRuns(false);
1463
+ await loadRuns(false, { say: false });
1384
1464
  paint();
1385
1465
  }
1386
1466
 
@@ -1514,6 +1594,10 @@ function onClick(e) {
1514
1594
  if (agent) { openAgent(agent.dataset.forgeAgent, `[data-forge-agent="${agent.dataset.forgeAgent}"]`); return; }
1515
1595
  const stop = t.closest('[data-forge-stop]');
1516
1596
  if (stop) { stopRun(stop.dataset.forgeStop); return; }
1597
+ const pick = t.closest('[data-run-select]');
1598
+ if (pick) { selectRun(pick.dataset.runSelect); return; }
1599
+ const timeline = t.closest('[data-run-timeline]');
1600
+ if (timeline) { toggleTimeline(timeline.dataset.runTimeline); return; }
1517
1601
  const unland = t.closest('[data-forge-landing-cancel]');
1518
1602
  if (unland) { cancelLanding(Number(unland.dataset.forgeLandingCancel)); return; }
1519
1603
  const merge = t.closest('[data-forge-merge]');
@@ -1610,6 +1694,10 @@ export function initForgePanel(config, opts = {}) {
1610
1694
  registerSheet('forge', panel);
1611
1695
  paintFilters();
1612
1696
  paint();
1697
+ // The map shows the open runs whether or not the panel is open: read them once now, and again now and then while any is open
1698
+ if (config.agents && config.agents.runs) {
1699
+ loadRuns(false).then(() => { if (ui.kind === 'work') paint(); }, () => {});
1700
+ }
1613
1701
  if (!wired) {
1614
1702
  wired = true;
1615
1703
  btn.addEventListener('click', toggleForge);
@@ -1617,6 +1705,10 @@ export function initForgePanel(config, opts = {}) {
1617
1705
  panel.addEventListener('change', onChange);
1618
1706
  panel.addEventListener('input', onInput);
1619
1707
  panel.addEventListener('keydown', onKey);
1708
+ if (typeof window !== 'undefined') {
1709
+ window.addEventListener('pagehide', onPageHide);
1710
+ window.addEventListener('pageshow', onPageShow);
1711
+ }
1620
1712
  const agent = agentEl();
1621
1713
  if (agent) {
1622
1714
  agent.addEventListener('click', onAgentClick);
@@ -1657,5 +1749,9 @@ export function resetForgePanelForTests(clock = () => Date.now()) {
1657
1749
  indexCache = null;
1658
1750
  state.forgeLit = null;
1659
1751
  state.threadIndex = null;
1660
- Object.assign(ui, { thread: null, threadDetail: null, threadError: '', litThread: null, idea: freshIdea(), config: null, open: false, kind: 'pulls', filters: defaultFilters(), lists: {}, work: null, workFilters: { agentsOnly: false, finished: false }, loading: false, error: '', selected: null, detail: null, landing: null, detailError: '', lit: null, deploy: null, deployItem: null, litDeploy: null, nodesSeen: null, busy: '', note: null, confirm: null, actNote: null, notes: freshNotes(), runs: null, runNote: null, agent: null });
1752
+ clearTimeout(runsTimer);
1753
+ runsTimer = 0;
1754
+ pageAway = false;
1755
+ clearRunsOnMap();
1756
+ Object.assign(ui, { runSel: null, runTimelines: new Set(), thread: null, threadDetail: null, threadError: '', litThread: null, idea: freshIdea(), config: null, open: false, kind: 'pulls', filters: defaultFilters(), lists: {}, work: null, workFilters: { agentsOnly: false, finished: false }, loading: false, error: '', selected: null, detail: null, landing: null, detailError: '', lit: null, deploy: null, deployItem: null, litDeploy: null, nodesSeen: null, busy: '', note: null, confirm: null, actNote: null, notes: freshNotes(), runs: null, runNote: null, agent: null });
1661
1757
  }
@@ -48,6 +48,13 @@
48
48
  <div id="flow-legend" hidden></div>
49
49
  <div id="cloud-legend" class="hx-menu-section" hidden></div>
50
50
 
51
+ <!-- What the marks of agent runs mean, while there are runs on the map (runs-map.js) -->
52
+ <div id="runs-legend" class="hx-menu-section" hidden>
53
+ <div class="hx-menu-title"><span>Agent runs</span></div>
54
+ <div class="hx-legend-row"><span class="hx-run-swatch hx-run-swatch--flight" aria-hidden="true"></span><span>In flight: a pulsing ring (still under reduced motion)</span></div>
55
+ <div class="hx-legend-row"><span class="hx-run-swatch hx-run-swatch--collision" aria-hidden="true"></span><span>Collision: dashed ring, ⚠ and the words, where two open runs change one node</span></div>
56
+ </div>
57
+
51
58
  <!-- What a proposal's marks mean, while one is open (proposal.js) -->
52
59
  <div id="proposal-legend" class="hx-menu-section" hidden>
53
60
  <div class="hx-menu-title"><span>Proposal marks</span></div>
@@ -171,6 +178,8 @@
171
178
  <p id="map-announcer" class="hx-sr-only" role="status" aria-live="polite"></p>
172
179
  <!-- Every label, drawn on its own transparent canvas over the map (explorer/labels.js); the map canvas still gets the pointer -->
173
180
  <canvas id="label-canvas" aria-hidden="true"></canvas>
181
+ <!-- The nodes agent runs are changing, marked over the map (explorer/runs-map.js): a picture; the Runs view says the same in words -->
182
+ <div id="run-overlay" class="hx-run-overlay" aria-hidden="true" hidden></div>
174
183
 
175
184
  <!-- Below 768px the proposal bar, the scenario player and the node panel stack here as bottom sheets (sheets.js); from 768px the dock is display: contents -->
176
185
  <div id="sheet-dock" class="hx-dock">
@@ -34,6 +34,7 @@ export const LAYER_DEFS = [
34
34
  { key: 'container', label: 'Containers', color: '#d6d3d1', desc: 'One service of a stack, from its compose file' },
35
35
  { key: 'device', label: 'Devices', color: '#2dd4bf', desc: 'Cameras, and live: phones, NFC tags, Home Assistant devices', live: true },
36
36
  { key: 'house', label: 'House', color: '#a3e635', desc: 'Floors and rooms (live)', live: true },
37
+ { key: 'runs', label: 'Runs in flight', color: '#5eead4', desc: 'A pulsing ring on the nodes an open agent run is changing; a dashed ring and a warning where two runs change the same node', overlay: true },
37
38
  { key: 'persona', label: 'People', color: '#fb7185', desc: 'Personas, with a live count of their accounts (never a name)', live: true },
38
39
  ];
39
40
 
@@ -68,6 +69,7 @@ export function renderLayerToggles() {
68
69
  const counts = {};
69
70
  state.nodes.forEach((n) => { const key = n.type === 'external' ? externalLayer(n) : layerOf(n.type); counts[key] = (counts[key] || 0) + 1; });
70
71
  counts.flow = state.edges.filter((e) => e.kind === 'flow').length;
72
+ counts.runs = state.runsMap ? state.runsMap.flight.size : 0;
71
73
 
72
74
  container.innerHTML = LAYER_DEFS.map((layer) => {
73
75
  const on = state.activeLayers[layer.key] !== false;
@@ -150,6 +150,14 @@ function paint() {
150
150
  // The labels repaint only when the camera, the scene or the size changed, never on a packet frame
151
151
  const labels = getLabelLayer();
152
152
  if (labels) labels.draw(scene, state.camera);
153
+ paintHooks.forEach((fn) => fn(scene, state.camera));
154
+ }
155
+
156
+ /** Things drawn over the map by the page itself (the runs' marks, explorer/runs-map.js): called after each draw with the scene and the camera */
157
+ const paintHooks = new Set();
158
+ export function onPaint(fn) {
159
+ paintHooks.add(fn);
160
+ return () => paintHooks.delete(fn);
153
161
  }
154
162
 
155
163
  const scheduler = createFrameScheduler({
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @module explorer/runs-map
3
+ *
4
+ * Agent runs on the map (issue #82, spec 0005 D9): the nodes an open run is changing **pulse**, and a node two or more open runs are
5
+ * changing carries a **collision** mark. The marks are a layer of their own over the map (`#run-overlay`, a DOM layer, so one drawing serves the
6
+ * Canvas 2D and the WebGL renderer, and the pulse is plain CSS that `prefers-reduced-motion` turns into a still ring):
7
+ *
8
+ * - in flight a ring that pulses (still under reduced motion: a thicker ring, no movement)
9
+ * - collision a dashed ring, a warning sign and the words "Collision" with how many runs: shape and text, never colour alone
10
+ *
11
+ * The layer is a toggle in the Layers menu ("Runs in flight", on unless turned off). The layer is decoration for the eye (`aria-hidden`):
12
+ * what a screen reader gets is the Runs view, which names each run's nodes and collisions in words, and the live region, which says when a
13
+ * state or a collision changes. The positions come from the scene the map just drew, so they follow every view, zoom and pan.
14
+ */
15
+
16
+ import { state } from './state.js';
17
+ import { render, onPaint } from './render.js';
18
+ import { overlayOf } from './runs-model.js';
19
+ import { flowReduceMotion } from './flows.js';
20
+
21
+ /** The most marks drawn at once: past this the Runs view still lists them all */
22
+ export const MAX_MARKS = 80;
23
+
24
+ /** The key of the layer in the Layers menu and `state.activeLayers` */
25
+ export const RUNS_LAYER = 'runs';
26
+
27
+ /** @type {Map<string, HTMLElement>} the marks on the page by `<kind>:<node id>`, kept so a pulse is not restarted by every pan */
28
+ const marks = new Map();
29
+ let wired = false;
30
+ /** What the marks were last placed for: a paint with the same scene, camera, runs, layer and motion moves nothing (packet frames) */
31
+ let placed = '';
32
+ let placedFor = null;
33
+ let placedScene = null;
34
+ let placedLayer = null;
35
+ let reduce = null;
36
+
37
+ /** Whether motion is reduced: the page's setting, or a test's */
38
+ const reducedMotion = () => (reduce === null ? flowReduceMotion : reduce);
39
+ /** For tests: force reduced motion on or off (null: follow the page's setting) */
40
+ export function setReducedMotionForTests(on) { reduce = on; }
41
+
42
+ /** Whether the layer is on */
43
+ export const runsLayerOn = () => state.activeLayers[RUNS_LAYER] !== false;
44
+
45
+ /** Hands the map the runs the API listed: what pulses and what collides is worked out here, and the map is drawn again */
46
+ export function setRunsOnMap(runs) {
47
+ state.runsMap = Array.isArray(runs) && runs.length ? { ...overlayOf(runs), runs } : null;
48
+ wire();
49
+ paintLegend();
50
+ render();
51
+ }
52
+
53
+ /** Takes the marks off (the panel is reset, or there is no forge) */
54
+ export function clearRunsOnMap() {
55
+ state.runsMap = null;
56
+ clearMarks();
57
+ paintLegend();
58
+ }
59
+
60
+ function wire() {
61
+ if (wired) return;
62
+ wired = true;
63
+ onPaint((scene, camera) => syncRunOverlay(scene, camera));
64
+ }
65
+
66
+ function clearMarks() {
67
+ for (const el of marks.values()) el.remove();
68
+ marks.clear();
69
+ placed = '';
70
+ }
71
+
72
+ /** The words on a collision mark */
73
+ export const collisionTag = (n) => `Collision · ${n} runs`;
74
+
75
+ /**
76
+ * Puts the marks on the nodes the scene drew, at the camera. Marks are kept between frames (only their place changes), so a pan does not
77
+ * restart the pulse.
78
+ * @param {{ nodes: { id: string, x: number, y: number, r: number, alpha: number }[] }} scene
79
+ * @param {{ x: number, y: number, zoom: number }} camera
80
+ */
81
+ export function syncRunOverlay(scene, camera) {
82
+ const layer = typeof document !== 'undefined' ? document.getElementById('run-overlay') : null;
83
+ if (!layer) return;
84
+ const map = state.runsMap;
85
+ if (!map || !runsLayerOn() || (!map.flight.size && !map.collide.size)) { if (marks.size) clearMarks(); layer.hidden = true; placed = ''; return; }
86
+ // The map paints every frame while packets move; the marks only move with the scene or the camera
87
+ const key = `${scene.version}|${camera.x}|${camera.y}|${camera.zoom}|${reducedMotion()}`;
88
+ if (key === placed && placedFor === map && placedScene === scene && placedLayer === layer) return;
89
+ placed = key;
90
+ placedLayer = layer;
91
+ placedFor = map;
92
+ placedScene = scene;
93
+ layer.hidden = false;
94
+ layer.classList.toggle('is-static', reducedMotion());
95
+ const seen = new Set();
96
+ let n = 0;
97
+ for (const node of scene.nodes) {
98
+ if (n >= MAX_MARKS) break;
99
+ const runs = map.collide.get(node.id) || map.flight.get(node.id);
100
+ if (!runs || !(node.alpha > 0.2)) continue;
101
+ const kind = map.collide.has(node.id) ? 'collision' : 'flight';
102
+ const key = `${kind}:${node.id}`;
103
+ n += 1;
104
+ seen.add(key);
105
+ let el = marks.get(key);
106
+ if (!el) {
107
+ el = document.createElement('span');
108
+ el.className = `hx-run-mark hx-run-mark--${kind}`;
109
+ el.dataset.node = node.id;
110
+ el.dataset.kind = kind;
111
+ el.innerHTML = `<span class="hx-run-ring"></span>${kind === 'collision' ? `<span class="hx-run-tag"><span class="hx-run-sign">⚠</span> ${collisionTag(runs.length)}</span>` : ''}`;
112
+ layer.append(el);
113
+ marks.set(key, el);
114
+ }
115
+ const r = Math.max(10, (node.r + 9) * camera.zoom);
116
+ el.style.left = `${(node.x * camera.zoom + camera.x).toFixed(1)}px`;
117
+ el.style.top = `${(node.y * camera.zoom + camera.y).toFixed(1)}px`;
118
+ el.style.setProperty('--r', `${r.toFixed(1)}px`);
119
+ }
120
+ for (const [key, el] of marks) if (!seen.has(key)) { el.remove(); marks.delete(key); }
121
+ }
122
+
123
+ /** The legend in the Layers menu: shown while runs are on the map, saying in words what each mark is */
124
+ export function paintLegend() {
125
+ const el = typeof document !== 'undefined' ? document.getElementById('runs-legend') : null;
126
+ if (!el) return;
127
+ el.hidden = !(state.runsMap && (state.runsMap.flight.size || state.runsMap.collide.size));
128
+ }
129
+
130
+ /** For tests: forget the page */
131
+ export function resetRunsMapForTests() {
132
+ clearMarks();
133
+ state.runsMap = null;
134
+ reduce = null;
135
+ }