cornfield 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,11 @@
20
20
  * the config says it restarted. Selecting one lights those nodes (a stack brings its containers; a failed deploy lights
21
21
  * them red) through the same paint path, turning on the layers they are on; a deploy no `deploys.restarts` names is listed and says it lights nothing.
22
22
  * Only one thing is selected at a time, a pull request or a deploy.
23
+ * - A Threads tab (docs/threads.md): the ideas (issues labelled `idea`) and the spec PRs' conversations, newest activity first.
24
+ * Opening one shows its messages, read-only, with a link out to the forge to reply (writing happens there, spec 0005 D6), and
25
+ * lights the nodes its text mentions (`[[node-id]]`, `[[repo/path]]`, a `cornfield-thread` block; core/mentions.js) the way a
26
+ * pull request lights its files. "New idea" opens an issue with the label as the signed-in maintainer: a POST to cornfield's
27
+ * own server, which guards, counts and audits it. A node's panel lists the threads that mention it (`state.threadIndex`).
23
28
  * - Keys: Tab reaches the header button, Enter opens the panel with the focus on its title; Escape closes it and
24
29
  * gives the focus back to the button. What happens (a list loaded, a pull request selected, what it lights, a
25
30
  * failure) is said through the map's live region (`announce`, keyboard.js). Closed, the panel is `inert`.
@@ -46,23 +51,33 @@
46
51
  * notes again itself and has the forge keep them as a DRAFT release; nothing is published and no tag is made. The
47
52
  * page never sends the text of the notes, only the tag. The result, or the forge's refusal, is a line in words.
48
53
  *
