@popcorn245/cornfield 0.11.0 → 0.13.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
+ }
@@ -304,10 +304,12 @@ export function mentionsOf(refs, nodes) {
304
304
  * @returns {{ nodes: object[], unresolved: string[], changed: Set<string>, failing: Set<string>, perMessage: Map<string, { nodes: object[], unresolved: string[] }> }}
305
305
  */
306
306
  export function litForThread(thread, nodes) {
307
- const all = linksOf(thread.messages.map((m) => m.body));
307
+ // A thread that is not there (not loaded, or an answer without one) lights nothing, and never throws
308
+ const messages = thread && Array.isArray(thread.messages) ? thread.messages.filter((m) => m && typeof m.body === 'string') : [];
309
+ const all = linksOf(messages.map((m) => m.body));
308
310
  const got = mentionsOf(all.refs, nodes);
309
311
  const perMessage = new Map();
310
- for (const m of thread.messages) perMessage.set(m.id, messageNodes(m.body, nodes));
312
+ for (const m of messages) perMessage.set(m.id, messageNodes(m.body, nodes));
311
313
  return { nodes: got.nodes, unresolved: got.unresolved, changed: new Set(got.ids), failing: new Set(), perMessage };
312
314
  }
313
315
 
@@ -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
 
@@ -126,10 +130,14 @@ function freshNotes() {
126
130
 
127
131
  /** The New idea form: whether it is open, one send in flight, and what the last send came to */
128
132
  function freshIdea() {
129
- return { open: false, busy: false, message: '', bad: false, link: null };
133
+ // `opener` is the id of the node whose panel button that opened the form ('' when the bar's own button did), `node` the node it was about
134
+ return { open: false, busy: false, message: '', bad: false, link: null, opener: '', node: null };
130
135
  }
131
136
 
132
137
  let wired = false;
138
+ let runsTimer = 0;
139
+ /** How often the runs are read again while one is open */
140
+ export const RUNS_POLL_MS = 60_000;
133
141
  let threadToken = 0;
134
142
  let indexToken = 0;
135
143
  let indexLoading = false;
@@ -188,11 +196,46 @@ async function loadWork(refresh, token) {
188
196
  }
189
197
 
190
198
  /** 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) {
199
+ async function loadRuns(refresh, { say = true } = {}) {
200
+ const before = ui.runs && ui.runs.data ? ui.runs.data.runs : null;
192
201
  const res = await request(runsUrl(refresh), refresh);
193
202
  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) };
203
+ if (!(ui.runs && ui.runs.data)) return;
204
+ // 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
205
+ setRunsOnMap(ui.runs.data.runs);
206
+ syncRunsPoll();
207
+ if (ui.runSel) relightRun();
208
+ const said = say ? changeWords(before, ui.runs.data.runs) : [];
209
+ if (said.length) announce(said.join('. '));
194
210
  }
195
211
 
212
+ /**
213
+ * 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
214
+ * 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
215
+ * away (`pagehide`). So the marks on the map and the words follow the runs.
216
+ */
217
+ function syncRunsPoll() {
218
+ const open = !!(ui.runs && ui.runs.data && ui.runs.data.open > 0);
219
+ if (!open || pageAway) {
220
+ clearTimeout(runsTimer);
221
+ runsTimer = 0;
222
+ return;
223
+ }
224
+ if (runsTimer) return;
225
+ runsTimer = setTimeout(async () => {
226
+ runsTimer = 0;
227
+ if (!(typeof document !== 'undefined' && document.hidden)) {
228
+ await loadRuns(false);
229
+ paint();
230
+ }
231
+ syncRunsPoll();
232
+ }, RUNS_POLL_MS);
233
+ }
234
+ /** The page is being put away (closed, or into the back-forward cache): no look until it is shown again */
235
+ let pageAway = false;
236
+ function onPageHide() { pageAway = true; syncRunsPoll(); }
237
+ function onPageShow() { pageAway = false; syncRunsPoll(); }
238
+
196
239
  /** Loads the list the kind and state name (again, past the server's cache, on a refresh) */
197
240
  async function loadList(refresh = false, quiet = false) {
198
241
  const token = ++listToken;
@@ -293,7 +336,10 @@ export function clearPull() {
293
336
  /** What the selected pull request lights, worked out again: for the nodes now on the map */
294
337
  function relight() {
295
338
  ui.nodesSeen = state.nodes;
296
- if (ui.threadDetail) {
339
+ if (ui.runSel) {
340
+ const run = runById(ui.runSel);
341
+ state.forgeLit = run ? litOfRun(run) : null;
342
+ } else if (ui.threadDetail && ui.threadDetail.thread) {
297
343
  ui.litThread = litForThread(ui.threadDetail.thread, state.nodes);
298
344
  state.forgeLit = { changed: ui.litThread.changed, failing: ui.litThread.failing };
299
345
  } else if (ui.deployItem) {
@@ -359,6 +405,7 @@ export function clearDeploy() {
359
405
 
360
406
  /** Forgets the selected thread without painting: another thing is being selected */
361
407
  function dropThread() {
408
+ ui.runSel = null;
362
409
  threadToken++;
363
410
  ui.thread = null;
364
411
  ui.threadDetail = null;
@@ -367,9 +414,13 @@ function dropThread() {
367
414
  }
368
415
 
369
416
  /** Selects a thread: asks for its messages (read-only), then lights the nodes they mention */
417
+ /** The thread a successful answer holds, or null (a failure, or an answer without one) */
418
+ const threadOf = (res) => (res && res.ok && res.body && res.body.thread && typeof res.body.thread === 'object' && Array.isArray(res.body.thread.messages) ? res.body.thread : null);
419
+
370
420
  async function selectThread(n) {
371
421
  const token = ++threadToken;
372
422
  pullToken++;
423
+ ui.runSel = null;
373
424
  ui.selected = null;
374
425
  ui.detail = null;
375
426
  ui.detailError = '';
@@ -388,13 +439,15 @@ async function selectThread(n) {
388
439
  paint();
389
440
  const res = await request(threadUrl(n), false);
390
441
  if (token !== threadToken) return;
391
- if (!res.ok) {
392
- ui.threadError = errorWords(res.status, res.body);
442
+ // An answer without a thread in it is a failure too: nothing to show or light
443
+ const thread = threadOf(res);
444
+ if (!thread) {
445
+ ui.threadError = res.ok ? `Thread ${n} could not be read: the answer had no thread in it. Refresh to ask again.` : errorWords(res.status, res.body);
393
446
  announce(ui.threadError);
394
447
  paint();
395
448
  return;
396
449
  }
397
- ui.threadDetail = { thread: res.body.thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
450
+ ui.threadDetail = { thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
398
451
  relight();
399
452
  $('forge-panel').querySelector('.hx-forge-body').scrollTop = 0; // the thread's messages are what was asked for, not the row that was tapped
400
453
  announce(threadSelectionWords());
@@ -460,9 +513,45 @@ function threadsFor(nodeId) {
460
513
  /** Opens or closes the form; the focus goes to the title, or back to the button */
461
514
  function toggleIdea(open) {
462
515
  ui.idea.open = open;
463
- if (open) ui.idea.message = '';
516
+ if (open) { ui.idea.message = ''; ui.idea.opener = ''; ui.idea.node = null; }
464
517
  paintIdea();
465
- if (open) $('idea-title').focus(); else $('idea-open').focus();
518
+ if (open) $('idea-title').focus(); else focusIdeaOpener();
519
+ }
520
+
521
+ /** Puts the focus back on what opened the form: the node panel's button while it is still there, else the New idea button */
522
+ function focusIdeaOpener() {
523
+ const from = ui.idea.opener ? [...document.querySelectorAll('[data-thread-start]')].find((b) => b.dataset.threadStart === ui.idea.opener) : null;
524
+ (from && !from.closest('[inert]') ? from : $('idea-open')).focus();
525
+ }
526
+
527
+ /** A node id that reads back as one mention: no brackets or line breaks, as core/mentions.js parses them */
528
+ const MENTIONABLE = /^[^[\]\r\n]{1,200}$/;
529
+
530
+ /** "Start a thread about this" (a node's panel): the New idea form, with the node's label as the title and its mention in the text */
531
+ function startThread(node) {
532
+ if (!node || ui.idea.busy || !ui.config || ui.config.threads === false) return;
533
+ if (ui.kind !== 'threads') {
534
+ ui.kind = 'threads';
535
+ ui.actNote = null;
536
+ ui.filters = defaultFilters('threads');
537
+ }
538
+ openForge();
539
+ paintFilters();
540
+ ui.idea.open = true;
541
+ ui.idea.message = '';
542
+ ui.idea.bad = false;
543
+ ui.idea.link = null;
544
+ ui.idea.node = { id: node.id, label: node.label };
545
+ ui.idea.opener = node.id;
546
+ // Never lose a draft: a title already typed stays, and text already typed stays under the mention, which is added once (`.value` is
547
+ // text, never markup, so the label and the id need no escaping here)
548
+ const title = $('idea-title');
549
+ if (title.value.trim() === '') title.value = String(node.label || node.id).slice(0, 256);
550
+ const text = $('idea-body');
551
+ const mention = MENTIONABLE.test(node.id) ? `[[${node.id}]]` : '';
552
+ if (mention && !text.value.includes(mention)) text.value = text.value.trim() === '' ? `${mention}\n\n` : `${mention}\n\n${text.value}`;
553
+ paintIdea();
554
+ $('idea-title').focus();
466
555
  }
467
556
 
468
557
  /** The one write: a POST to cornfield's own server, which opens an issue with the label as the maintainer */
@@ -494,13 +583,36 @@ async function sendIdea() {
494
583
  }
495
584
  paintIdea();
496
585
  announce(ui.idea.message);
497
- if (res.ok) {
586
+ if (res.ok) await afterIdea(res);
587
+ else $('idea-title').focus();
588
+ }
589
+
590
+ /**
591
+ * After an idea is opened: the list read again, the new thread selected (so what it mentions lights), the focus back on what opened
592
+ * the form, and all of it said once. The idea is on the forge whatever happens here, so a failure is said, never thrown.
593
+ */
594
+ async function afterIdea(res) {
595
+ const about = ui.idea.node;
596
+ const n = res.body && res.body.number;
597
+ let trouble = '';
598
+ try {
498
599
  ui.lists = {};
499
600
  indexCache = null;
500
601
  await loadList(true, true);
501
602
  ensureThreadsList();
502
- $('idea-open').focus();
503
- } else $('idea-title').focus();
603
+ // The new thread is selected in the list, so what it mentions lights on the map once its messages are in
604
+ if (Number.isInteger(n)) await selectThread(n);
605
+ } catch (err) {
606
+ trouble = ` The list could not be read again (${err instanceof Error ? err.message : err}); Refresh asks for it.`;
607
+ }
608
+ // Then the focus goes back to what opened the form: after the selection, which draws the node's panel (and its button) again
609
+ try { focusIdeaOpener(); } catch { /* the page moved on */ }
610
+ ui.idea.opener = '';
611
+ ui.idea.node = null;
612
+ const shown = ui.thread === n && ui.threadDetail && ui.threadDetail.thread ? ` ${threadSelectionWords()}` : ui.thread === n && ui.threadError ? ` ${ui.threadError}` : '';
613
+ // Opened from a node: say that its "Mentioned in" lists the new thread
614
+ const listed = about && (threadsFor(about.id) || []).some((t) => t.number === n);
615
+ announce(`${ui.idea.message}${shown}${listed ? ` It is listed under Mentioned in for ${about.label}.` : ''}${trouble}`);
504
616
  }
505
617
 
506
618
  /** The New idea bar and form, from `ui.idea`; typed text and the focus are left alone */
@@ -924,6 +1036,8 @@ function findAgain(el) {
924
1036
  if (el.dataset.forgeRerun) return `[data-forge-rerun="${el.dataset.forgeRerun}"]`;
925
1037
  if (el.dataset.forgeAgent) return `[data-forge-agent="${el.dataset.forgeAgent}"]`;
926
1038
  if (el.dataset.forgeStop) return `[data-forge-stop="${el.dataset.forgeStop}"]`;
1039
+ if (el.dataset.runSelect) return `[data-run-select="${el.dataset.runSelect}"]`;
1040
+ if (el.dataset.runTimeline) return `[data-run-timeline="${el.dataset.runTimeline}"]`;
927
1041
  return null;
928
1042
  }
929
1043
 
@@ -1010,20 +1124,51 @@ function paintRuns() {
1010
1124
  }
1011
1125
  const list = $('forge-runs-list');
1012
1126
  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>';
1127
+ 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>';
1128
+ }
1129
+
1130
+ /** What the Runs view needs to know of the page */
1131
+ function viewContext() {
1132
+ const f = ui.config && ui.config.forge;
1133
+ const base = f && safeUrl(f.url) ? f.url.replace(/\/+$/, '') : null;
1134
+ return {
1135
+ esc: escHtml, safeUrl, forgeName: forgeName(), forgeType: f ? f.type : 'github', repoUrl: base && f.repo ? `${base}/${f.repo}` : null,
1136
+ now: now(), nodes: state.nodes, selected: ui.runSel, timelines: ui.runTimelines, busy: !!ui.busy,
1137
+ };
1138
+ }
1139
+
1140
+ const runById = (id) => ((ui.runs && ui.runs.data && ui.runs.data.runs) || []).find((r) => r.runId === id) || null;
1141
+
1142
+ /** The nodes of a selected run, worked out again (the runs were read again, or the map changed) */
1143
+ function relightRun() {
1144
+ if (!runById(ui.runSel)) ui.runSel = null;
1145
+ relight();
1146
+ }
1147
+
1148
+ /** Lights the nodes of a run on the map, like a pull request; the same button lets go of it */
1149
+ function selectRun(id) {
1150
+ const run = runById(id);
1151
+ if (!run) return;
1152
+ if (ui.runSel === id) {
1153
+ ui.runSel = null;
1154
+ relight();
1155
+ announce(`Run ${id} no longer lit on the map`);
1156
+ return;
1157
+ }
1158
+ const had = ui.runSel;
1159
+ pullToken++;
1160
+ dropThread();
1161
+ Object.assign(ui, { selected: null, detail: null, detailError: '', lit: null, landing: null, note: null, deploy: null, deployItem: null, litDeploy: null });
1162
+ ui.runSel = id;
1163
+ relight();
1164
+ const n = (run.nodes || []).length;
1165
+ announce(n ? `Run ${id}: ${n} ${n === 1 ? 'node' : 'nodes'} lit on the map${had ? '' : ''}` : `Run ${id} has no node on the map yet`);
1014
1166
  }
1015
1167
 
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>`;
1168
+ /** Opens or closes the timeline of a run (it stays open when the list is drawn again) */
1169
+ function toggleTimeline(id) {
1170
+ if (ui.runTimelines.has(id)) ui.runTimelines.delete(id); else ui.runTimelines.add(id);
1171
+ paint();
1027
1172
  }
1028
1173
 
1029
1174
  /** The panel's lines for the Release notes view: the status, a failure, the rate limit; the rest is paintNotes */
@@ -1045,11 +1190,11 @@ function drawNotes() {
1045
1190
  function paintButton() {
1046
1191
  const btn = $('forge-btn');
1047
1192
  if (!btn) return;
1048
- const lit = ui.selected !== null || !!ui.deployItem || ui.thread !== null;
1193
+ const lit = ui.selected !== null || !!ui.deployItem || ui.thread !== null || !!ui.runSel;
1049
1194
  const tag = $('forge-btn-lit');
1050
1195
  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');
1196
+ tag.textContent = ui.runSel ? 'run' : ui.thread !== null ? `thread #${ui.thread}` : ui.deployItem ? 'deploy' : lit ? `#${ui.selected}` : '';
1197
+ 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
1198
  btn.setAttribute('aria-expanded', String(ui.open));
1054
1199
  }
1055
1200
 
@@ -1380,7 +1525,7 @@ async function stopRun(runId) {
1380
1525
  ui.busy = '';
1381
1526
  ui.runNote = { text: stopWords(res.status, res.body, runId), ok: res.ok };
1382
1527
  announce(ui.runNote.text);
1383
- await loadRuns(false);
1528
+ await loadRuns(false, { say: false });
1384
1529
  paint();
1385
1530
  }
1386
1531
 
@@ -1438,8 +1583,9 @@ export async function refreshForge({ quiet = false } = {}) {
1438
1583
  const at = ui.thread;
1439
1584
  const token = ++threadToken;
1440
1585
  const res = await request(threadUrl(at), true);
1441
- if (token !== threadToken || !res.ok) return;
1442
- ui.threadDetail = { thread: res.body.thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
1586
+ const thread = threadOf(res);
1587
+ if (token !== threadToken || !thread) return;
1588
+ ui.threadDetail = { thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
1443
1589
  relight();
1444
1590
  return;
1445
1591
  }
@@ -1514,6 +1660,10 @@ function onClick(e) {
1514
1660
  if (agent) { openAgent(agent.dataset.forgeAgent, `[data-forge-agent="${agent.dataset.forgeAgent}"]`); return; }
1515
1661
  const stop = t.closest('[data-forge-stop]');
1516
1662
  if (stop) { stopRun(stop.dataset.forgeStop); return; }
1663
+ const pick = t.closest('[data-run-select]');
1664
+ if (pick) { selectRun(pick.dataset.runSelect); return; }
1665
+ const timeline = t.closest('[data-run-timeline]');
1666
+ if (timeline) { toggleTimeline(timeline.dataset.runTimeline); return; }
1517
1667
  const unland = t.closest('[data-forge-landing-cancel]');
1518
1668
  if (unland) { cancelLanding(Number(unland.dataset.forgeLandingCancel)); return; }
1519
1669
  const merge = t.closest('[data-forge-merge]');
@@ -1536,7 +1686,16 @@ function onClick(e) {
1536
1686
  else if (action.dataset.action === 'clear-forge-deploy') { clearDeploy(); $('forge-title').focus(); }
1537
1687
  else if (action.dataset.action === 'clear-forge-thread') { clearThread(); $('forge-title').focus(); }
1538
1688
  else if (action.dataset.action === 'idea-open') toggleIdea(!ui.idea.open);
1539
- else if (action.dataset.action === 'idea-send') sendIdea();
1689
+ else if (action.dataset.action === 'idea-send') {
1690
+ // Nothing of it may escape as an unhandled rejection: what went wrong is said, and the form is usable again
1691
+ sendIdea().catch((err) => {
1692
+ ui.idea.busy = false;
1693
+ ui.idea.message = `Something went wrong in the page: ${err instanceof Error ? err.message : err}`;
1694
+ ui.idea.bad = true;
1695
+ paintIdea();
1696
+ announce(ui.idea.message);
1697
+ });
1698
+ }
1540
1699
  else if (action.dataset.action === 'idea-cancel') { toggleIdea(false); announce('Cancelled. Nothing was opened.'); }
1541
1700
  }
1542
1701
 
@@ -1606,10 +1765,14 @@ export function initForgePanel(config, opts = {}) {
1606
1765
  const threadsOn = config.threads !== false;
1607
1766
  const tab = panel.querySelector('[data-forge-kind="threads"]');
1608
1767
  if (tab) tab.hidden = !threadsOn;
1609
- state.threadIndex = threadsOn ? { for: threadsFor, ensure: ensureThreadsList, open: openThread } : null;
1768
+ state.threadIndex = threadsOn ? { for: threadsFor, ensure: ensureThreadsList, open: openThread, start: startThread, forge: forgeName } : null;
1610
1769
  registerSheet('forge', panel);
1611
1770
  paintFilters();
1612
1771
  paint();
1772
+ // 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
1773
+ if (config.agents && config.agents.runs) {
1774
+ loadRuns(false).then(() => { if (ui.kind === 'work') paint(); }, () => {});
1775
+ }
1613
1776
  if (!wired) {
1614
1777
  wired = true;
1615
1778
  btn.addEventListener('click', toggleForge);
@@ -1617,6 +1780,10 @@ export function initForgePanel(config, opts = {}) {
1617
1780
  panel.addEventListener('change', onChange);
1618
1781
  panel.addEventListener('input', onInput);
1619
1782
  panel.addEventListener('keydown', onKey);
1783
+ if (typeof window !== 'undefined') {
1784
+ window.addEventListener('pagehide', onPageHide);
1785
+ window.addEventListener('pageshow', onPageShow);
1786
+ }
1620
1787
  const agent = agentEl();
1621
1788
  if (agent) {
1622
1789
  agent.addEventListener('click', onAgentClick);
@@ -1633,7 +1800,9 @@ export function initForgePanel(config, opts = {}) {
1633
1800
  const drawer = $('spec-drawer');
1634
1801
  if (drawer) drawer.addEventListener('click', (e) => {
1635
1802
  const open = e.target.closest && e.target.closest('[data-thread-open]');
1636
- if (open && state.threadIndex) openThread(Number(open.dataset.threadOpen));
1803
+ if (open && state.threadIndex) { openThread(Number(open.dataset.threadOpen)); return; }
1804
+ const start = e.target.closest && e.target.closest('[data-thread-start]');
1805
+ if (start && state.threadIndex && state.selectedNode) startThread(state.selectedNode);
1637
1806
  });
1638
1807
  }
1639
1808
  return true;
@@ -1657,5 +1826,9 @@ export function resetForgePanelForTests(clock = () => Date.now()) {
1657
1826
  indexCache = null;
1658
1827
  state.forgeLit = null;
1659
1828
  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 });
1829
+ clearTimeout(runsTimer);
1830
+ runsTimer = 0;
1831
+ pageAway = false;
1832
+ clearRunsOnMap();
1833
+ 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
1834
  }
@@ -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;
@@ -132,10 +132,13 @@ function dataSummary(node) {
132
132
  /** "Mentioned in": the threads whose text names the node (an idea, or a spec PR's conversation); each opens in the issues and PRs panel */
133
133
  function threadsSection(node) {
134
134
  const index = state.threadIndex;
135
- const threads = index ? index.for(node.id) : null;
136
- if (!threads || !threads.length) return '';
135
+ if (!index) return '';
136
+ const threads = index.for(node.id);
137
+ // "Start a thread about this" shows whenever threads are on and a maintainer is signed in (the panel only sets `start` then)
138
+ const start = index.start ? `<div class="hx-line"><button type="button" class="hx-btn" data-thread-start="${escHtml(node.id)}" aria-label="Start a thread about this: opens the New idea form for ${escHtml(index.forge())}, with this node mentioned">Start a thread about this</button></div>` : '';
139
+ if (!threads || !threads.length) return start;
137
140
  const rows = threads.map((t) => `<div class="hx-line"><span class="hx-glyph" style="--glyph:#fbbf24">◆</span><span><button type="button" class="hx-btn hx-thread-open" data-thread-open="${t.number}" aria-label="Open thread #${t.number}, ${escHtml(t.title)}, in the issues and pull requests panel">#${t.number} ${escHtml(t.title)}</button> <span class="hx-faint">${t.kind === 'proposal' ? 'proposal' : 'idea'}</span></span></div>`);
138
- return section(`Mentioned in ${threads.length} ${threads.length === 1 ? 'thread' : 'threads'}:`, rows);
141
+ return section(`Mentioned in ${threads.length} ${threads.length === 1 ? 'thread' : 'threads'}:`, rows) + start;
139
142
  }
140
143
 
141
144
  /** Draws "Mentioned in" again in the open panel (the list was just read, or the map changed); nothing else in the panel moves */
@@ -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({