54
+ * - Agent runs (issue #80, spec 0005, explorer/runs-model.js): **Start an agent** on an open issue and on an issue or proposal group of the Agents
55
+ * tab opens a dialog (kind, a profile when the config lists any, a brief, and in words what it will do) and POSTs once to
56
+ * `/api/agent-runs`; the Agents tab lists the runs with their state, links and a **Stop**, which asks the runner to stop. cornfield only
57
+ * asks: the request is a comment on the forge, as the maintainer. A refusal (the limit, a run already open) is said in the dialog, with
58
+ * a way to ask on purpose where the rule allows it.
59
+ *
49
60
  * What is asked, which words and which nodes are forge-panel-model.js, tested without a page.
50
61
  */
51
62
 
52
63
  import { state, nodeById, escHtml, touchScene, onSceneTouch } from './state.js';
53
64
  import { render, isNodeVisible } from './render.js';
54
- import { selectNode } from './panels.js';
65
+ import { selectNode, repaintThreads } from './panels.js';
55
66
  import { announce, centreOn } from './keyboard.js';
56
67
  import { isPhone, registerSheet, expandSheet, collapseSheet } from './sheets.js';
57
68
  import { relativeTime } from './review-model.js';
58
69
  import { LAYER_DEFS, toggleLayer } from './layers.js';
59
70
  import { WORK_URL, groupWork, filterGroups, groupWords, workStatus, markWords } from './agents-model.js';
71
+ import {
72
+ RUNS_URL, runsUrl, stopUrl, KINDS, aboutWords, statusWords, statusWhy, runMeta, canStop, runsStatus, startBody, startWords, stopWords, confirmStartWords,
73
+ } from './runs-model.js';
60
74
  import {
61
75
  STATES, defaultFilters, listUrl, pullUrl, serverState, filterItems, facetsOf, stateWords, metaWords, checkWords, checksWords, litFor, errorWords,
62
76
  landingUrl, cancelLandingUrl, landingWords, mergeBlock, rerunWhy, confirmMerge, mergeBody, actionUrl, actionWords,
63
77
  specBlock, buildOffers, planUrl, confirmSpec, confirmBuild, specBody, buildBody, existingWords,
64
78
  listKeyOf, litForDeploy, nodesForRestarts, deployWords, deployMeta,
65
79
  DRAFTS_URL, notesUrl, sinceChoices, notesSummary, validTag, confirmWords, postedWords, draftRequest,
80
+ threadsUrl, threadUrl, IDEAS_URL, threadKindWords, threadMeta, litForThread, mentionsIndex, ideaRequest, ideaWords,
66
81
  } from './forge-panel-model.js';
67
82
 
68
83
  const $ = (id) => document.getElementById(id);
@@ -85,6 +100,11 @@ const ui = {
85
100
  landing: null, // what waits to land on its approval for the selected pull request ({ slug, by, since }), or null
86
101
  detailError: '',
87
102
  lit: null, // litFor(detail, nodes)
103
+ thread: null, // the selected thread's number (Threads tab)
104
+ threadDetail: null, // { thread, at, rate, stale } of the selected one: its messages, as written
105
+ threadError: '',
106
+ litThread: null, // litForThread(thread, nodes)
107
+ idea: freshIdea(), // the New idea form
88
108
  deploy: null, // the selected deploy's id; the deploy itself is `deployItem`
89
109
  deployItem: null,
90
110
  litDeploy: null, // litForDeploy(deployItem, nodes)
@@ -94,6 +114,9 @@ const ui = {
94
114
  confirm: null, // { opener: selector, kind: 'merge' | 'spec' | 'build', plan?, n?, slug? } while the confirm dialog is open
95
115
  actNote: null, // { text, ok, url }: what the last pipeline step came to
96
116
  notes: freshNotes(), // the Release notes view
117
+ runs: null, // { data, error } of the Agents tab's runs: what /api/agent-runs answered
118
+ runNote: null, // { text, ok }: what the last Stop came to
119
+ agent: null, // { opener, refs, posting } while the Start an agent dialog is open
97
120
  };
98
121
 
99
122
  /** The Release notes view: the draft loaded for a tag, and where the confirm step is */
@@ -101,7 +124,16 @@ function freshNotes() {
101
124
  return { since: '', data: null, loading: false, error: '', confirm: false, posting: false, message: '', bad: false, link: null };
102
125
  }
103
126
 
127
+ /** The New idea form: whether it is open, one send in flight, and what the last send came to */
128
+ function freshIdea() {
129
+ return { open: false, busy: false, message: '', bad: false, link: null };
130
+ }
131
+
104
132
  let wired = false;
133
+ let threadToken = 0;
134
+ let indexToken = 0;
135
+ let indexLoading = false;
136
+ let indexCache = null; // { items, nodes, map } the mentions index for the list and map it was made from
105
137
  let listToken = 0;
106
138
  let pullToken = 0;
107
139
  let notesToken = 0;
@@ -114,7 +146,7 @@ const listKey = () => listKeyOf(ui.kind, ui.filters.state);
114
146
  const current = () => (ui.kind === 'work' ? ui.work : ui.lists[listKey()]) || null;
115
147
  const groupsShown = () => (ui.work ? filterGroups(ui.work.grouped.groups, ui.workFilters) : []);
116
148
  const plural = (n, word, more = `${word}s`) => `${n} ${n === 1 ? word : more}`;
117
- const kindWords = () => ({ pulls: 'pull request', issues: 'issue', deploys: 'deploy' })[ui.kind] || 'piece of work';
149
+ const kindWords = () => ({ pulls: 'pull request', issues: 'issue', threads: 'thread', deploys: 'deploy' })[ui.kind] || 'piece of work';
118
150
  /** A link only if it is the forge's web address: a forge's JSON is data, not trusted markup */
119
151
  const safeUrl = (u) => (typeof u === 'string' && /^https?:\/\//i.test(u) ? u : null);
120
152
 
@@ -141,7 +173,7 @@ async function loadWork(refresh, token) {
141
173
  ui.loading = true;
142
174
  ui.error = '';
143
175
  paint();
144
- const res = await request(WORK_URL, refresh);
176
+ const [res] = await Promise.all([request(WORK_URL, refresh), loadRuns(refresh)]);
145
177
  if (token !== listToken) return;
146
178
  ui.loading = false;
147
179
  if (res.ok) {
@@ -155,6 +187,12 @@ async function loadWork(refresh, token) {
155
187
  paint();
156
188
  }
157
189
 
190
+ /** 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) {
192
+ const res = await request(runsUrl(refresh), refresh);
193
+ 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) };
194
+ }
195
+
158
196
  /** Loads the list the kind and state name (again, past the server's cache, on a refresh) */
159
197
  async function loadList(refresh = false, quiet = false) {
160
198
  const token = ++listToken;
@@ -167,7 +205,8 @@ async function loadList(refresh = false, quiet = false) {
167
205
  if (token !== listToken) return;
168
206
  ui.loading = false;
169
207
  if (res.ok) {
170
- ui.lists[key] = { items: res.body.items || [], at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
208
+ ui.lists[key] = { items: res.body.items || [], at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '', problem: res.body.problem || '' };
209
+ if (ui.kind === 'threads') repaintThreads();
171
210
  if (!quiet) announce(`${plural(filterItems(ui.lists[key].items, ui.kind, ui.filters).length, kindWords())} shown${ui.lists[key].stale ? '. This is an older answer' : ''}`);
172
211
  } else {
173
212
  ui.error = errorWords(res.status, res.body);
@@ -180,6 +219,7 @@ async function loadList(refresh = false, quiet = false) {
180
219
  /** Selects a pull request: asks for its files and checks, then lights what they are on the map */
181
220
  async function selectPull(n) {
182
221
  const token = ++pullToken;
222
+ dropThread();
183
223
  ui.deploy = null;
184
224
  ui.deployItem = null;
185
225
  ui.litDeploy = null;
@@ -253,7 +293,10 @@ export function clearPull() {
253
293
  /** What the selected pull request lights, worked out again: for the nodes now on the map */
254
294
  function relight() {
255
295
  ui.nodesSeen = state.nodes;
256
- if (ui.deployItem) {
296
+ if (ui.threadDetail) {
297
+ ui.litThread = litForThread(ui.threadDetail.thread, state.nodes);
298
+ state.forgeLit = { changed: ui.litThread.changed, failing: ui.litThread.failing };
299
+ } else if (ui.deployItem) {
257
300
  ui.litDeploy = litForDeploy(ui.deployItem, state.nodes);
258
301
  state.forgeLit = { changed: ui.litDeploy.changed, failing: ui.litDeploy.failing };
259
302
  } else if (ui.detail) {
@@ -270,7 +313,8 @@ function relight() {
270
313
 
271
314
  /** A proposal opening, or the live layer, changes the nodes: the lights follow them */
272
315
  function onNodesChange() {
273
- if ((ui.detail || ui.deployItem) && state.nodes !== ui.nodesSeen) relight();
316
+ if ((ui.detail || ui.deployItem || ui.threadDetail) && state.nodes !== ui.nodesSeen) relight();
317
+ repaintThreads();
274
318
  }
275
319
 
276
320
  /** Selects a deploy: nothing to ask (the list holds what it restarted), so its nodes light at once */
@@ -278,6 +322,7 @@ export function selectDeploy(id) {
278
322
  const found = ui.lists.deploys && ui.lists.deploys.items.find((d) => d.id === id);
279
323
  if (!found) return;
280
324
  pullToken++;
325
+ dropThread();
281
326
  ui.selected = null;
282
327
  ui.detail = null;
283
328
  ui.detailError = '';
@@ -308,6 +353,184 @@ export function clearDeploy() {
308
353
  if (had) announce(`Deploy ${had.name} cleared`);
309
354
  }
310
355
 
356
+ // ---------------------------------------------------------------------------
357
+ // Threads
358
+ // ---------------------------------------------------------------------------
359
+
360
+ /** Forgets the selected thread without painting: another thing is being selected */
361
+ function dropThread() {
362
+ threadToken++;
363
+ ui.thread = null;
364
+ ui.threadDetail = null;
365
+ ui.threadError = '';
366
+ ui.litThread = null;
367
+ }
368
+
369
+ /** Selects a thread: asks for its messages (read-only), then lights the nodes they mention */
370
+ async function selectThread(n) {
371
+ const token = ++threadToken;
372
+ pullToken++;
373
+ ui.selected = null;
374
+ ui.detail = null;
375
+ ui.detailError = '';
376
+ ui.lit = null;
377
+ ui.landing = null;
378
+ ui.deploy = null;
379
+ ui.deployItem = null;
380
+ ui.litDeploy = null;
381
+ ui.thread = n;
382
+ ui.threadDetail = null;
383
+ ui.threadError = '';
384
+ ui.litThread = null;
385
+ state.forgeLit = null;
386
+ touchScene();
387
+ render();
388
+ paint();
389
+ const res = await request(threadUrl(n), false);
390
+ if (token !== threadToken) return;
391
+ if (!res.ok) {
392
+ ui.threadError = errorWords(res.status, res.body);
393
+ announce(ui.threadError);
394
+ paint();
395
+ return;
396
+ }
397
+ ui.threadDetail = { thread: res.body.thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
398
+ relight();
399
+ $('forge-panel').querySelector('.hx-forge-body').scrollTop = 0; // the thread's messages are what was asked for, not the row that was tapped
400
+ announce(threadSelectionWords());
401
+ }
402
+
403
+ /** Lets go of the selected thread: nothing is lit any more */
404
+ export function clearThread() {
405
+ const had = ui.thread;
406
+ dropThread();
407
+ state.forgeLit = null;
408
+ touchScene();
409
+ render();
410
+ paint();
411
+ if (had !== null) announce(`Thread ${had} cleared`);
412
+ }
413
+
414
+ /** What a screen reader hears when a thread is selected */
415
+ function threadSelectionWords() {
416
+ const { thread } = ui.threadDetail;
417
+ const lit = ui.litThread ? ui.litThread.nodes.length : 0;
418
+ const off = ui.litThread ? ui.litThread.unresolved.length : 0;
419
+ return `Thread ${thread.number}, ${thread.title}: ${plural(thread.messages.length, 'message')}, ${lit ? `${plural(lit, 'node')} lit` : 'no node mentioned'}${off ? `, ${off} named that ${off === 1 ? 'is' : 'are'} not on the map` : ''}. Read-only: reply on ${forgeName()}.`;
420
+ }
421
+
422
+ /** Shows a thread from the map (a node's "Mentioned in"): the panel opens on the Threads tab with that thread selected */
423
+ function openThread(n) {
424
+ if (ui.kind !== 'threads') {
425
+ ui.kind = 'threads';
426
+ ui.actNote = null;
427
+ ui.filters = defaultFilters('threads');
428
+ }
429
+ openForge();
430
+ paintFilters();
431
+ selectThread(n);
432
+ }
433
+
434
+ /** The threads list for the node panel's "Mentioned in", read once (quietly) when a node is first selected, and when the Threads tab loads it */
435
+ function ensureThreadsList() {
436
+ if (!ui.config || ui.config.threads === false || ui.lists['threads|open'] || indexLoading) return;
437
+ indexLoading = true;
438
+ const token = ++indexToken;
439
+ request(threadsUrl('open'), false).then((res) => {
440
+ indexLoading = false;
441
+ if (token !== indexToken || !res.ok || ui.lists['threads|open']) return;
442
+ ui.lists['threads|open'] = { items: res.body.items || [], at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '', problem: res.body.problem || '' };
443
+ repaintThreads();
444
+ if (ui.open && ui.kind === 'threads') paint();
445
+ });
446
+ }
447
+
448
+ /** The threads that mention a node on the map now, or null while the list has not been read */
449
+ function threadsFor(nodeId) {
450
+ const list = ui.lists['threads|open'];
451
+ if (!list) return null;
452
+ if (!indexCache || indexCache.items !== list.items || indexCache.nodes !== state.nodes) indexCache = { items: list.items, nodes: state.nodes, map: mentionsIndex(list.items, state.nodes) };
453
+ return indexCache.map.get(nodeId) || [];
454
+ }
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // New idea
458
+ // ---------------------------------------------------------------------------
459
+
460
+ /** Opens or closes the form; the focus goes to the title, or back to the button */
461
+ function toggleIdea(open) {
462
+ ui.idea.open = open;
463
+ if (open) ui.idea.message = '';
464
+ paintIdea();
465
+ if (open) $('idea-title').focus(); else $('idea-open').focus();
466
+ }
467
+
468
+ /** The one write: a POST to cornfield's own server, which opens an issue with the label as the maintainer */
469
+ async function sendIdea() {
470
+ if (ui.idea.busy) return;
471
+ const asked = ideaRequest($('idea-title').value, $('idea-body').value);
472
+ if (!asked) {
473
+ ui.idea.message = 'Give the idea a title.';
474
+ ui.idea.bad = true;
475
+ ui.idea.link = null;
476
+ paintIdea();
477
+ announce(ui.idea.message);
478
+ $('idea-title').focus();
479
+ return;
480
+ }
481
+ ui.idea.busy = true;
482
+ ui.idea.message = '';
483
+ paintIdea();
484
+ announce(`Opening the idea on ${forgeName()}`);
485
+ const res = await post(IDEAS_URL, asked);
486
+ ui.idea.busy = false;
487
+ ui.idea.message = ideaWords(forgeName(), res);
488
+ ui.idea.bad = !res.ok;
489
+ ui.idea.link = res.ok && res.body.issue ? res.body.issue.url : null;
490
+ if (res.ok) {
491
+ ui.idea.open = false;
492
+ $('idea-title').value = '';
493
+ $('idea-body').value = '';
494
+ }
495
+ paintIdea();
496
+ announce(ui.idea.message);
497
+ if (res.ok) {
498
+ ui.lists = {};
499
+ indexCache = null;
500
+ await loadList(true, true);
501
+ ensureThreadsList();
502
+ $('idea-open').focus();
503
+ } else $('idea-title').focus();
504
+ }
505
+
506
+ /** The New idea bar and form, from `ui.idea`; typed text and the focus are left alone */
507
+ function paintIdea() {
508
+ const bar = $('forge-idea');
509
+ if (!bar) return;
510
+ bar.hidden = ui.kind !== 'threads';
511
+ $('idea-open').setAttribute('aria-expanded', String(ui.idea.open));
512
+ $('idea-form').hidden = !ui.idea.open;
513
+ $('idea-send').textContent = ui.idea.busy ? 'Opening …' : 'Open the idea';
514
+ $('idea-send').disabled = ui.idea.busy;
515
+ if (ui.idea.open) {
516
+ const label = (ui.config && ui.config.threads && ui.config.threads.label) || 'idea';
517
+ $('idea-words').textContent = `This opens an issue labelled "${label}" on ${forgeName()}, as you. The conversation lives there; cornfield keeps no copy of it.`;
518
+ }
519
+ const message = $('idea-message');
520
+ message.hidden = !ui.idea.message;
521
+ message.classList.toggle('hx-forge-msg--bad', ui.idea.bad);
522
+ message.textContent = ui.idea.message;
523
+ if (ui.idea.message && ui.idea.link && safeUrl(ui.idea.link)) {
524
+ const link = document.createElement('a');
525
+ link.className = 'hx-link';
526
+ link.href = ui.idea.link;
527
+ link.target = '_blank';
528
+ link.rel = 'noreferrer';
529
+ link.textContent = `Open it on ${forgeName()} (new tab)`;
530
+ message.append(' ', link);
531
+ }
532
+ }
533
+
311
534
  /** What a screen reader hears when a deploy is selected */
312
535
  function deploySelectionWords(turnedOn = []) {
313
536
  const { name, branch } = ui.deployItem;
@@ -480,10 +703,10 @@ function paintFilters() {
480
703
  $('forge-label').innerHTML = option('', 'Any label', !ui.filters.label) + labels.map((l) => option(l, l, l === ui.filters.label)).join('');
481
704
  $('forge-author').innerHTML = option('', 'Anyone', !ui.filters.author) + authors.map((a) => option(a, a, a === ui.filters.author)).join('');
482
705
  // A deploy has no labels and no author to pick: its list is filtered by state and the search
483
- $('forge-label').parentElement.hidden = ui.kind === 'deploys';
706
+ $('forge-label').parentElement.hidden = ui.kind === 'deploys' || ui.kind === 'threads';
484
707
  $('forge-author').parentElement.hidden = ui.kind === 'deploys';
485
708
  // …and its search reads the workflow, what it ran for, the branch and who started it, not a number or a label
486
- $('forge-text').placeholder = ui.kind === 'deploys' ? 'Workflow, branch, commit, who' : 'Title, number, label';
709
+ $('forge-text').placeholder = ui.kind === 'deploys' ? 'Workflow, branch, commit, who' : ui.kind === 'threads' ? 'Title, number, author' : 'Title, number, label';
487
710
  }
488
711
 
489
712
  /** What a deploy lights, said short, for its row: how many nodes, or that it is not mapped */
@@ -502,8 +725,19 @@ function deployHtml(d) {
502
725
  <span class="hx-forge-labels"><span class="hx-forge-label">${escHtml(lightsWords(d))}</span></span></button>${out}</li>`;
503
726
  }
504
727
 
728
+ /** One thread in the list: a row that opens it, and a link out to the forge */
729
+ function threadHtml(t) {
730
+ const link = safeUrl(t.url);
731
+ const out = link ? `<a class="hx-forge-out" href="${escHtml(link)}" target="_blank" rel="noreferrer" aria-label="Open thread ${t.number} on ${escHtml(forgeName())} (new tab)">↗</a>` : '';
732
+ const note = [t.proposals && t.proposals.length ? `Proposal ${t.proposals.map((p) => p.slug).join(', ')}` : '', t.idea ? `Idea #${t.idea}` : ''].filter(Boolean).join(' · ');
733
+ return `<li class="hx-forge-item"><button type="button" class="hx-forge-row" data-forge-thread="${t.number}" aria-pressed="${ui.thread === t.number}">
734
+ <span class="hx-forge-title">${escHtml(t.title)}</span>
735
+ <span class="hx-forge-meta"><span class="hx-forge-state hx-forge-state--${escHtml(t.state)}">${escHtml(t.state === 'open' ? 'Open' : 'Closed')}</span> <span class="hx-forge-label">${escHtml(threadKindWords(t))}</span> ${escHtml(threadMeta(t, now()))}</span>${note ? `<span class="hx-forge-meta">${escHtml(note)}</span>` : ''}</button>${out}</li>`;
736
+ }
737
+
505
738
  function itemHtml(item, before = '', after = '') {
506
739
  if (ui.kind === 'deploys') return deployHtml(item);
740
+ if (ui.kind === 'threads') return threadHtml(item);
507
741
  const link = safeUrl(item.url);
508
742
  const labels = item.labels.length ? `<span class="hx-forge-labels">${item.labels.map((l) => `<span class="hx-forge-label">${escHtml(l)}</span>`).join('')}</span>` : '';
509
743
  const inner = `<span class="hx-forge-title">${escHtml(item.title)}</span>
@@ -516,9 +750,12 @@ function itemHtml(item, before = '', after = '') {
516
750
  const spec = specBlock(item) === null
517
751
  ? `<button type="button" class="hx-btn hx-forge-act hx-forge-startspec" data-forge-spec="${item.number}" aria-label="Start a spec for issue ${item.number}"${ui.busy ? ' disabled' : ''}>Start a spec …</button>`
518
752
  : '';
753
+ const agent = item.state === 'open'
754
+ ? `<button type="button" class="hx-btn hx-forge-act hx-forge-startspec" data-forge-agent="issue:${item.number}" aria-label="Start an agent on issue ${item.number}">Start an agent …</button>`
755
+ : '';
519
756
  return link
520
- ? `<li class="hx-forge-item"><a class="hx-forge-row" href="${escHtml(link)}" target="_blank" rel="noreferrer">${inner}<span class="hx-sr-only"> (opens ${escHtml(forgeName())} in a new tab)</span></a>${spec}</li>`
521
- : `<li class="hx-forge-item"><div class="hx-forge-row">${inner}</div>${spec}</li>`;
757
+ ? `<li class="hx-forge-item"><a class="hx-forge-row" href="${escHtml(link)}" target="_blank" rel="noreferrer">${inner}<span class="hx-sr-only"> (opens ${escHtml(forgeName())} in a new tab)</span></a>${spec}${agent}</li>`
758
+ : `<li class="hx-forge-item"><div class="hx-forge-row">${inner}</div>${spec}${agent}</li>`;
522
759
  }
523
760
 
524
761
  /** One piece of work in a group: a pull request (a row that selects it, as in the Pull requests tab) or a branch with none */
@@ -536,7 +773,9 @@ function workHtml(e) {
536
773
  function groupsHtml(groups) {
537
774
  return groups.map((g, i) => {
538
775
  const link = g.issue && safeUrl(g.issue.url) ? ` <a class="hx-link hx-forge-gout" href="${escHtml(g.issue.url)}" target="_blank" rel="noreferrer" aria-label="Open issue ${g.number} on ${escHtml(forgeName())} (new tab)">↗</a>` : '';
539
- return `<li class="hx-forge-group"><h3 class="hx-forge-gtitle" id="forge-group-${i}">${escHtml(g.title)}${link}</h3><p class="hx-forge-meta">${escHtml(groupWords(g))}</p><ul class="hx-forge-list" aria-labelledby="forge-group-${i}">${g.entries.map(workHtml).join('')}</ul></li>`;
776
+ const startRef = g.kind === 'issue' && g.issue && g.issue.state === 'open' ? `issue:${g.number}` : g.kind === 'proposal' ? `proposal:${g.slug}` : null;
777
+ const start = startRef ? ` <button type="button" class="hx-btn hx-forge-act" data-forge-agent="${escHtml(startRef)}" aria-label="Start an agent on ${escHtml(g.kind === 'issue' ? `issue ${g.number}` : `proposal ${g.slug}`)}">Start an agent …</button>` : '';
778
+ return `<li class="hx-forge-group"><h3 class="hx-forge-gtitle" id="forge-group-${i}">${escHtml(g.title)}${link}</h3>${start}<p class="hx-forge-meta">${escHtml(groupWords(g))}</p><ul class="hx-forge-list" aria-labelledby="forge-group-${i}">${g.entries.map(workHtml).join('')}</ul></li>`;
540
779
  }).join('');
541
780
  }
542
781
 
@@ -561,7 +800,48 @@ function deployDetailHtml() {
561
800
  return `${head}${stale ? `<p class="hx-forge-notice">${escHtml(`This is an older answer. ${stale}`)}</p>` : ''}${body}`;
562
801
  }
563
802
 
803
+ /** A node button, as in a pull request's files: it selects the node on the map */
804
+ const nodeButton = (n) => `<button type="button" class="hx-forge-node" data-forge-node="${escHtml(n.id)}" aria-label="Show ${escHtml(n.label)} on the map${isNodeVisible(n) ? '' : ' (its layer is off)'}">${escHtml(n.label)}${isNodeVisible(n) ? '' : ' · layer off'}</button>`;
805
+
806
+ /** The selected thread: read-only, its messages as written, a way to reply on the forge, and the nodes its text lights */
807
+ function threadDetailHtml() {
808
+ const head = (title) => `<div class="hx-forge-detail-head"><h3 class="hx-forge-detail-title">${title}</h3><button type="button" class="hx-btn hx-btn--quiet" data-action="clear-forge-thread">Clear</button></div>`;
809
+ if (ui.threadError) return `${head(`Thread ${ui.thread}`)}<p class="hx-forge-notice">${escHtml(ui.threadError)}</p>`;
810
+ if (!ui.threadDetail) return `${head(`Thread ${ui.thread}`)}<p class="hx-faint">Loading its messages …</p>`;
811
+ const { thread: t, stale } = ui.threadDetail;
812
+ const lit = ui.litThread;
813
+ const url = safeUrl(t.url);
814
+ const reply = url ? `<a class="hx-link" href="${escHtml(url)}" target="_blank" rel="noreferrer">Reply on ${escHtml(forgeName())} (new tab)</a>` : '';
815
+ const named = [
816
+ ...(t.proposals || []).map((p) => `Proposal <code class="hx-code">${escHtml(p.slug)}</code> in pull request #${p.pull}`),
817
+ ...(t.kind === 'proposal' ? [`Spec pull request for <code class="hx-code">${escHtml((t.slugs || []).join(', '))}</code>`] : []),
818
+ ...(t.idea ? [`Names idea #${t.idea}`] : []),
819
+ ];
820
+ const msgs = t.messages.map((m) => {
821
+ const own = lit.perMessage.get(m.id) || { nodes: [], unresolved: [] };
822
+ const link = safeUrl(m.url) ? ` <a class="hx-link" href="${escHtml(m.url)}" target="_blank" rel="noreferrer" aria-label="Open this message on ${escHtml(forgeName())} (new tab)">↗</a>` : '';
823
+ const chips = own.nodes.length || own.unresolved.length
824
+ ? `<span class="hx-forge-nodes hx-thread-nodes">${own.nodes.map(nodeButton).join('')}${own.unresolved.length ? `<span class="hx-faint">not on the map: ${own.unresolved.map((r) => `<code class="hx-code">${escHtml(r)}</code>`).join(' ')}</span>` : ''}</span>`
825
+ : '';
826
+ return `<li class="hx-thread-msg"><p class="hx-forge-meta"><strong class="hx-strong">${escHtml(m.author ? m.author.login : 'someone')}</strong>${m.opening ? ' opened this' : ''} · ${escHtml(relativeTime(m.createdAt, now()))}${link}</p><p class="hx-thread-body">${escHtml(m.body.trim() === '' ? '(no text)' : m.body)}${m.cut ? '\n… (cut here; the rest is on the forge)' : ''}</p>${chips}</li>`;
827
+ }).join('');
828
+ const key = lit.nodes.length ? `<p class="hx-forge-key hx-faint"><span class="hx-mark-dot hx-mark-dot--changed" aria-hidden="true"></span>a node this thread mentions</p>` : '';
829
+ return `${head(`#${t.number} ${escHtml(t.title)}`)}
830
+ <p class="hx-forge-meta"><span class="hx-forge-state hx-forge-state--${escHtml(t.state)}">${t.state === 'open' ? 'Open' : 'Closed'}</span> ${escHtml(threadKindWords(t))} · ${escHtml(threadMeta({ ...t, comments: t.messages.length - (t.kind === 'idea' ? 1 : 0) + (t.hidden || 0) }, now()))}</p>
831
+ <p class="hx-forge-why hx-faint">Read-only here: a thread is a conversation on ${escHtml(forgeName())}, so you reply there. ${reply}</p>
832
+ ${named.length ? `<p class="hx-forge-meta">${named.join(' · ')}</p>` : ''}
833
+ ${stale ? `<p class="hx-forge-notice">${escHtml(`This is an older answer. ${stale}`)}</p>` : ''}
834
+ <h4 class="hx-forge-sub">${escHtml(`Mentions: ${plural(lit.nodes.length, 'node')} on the map`)}</h4>
835
+ ${key}
836
+ ${lit.nodes.length ? `<span class="hx-forge-nodes hx-thread-nodes">${lit.nodes.map(nodeButton).join('')}</span>` : '<p class="hx-faint">No message mentions a node. Write [[node-id]] or [[repo/path]] in a message to point at the map.</p>'}
837
+ ${lit.unresolved.length ? `<p class="hx-forge-notice">${escHtml(lit.unresolved.join(', '))} ${lit.unresolved.length === 1 ? 'is' : 'are'} mentioned but not on the map, so ${lit.unresolved.length === 1 ? 'it stays' : 'they stay'} text.</p>` : ''}
838
+ <h4 class="hx-forge-sub">${escHtml(plural(t.messages.length, 'message'))}</h4>
839
+ ${t.hidden ? `<p class="hx-forge-why hx-faint">${escHtml(plural(t.hidden, 'earlier message'))} not shown; they are on ${escHtml(forgeName())}.</p>` : ''}
840
+ <ol class="hx-thread-msgs" aria-label="Messages">${msgs}</ol>`;
841
+ }
842
+
564
843
  function detailHtml() {
844
+ if (ui.thread !== null) return threadDetailHtml();
565
845
  if (ui.deployItem) return deployDetailHtml();
566
846
  if (ui.selected === null) return '';
567
847
  const head = (title, link) => `<div class="hx-forge-detail-head"><h3 class="hx-forge-detail-title">${title}</h3><button type="button" class="hx-btn hx-btn--quiet" data-action="clear-forge-pr">Clear</button></div>${link || ''}`;
@@ -636,11 +916,14 @@ function findAgain(el) {
636
916
  if (!el || !el.dataset) return null;
637
917
  if (el.dataset.forgePr) return `[data-forge-pr="${el.dataset.forgePr}"]`;
638
918
  if (el.dataset.forgeDeploy) return `[data-forge-deploy="${el.dataset.forgeDeploy}"]`;
919
+ if (el.dataset.forgeThread) return `[data-forge-thread="${el.dataset.forgeThread}"]`;
639
920
  if (el.dataset.forgeNode) return `[data-forge-node="${el.dataset.forgeNode}"]`;
640
921
  if (el.dataset.forgeMerge) return '[data-forge-merge]';
641
922
  if (el.dataset.forgeSpec) return `[data-forge-spec="${el.dataset.forgeSpec}"]`;
642
923
  if (el.dataset.forgeBuild) return `[data-forge-build="${el.dataset.forgeBuild}"]`;
643
924
  if (el.dataset.forgeRerun) return `[data-forge-rerun="${el.dataset.forgeRerun}"]`;
925
+ if (el.dataset.forgeAgent) return `[data-forge-agent="${el.dataset.forgeAgent}"]`;
926
+ if (el.dataset.forgeStop) return `[data-forge-stop="${el.dataset.forgeStop}"]`;
644
927
  return null;
645
928
  }
646
929
 
@@ -677,7 +960,7 @@ function draw(panel) {
677
960
  else status.textContent = `${shown.length} of ${plural(list.items.length, kindWords())}${list.at ? `, as of ${relativeTime(list.at, now())}` : ''}`;
678
961
 
679
962
  const notice = $('forge-notice');
680
- const words = ui.error || (list && list.stale ? `Showing an older answer. ${list.stale}` : '') || (work && list && list.problem ? `Spec pull requests could not all be read: ${list.problem}` : '');
963
+ const words = ui.error || (list && list.stale ? `Showing an older answer. ${list.stale}` : '') || (work && list && list.problem ? `Spec pull requests could not all be read: ${list.problem}` : '') || (ui.kind === 'threads' && list && list.problem ? `Some threads could not all be read: ${list.problem}` : '');
681
964
  notice.hidden = !words;
682
965
  notice.textContent = words;
683
966
 
@@ -695,15 +978,48 @@ function draw(panel) {
695
978
  else rows.innerHTML = shown.length ? shown.map((i) => itemHtml(i)).join('') : `<li class="hx-forge-empty hx-faint">${list.items.length ? `No ${kindWords()} matches these filters.` : `No ${kindWords()}s here.`}</li>`;
696
979
 
697
980
  const detail = $('forge-detail');
698
- detail.hidden = ui.selected === null && !ui.deployItem;
699
- detail.setAttribute('aria-label', ui.deployItem ? 'Selected deploy' : 'Selected pull request');
981
+ detail.hidden = ui.selected === null && !ui.deployItem && ui.thread === null;
982
+ detail.setAttribute('aria-label', ui.thread !== null ? 'Selected thread' : ui.deployItem ? 'Selected deploy' : 'Selected pull request');
700
983
  detail.innerHTML = detailHtml();
701
984
 
702
- const rate = (ui.detail && ui.detail.rate) || (list && list.rate) || '';
985
+ paintIdea();
986
+ const rate = (ui.threadDetail && ui.threadDetail.rate) || (ui.detail && ui.detail.rate) || (list && list.rate) || '';
703
987
  $('forge-rate').textContent = rate;
988
+ paintRuns();
704
989
  paintButton();
705
990
  }
706
991
 
992
+ /** The runs of the Agents tab: state, what each is about, links, and Stop. A runner's words are escaped like any text from the forge. */
993
+ function paintRuns() {
994
+ const box = $('forge-runs');
995
+ if (!box) return;
996
+ box.hidden = ui.kind !== 'work';
997
+ if (box.hidden) return;
998
+ const data = ui.runs && ui.runs.data;
999
+ $('forge-runs-status').textContent = data ? runsStatus(data) : ui.loading ? 'Loading the runs …' : '';
1000
+ const note = $('forge-runs-note');
1001
+ const words = (ui.runs && ui.runs.error) || (ui.runNote ? ui.runNote.text : '');
1002
+ note.hidden = !words;
1003
+ note.className = `hx-forge-notice${!(ui.runs && ui.runs.error) && ui.runNote && ui.runNote.ok ? ' hx-forge-notice--ok' : ''}`;
1004
+ note.textContent = words;
1005
+ const list = $('forge-runs-list');
1006
+ if (!data) { list.innerHTML = ''; return; }
1007
+ 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>';
1008
+ }
1009
+
1010
+ function runHtml(run) {
1011
+ const links = [
1012
+ 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>` : '',
1013
+ 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>'}` : '',
1014
+ 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>` : '',
1015
+ ].filter(Boolean).join(' · ');
1016
+ const why = statusWhy(run);
1017
+ const note = run.report && run.report.note ? `<p class="hx-forge-meta">Runner: ${escHtml(run.report.note)}</p>` : '';
1018
+ 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>` : '';
1019
+ 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}
1020
+ <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>`;
1021
+ }
1022
+
707
1023
  /** The panel's lines for the Release notes view: the status, a failure, the rate limit; the rest is paintNotes */
708
1024
  function drawNotes() {
709
1025
  const n = ui.notes;
@@ -713,6 +1029,7 @@ function drawNotes() {
713
1029
  notice.hidden = !words;
714
1030
  notice.textContent = words;
715
1031
  $('forge-detail').hidden = true;
1032
+ paintIdea();
716
1033
  $('forge-rate').textContent = (n.data && n.data.rate) || '';
717
1034
  paintNotes();
718
1035
  paintButton();
@@ -722,11 +1039,11 @@ function drawNotes() {
722
1039
  function paintButton() {
723
1040
  const btn = $('forge-btn');
724
1041
  if (!btn) return;
725
- const lit = ui.selected !== null || !!ui.deployItem;
1042
+ const lit = ui.selected !== null || !!ui.deployItem || ui.thread !== null;
726
1043
  const tag = $('forge-btn-lit');
727
1044
  tag.hidden = !lit;
728
- tag.textContent = ui.deployItem ? 'deploy' : lit ? `#${ui.selected}` : '';
729
- btn.setAttribute('aria-label', ui.deployItem ? 'Issues and pull requests, a deploy selected' : lit ? `Issues and pull requests, pull request ${ui.selected} selected` : 'Issues and pull requests');
1045
+ tag.textContent = ui.thread !== null ? `thread #${ui.thread}` : ui.deployItem ? 'deploy' : lit ? `#${ui.selected}` : '';
1046
+ 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');
730
1047
  btn.setAttribute('aria-expanded', String(ui.open));
731
1048
  }
732
1049
 
@@ -931,6 +1248,136 @@ function onConfirmClick(e) {
931
1248
  if (action.dataset.action === 'forge-confirm-cancel') { if (!ui.busy) closeConfirm(); } else if (action.dataset.action === 'forge-confirm-go') { if (ui.confirm && ui.confirm.kind === 'merge') confirmedMerge(); else confirmedStep(); }
932
1249
  }
933
1250
 
1251
+ // ---------------------------------------------------------------------------
1252
+ // Agent runs: Start an agent, and Stop
1253
+ // ---------------------------------------------------------------------------
1254
+
1255
+ const agentEl = () => $('agent-start');
1256
+
1257
+ /** While the dialog is open the focus cannot leave it */
1258
+ function keepFocusInAgent(e) {
1259
+ const el = agentEl();
1260
+ if (ui.agent && el && !el.contains(e.target)) $('agent-kind').focus();
1261
+ }
1262
+
1263
+ /** Opens the Start an agent dialog for `issue:<n>` or `proposal:<slug>`; the focus goes to the first field */
1264
+ async function openAgent(ref, opener) {
1265
+ if (!agentEl() || ui.agent) return;
1266
+ const at = ref.indexOf(':');
1267
+ const type = ref.slice(0, at);
1268
+ const value = ref.slice(at + 1);
1269
+ const refs = type === 'proposal' ? { proposal: value } : { issue: Number(value) };
1270
+ ui.agent = { opener, refs, posting: false };
1271
+ // The profiles the config lists come with the runs: asked for if the Agents tab has not been opened yet
1272
+ if (!ui.runs) { await loadRuns(false); if (ui.kind === 'work') paint(); }
1273
+ if (!ui.agent) return;
1274
+ const profiles = (ui.runs && ui.runs.data && ui.runs.data.profiles) || [];
1275
+ $('agent-start-about').textContent = `About ${aboutWords(refs)}`;
1276
+ $('agent-kind').innerHTML = KINDS.map(([v, l]) => option(v, l, v === 'code')).join('');
1277
+ $('agent-profile').innerHTML = option('', 'No profile', true) + profiles.map((p) => option(p, p, false)).join('');
1278
+ $('agent-profile-field').hidden = !profiles.length;
1279
+ $('agent-brief').value = '';
1280
+ $('agent-force').checked = false;
1281
+ $('agent-force-field').hidden = true;
1282
+ $('agent-start-msg').hidden = true;
1283
+ $('agent-start-msg').textContent = '';
1284
+ $('agent-start-words').textContent = confirmStartWords(refs, forgeName());
1285
+ const go = $('agent-start-go');
1286
+ go.disabled = false;
1287
+ go.removeAttribute('aria-busy');
1288
+ go.textContent = 'Ask the runner';
1289
+ document.addEventListener('focusin', keepFocusInAgent);
1290
+ agentEl().hidden = false;
1291
+ $('agent-kind').focus();
1292
+ announce(`Start an agent on ${aboutWords(refs)}. ${confirmStartWords(refs, forgeName())}`);
1293
+ }
1294
+
1295
+ /** Closes the dialog; the focus goes back to the button that opened it unless told not to */
1296
+ function closeAgent({ restoreFocus = true } = {}) {
1297
+ const el = agentEl();
1298
+ if (!el || !ui.agent) return;
1299
+ const { opener } = ui.agent;
1300
+ ui.agent = null;
1301
+ el.hidden = true;
1302
+ document.removeEventListener('focusin', keepFocusInAgent);
1303
+ if (!restoreFocus) return;
1304
+ const back = $('forge-panel').querySelector(opener) || $('forge-title');
1305
+ if (back && !back.disabled) back.focus();
1306
+ }
1307
+
1308
+ /** The one write: posts the choices once; a refusal stays in the dialog, in the server's words */
1309
+ async function submitAgent(e) {
1310
+ e.preventDefault();
1311
+ if (!ui.agent || ui.agent.posting) return;
1312
+ ui.agent.posting = true;
1313
+ const go = $('agent-start-go');
1314
+ go.disabled = true;
1315
+ go.setAttribute('aria-busy', 'true');
1316
+ go.textContent = 'Asking …';
1317
+ $('agent-start-msg').hidden = true;
1318
+ const res = await post(RUNS_URL, startBody({
1319
+ kind: $('agent-kind').value, profile: $('agent-profile-field').hidden ? '' : $('agent-profile').value, brief: $('agent-brief').value, refs: ui.agent.refs, force: $('agent-force').checked,
1320
+ }));
1321
+ if (!ui.agent) return;
1322
+ ui.agent.posting = false;
1323
+ const text = startWords(res.status, res.body);
1324
+ if (res.ok) {
1325
+ closeAgent();
1326
+ ui.runNote = { text, ok: true };
1327
+ announce(text);
1328
+ await loadRuns(false);
1329
+ paint();
1330
+ return;
1331
+ }
1332
+ go.disabled = false;
1333
+ go.removeAttribute('aria-busy');
1334
+ go.textContent = 'Ask the runner';
1335
+ const msg = $('agent-start-msg');
1336
+ msg.textContent = text;
1337
+ msg.hidden = false;
1338
+ // The one-per-issue rule can be set aside on purpose; the cap cannot
1339
+ if (res.body && res.body.code === 'run-open') $('agent-force-field').hidden = false;
1340
+ announce(text);
1341
+ }
1342
+
1343
+ /** Keys inside the dialog: Escape cancels, Tab stays inside, nothing reaches the map behind it */
1344
+ function onAgentKey(e) {
1345
+ e.stopPropagation();
1346
+ if (e.key === 'Escape') {
1347
+ e.preventDefault();
1348
+ if (!ui.agent || !ui.agent.posting) closeAgent();
1349
+ return;
1350
+ }
1351
+ if (e.key !== 'Tab') return;
1352
+ const fields = [...agentEl().querySelectorAll('select, textarea, input, button')].filter((el) => !el.disabled && !el.closest('[hidden]'));
1353
+ if (!fields.length) { e.preventDefault(); return; }
1354
+ const at = fields.indexOf(document.activeElement);
1355
+ const next = e.shiftKey ? (at <= 0 ? fields.length - 1 : at - 1) : (at === -1 || at === fields.length - 1 ? 0 : at + 1);
1356
+ e.preventDefault();
1357
+ fields[next].focus();
1358
+ }
1359
+
1360
+ function onAgentClick(e) {
1361
+ const t = e.target;
1362
+ if (t === agentEl()) { if (ui.agent && !ui.agent.posting) closeAgent(); return; } // the scrim
1363
+ if (t.closest && t.closest('[data-action="agent-start-cancel"]') && ui.agent && !ui.agent.posting) closeAgent();
1364
+ }
1365
+
1366
+ /** Asks a run to stop: one POST, then the runs are read again */
1367
+ async function stopRun(runId) {
1368
+ if (ui.busy) return;
1369
+ ui.busy = 'stop';
1370
+ ui.runNote = null;
1371
+ announce(`Asking ${runId} to stop`);
1372
+ paint();
1373
+ const res = await post(stopUrl(runId), {});
1374
+ ui.busy = '';
1375
+ ui.runNote = { text: stopWords(res.status, res.body, runId), ok: res.ok };
1376
+ announce(ui.runNote.text);
1377
+ await loadRuns(false);
1378
+ paint();
1379
+ }
1380
+
934
1381
  // ---------------------------------------------------------------------------
935
1382
  // Opening and closing
936
1383
  // ---------------------------------------------------------------------------
@@ -981,6 +1428,15 @@ export async function refreshForge({ quiet = false } = {}) {
981
1428
  if (again) { ui.deployItem = again; relight(); }
982
1429
  return;
983
1430
  }
1431
+ if (ui.thread !== null) {
1432
+ const at = ui.thread;
1433
+ const token = ++threadToken;
1434
+ const res = await request(threadUrl(at), true);
1435
+ if (token !== threadToken || !res.ok) return;
1436
+ ui.threadDetail = { thread: res.body.thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
1437
+ relight();
1438
+ return;
1439
+ }
984
1440
  if (n !== null && ui.selected === n) {
985
1441
  const token = ++pullToken;
986
1442
  const res = await request(pullUrl(n), true);
@@ -1027,6 +1483,12 @@ function onClick(e) {
1027
1483
  if (ui.selected === n) clearPull(); else selectPull(n);
1028
1484
  return;
1029
1485
  }
1486
+ const thr = t.closest('[data-forge-thread]');
1487
+ if (thr) {
1488
+ const n = Number(thr.dataset.forgeThread);
1489
+ if (ui.thread === n) clearThread(); else selectThread(n);
1490
+ return;
1491
+ }
1030
1492
  const dep = t.closest('[data-forge-deploy]');
1031
1493
  if (dep) {
1032
1494
  if (ui.deploy === dep.dataset.forgeDeploy) clearDeploy(); else selectDeploy(dep.dataset.forgeDeploy);
@@ -1042,6 +1504,10 @@ function onClick(e) {
1042
1504
  announce(`${node.label}, ${node.type}`);
1043
1505
  return;
1044
1506
  }
1507
+ const agent = t.closest('[data-forge-agent]');
1508
+ if (agent) { openAgent(agent.dataset.forgeAgent, `[data-forge-agent="${agent.dataset.forgeAgent}"]`); return; }
1509
+ const stop = t.closest('[data-forge-stop]');
1510
+ if (stop) { stopRun(stop.dataset.forgeStop); return; }
1045
1511
  const unland = t.closest('[data-forge-landing-cancel]');
1046
1512
  if (unland) { cancelLanding(Number(unland.dataset.forgeLandingCancel)); return; }
1047
1513
  const merge = t.closest('[data-forge-merge]');
@@ -1062,6 +1528,10 @@ function onClick(e) {
1062
1528
  else if (action.dataset.action === 'cancel-notes') { closeNotesConfirm(); announce('Cancelled. Nothing was posted.'); }
1063
1529
  else if (action.dataset.action === 'clear-forge-pr') { clearPull(); $('forge-title').focus(); }
1064
1530
  else if (action.dataset.action === 'clear-forge-deploy') { clearDeploy(); $('forge-title').focus(); }
1531
+ else if (action.dataset.action === 'clear-forge-thread') { clearThread(); $('forge-title').focus(); }
1532
+ else if (action.dataset.action === 'idea-open') toggleIdea(!ui.idea.open);
1533
+ else if (action.dataset.action === 'idea-send') sendIdea();
1534
+ else if (action.dataset.action === 'idea-cancel') { toggleIdea(false); announce('Cancelled. Nothing was opened.'); }
1065
1535
  }
1066
1536
 
1067
1537
  function onChange(e) {
@@ -1126,6 +1596,11 @@ export function initForgePanel(config, opts = {}) {
1126
1596
  if (opts.clock) now = opts.clock;
1127
1597
  btn.hidden = false;
1128
1598
  panel.hidden = false;
1599
+ // `threads: false` in the config turns the tab off; a server that does not say has threads
1600
+ const threadsOn = config.threads !== false;
1601
+ const tab = panel.querySelector('[data-forge-kind="threads"]');
1602
+ if (tab) tab.hidden = !threadsOn;
1603
+ state.threadIndex = threadsOn ? { for: threadsFor, ensure: ensureThreadsList, open: openThread } : null;
1129
1604
  registerSheet('forge', panel);
1130
1605
  paintFilters();
1131
1606
  paint();
@@ -1136,12 +1611,24 @@ export function initForgePanel(config, opts = {}) {
1136
1611
  panel.addEventListener('change', onChange);
1137
1612
  panel.addEventListener('input', onInput);
1138
1613
  panel.addEventListener('keydown', onKey);
1614
+ const agent = agentEl();
1615
+ if (agent) {
1616
+ agent.addEventListener('click', onAgentClick);
1617
+ agent.addEventListener('keydown', onAgentKey);
1618
+ agent.querySelector('form').addEventListener('submit', submitAgent);
1619
+ }
1139
1620
  const confirm = confirmEl();
1140
1621
  if (confirm) {
1141
1622
  confirm.addEventListener('click', onConfirmClick);
1142
1623
  confirm.addEventListener('keydown', onConfirmKey);
1143
1624
  }
1144
1625
  offScene = onSceneTouch(onNodesChange);
1626
+ // A node's panel lists the threads that mention it; a click there shows one in this panel
1627
+ const drawer = $('spec-drawer');
1628
+ if (drawer) drawer.addEventListener('click', (e) => {
1629
+ const open = e.target.closest && e.target.closest('[data-thread-open]');
1630
+ if (open && state.threadIndex) openThread(Number(open.dataset.threadOpen));
1631
+ });
1145
1632
  }
1146
1633
  return true;
1147
1634
  }
@@ -1149,6 +1636,7 @@ export function initForgePanel(config, opts = {}) {
1149
1636
  /** For tests: forget the page this was wired to, and everything shown */
1150
1637
  export function resetForgePanelForTests(clock = () => Date.now()) {
1151
1638
  if (ui.confirm) closeConfirm({ restoreFocus: false });
1639
+ if (ui.agent) closeAgent({ restoreFocus: false });
1152
1640
  if (offScene) offScene();
1153
1641
  offScene = null;
1154
1642
  clearTimeout(searchTimer);
@@ -1157,6 +1645,11 @@ export function resetForgePanelForTests(clock = () => Date.now()) {
1157
1645
  listToken++;
1158
1646
  pullToken++;
1159
1647
  notesToken++;
1648
+ threadToken++;
1649
+ indexToken++;
1650
+ indexLoading = false;
1651
+ indexCache = null;
1160
1652
  state.forgeLit = null;
1161
- Object.assign(ui, { 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() });
1653
+ state.threadIndex = null;
1654
+ 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 });
1162
1655
  }