cornfield 0.6.0 → 0.8.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.
@@ -24,17 +24,21 @@
24
24
  * where a step's plan is read, what the confirm step shows of it, what is sent to confirm it, and what is said of one that is made already
25
25
  * landingUrl, cancelLandingUrl, landingWords
26
26
  * a spec pull request waiting to land on its approval (`proposals.approveLands`, server/lander.js): where it is read, where it is cancelled, and the words
27
+ * threadsUrl, threadUrl, IDEAS_URL, threadKindWords, threadMeta, litForThread, mentionsIndex, mentionsOf, messageNodes, ideaRequest, ideaWords
28
+ * the Threads tab (docs/threads.md): what to ask, a thread's words, which nodes its text lights and which threads mention a node
27
29
  * notesUrl, sinceChoices, notesSummary, validTag, confirmWords, postedWords, draftRequest
28
30
  * the Release notes view: what to ask, the tags to start from, and the words around a draft
29
31
  */
30
32
 
31
33
  import { relativeTime } from './review-model.js';
34
+ import { nodesForFile, linksOf, parseMentions, resolveMentions } from '../core/mentions.js';
32
35
 
33
- export const KINDS = Object.freeze(['pulls', 'issues', 'deploys', 'notes']);
36
+ export const KINDS = Object.freeze(['pulls', 'issues', 'threads', 'deploys', 'notes']);
34
37
  /** The states a list offers: [value, label]; a pull request that landed is "merged", one closed without landing "closed" */
35
38
  export const STATES = Object.freeze({
36
39
  pulls: [['open', 'Open'], ['merged', 'Merged'], ['closed', 'Closed'], ['all', 'All']],
37
40
  issues: [['open', 'Open'], ['closed', 'Closed'], ['all', 'All']],
41
+ threads: [['open', 'Open'], ['closed', 'Closed'], ['all', 'All']],
38
42
  deploys: [['all', 'All'], ['success', 'Succeeded'], ['failure', 'Failed'], ['running', 'Running or waiting'], ['other', 'Cancelled or skipped']],
39
43
  });
40
44
  /** How many a list asks for (the server's own default; its most is 300) */
@@ -50,9 +54,13 @@ export const defaultFilters = (kind = 'pulls') => ({ state: kind === 'deploys' ?
50
54
  export const serverState = (state) => (state === 'merged' ? 'closed' : state);
51
55
 
52
56
  /** The address of a list */
53
- export const listUrl = (kind, state) => (kind === 'deploys' ? `/api/forge/deploys?limit=${LIST_LIMIT}` : `/api/forge/${kind}?state=${serverState(state)}&limit=${LIST_LIMIT}`);
57
+ export const listUrl = (kind, state) => (kind === 'deploys' ? `/api/forge/deploys?limit=${LIST_LIMIT}` : kind === 'threads' ? threadsUrl(state) : `/api/forge/${kind}?state=${serverState(state)}&limit=${LIST_LIMIT}`);
54
58
  /** What a list is kept under: a deploys list is filtered by state here, so every state is one list */
55
59
  export const listKeyOf = (kind, state) => (kind === 'deploys' ? 'deploys' : `${kind}|${serverState(state)}`);
60
+ /** The address of the threads list for a state, of one thread, and where a new idea is posted */
61
+ export const threadsUrl = (state) => `/api/forge/threads?state=${state}&limit=${LIST_LIMIT}`;
62
+ export const threadUrl = (n) => `/api/forge/threads/${n}`;
63
+ export const IDEAS_URL = '/api/forge/threads';
56
64
  /** The address of one pull request with its files and checks */
57
65
  export const pullUrl = (n) => `/api/forge/pulls/${n}`;
58
66
 
@@ -152,25 +160,8 @@ export function checksWords(checks) {
152
160
 
153
161
  const clean = (p) => String(p).replace(/^\.\//, '').replace(/\/+$/, '');
154
162
 
155
- /**
156
- * The node(s) whose `file` holds a path: the node's `file` is the path itself or a folder above it, and the deepest
157
- * (longest) `file` wins. A file that holds several nodes (a page and what renders it) lights all of them.
158
- * @param {string} path a repo-relative path
159
- * @param {{ id: string, file?: string }[]} nodes
160
- * @returns {object[]} the nodes, [] when none holds it
161
- */
162
- export function nodesForFile(path, nodes) {
163
- const p = clean(path);
164
- let best = -1;
165
- let out = [];
166
- for (const node of nodes) {
167
- if (typeof node.file !== 'string' || node.file === '') continue;
168
- const file = clean(node.file);
169
- if (p !== file && !p.startsWith(`${file}/`)) continue;
170
- if (file.length > best) { best = file.length; out = [node]; } else if (file.length === best) out.push(node);
171
- }
172
- return out;
173
- }
163
+ // The map's rule for a file's node(s) is core/mentions.js's too (a thread's [[server/auth.js]] resolves the same way)
164
+ export { nodesForFile };
174
165
 
175
166
  const TEST_PATH = /(?:^|\/)(?:tests?|__tests__|spec|specs)\/|\.(?:test|spec)\.[a-z]+$|_test\.[a-z]+$|(?:^|\/)test_[^/]+\.py$/i;
176
167
  /** Whether a path looks like a test file */
@@ -279,6 +270,82 @@ export function litForDeploy(deploy, nodes) {
279
270
  : { nodes: hit, missing, changed: ids, failing: new Set() };
280
271
  }
281
272
 
273
+ // ---------------------------------------------------------------------------
274
+ // Threads: a conversation on the forge that points at the map (core/mentions.js)
275
+ // ---------------------------------------------------------------------------
276
+
277
+ /** "Idea", or "Proposal 0040-meals" for a spec PR's conversation */
278
+ export const threadKindWords = (t) => (t.kind === 'proposal' ? `Proposal${t.slugs && t.slugs.length ? ` ${t.slugs.join(', ')}` : ''}` : 'Idea');
279
+
280
+ /** "#41 · maintainer-one · 3 comments · 2 days ago" (the time of the last message) */
281
+ export function threadMeta(t, nowMs) {
282
+ const count = t.kind === 'proposal' && !t.comments ? 'no comments' : `${t.comments} ${t.comments === 1 ? 'comment' : 'comments'}`;
283
+ return [`#${t.number}`, t.author ? t.author.login : 'someone', count, relativeTime(t.updatedAt, nowMs)].filter(Boolean).join(' · ');
284
+ }
285
+
286
+ /**
287
+ * The nodes the references in some texts stand for, against the map now on screen. `refs` are as the server gives them (or
288
+ * as `linksOf` finds them); what is on no node stays text.
289
+ * @param {string[]} refs
290
+ * @param {{ id: string, file?: string }[]} nodes
291
+ * @returns {{ ids: string[], nodes: object[], unresolved: string[], resolved: { ref: string, ids: string[] }[] }}
292
+ */
293
+ export function mentionsOf(refs, nodes) {
294
+ const got = resolveMentions(refs, nodes);
295
+ const byId = new Map(nodes.map((n) => [n.id, n]));
296
+ return { ...got, nodes: got.ids.map((id) => byId.get(id)).filter(Boolean) };
297
+ }
298
+
299
+ /**
300
+ * What an open thread lights: the nodes its messages mention, as a pull request's files light theirs (amber, the rest
301
+ * faded). `messages` carry `body`; each message also says which nodes are its own (for the chips under it).
302
+ * @param {{ messages: { id: string, body: string }[] }} thread
303
+ * @param {{ id: string, file?: string }[]} nodes
304
+ * @returns {{ nodes: object[], unresolved: string[], changed: Set<string>, failing: Set<string>, perMessage: Map<string, { nodes: object[], unresolved: string[] }> }}
305
+ */
306
+ export function litForThread(thread, nodes) {
307
+ const all = linksOf(thread.messages.map((m) => m.body));
308
+ const got = mentionsOf(all.refs, nodes);
309
+ const perMessage = new Map();
310
+ for (const m of thread.messages) perMessage.set(m.id, messageNodes(m.body, nodes));
311
+ return { nodes: got.nodes, unresolved: got.unresolved, changed: new Set(got.ids), failing: new Set(), perMessage };
312
+ }
313
+
314
+ /** The nodes one message mentions (its `[[…]]` and its `cornfield-thread` block), and the references that are on none */
315
+ export function messageNodes(body, nodes) {
316
+ const got = parseMentions(body);
317
+ const found = mentionsOf([...got.refs, ...(got.block ? got.block.nodes : [])], nodes);
318
+ return { nodes: found.nodes, unresolved: found.unresolved };
319
+ }
320
+
321
+ /**
322
+ * Which threads mention which nodes, from the list the server gave (each thread's references as written), against the map
323
+ * on screen.
324
+ * @param {{ number: number, refs: string[] }[]} items
325
+ * @param {{ id: string, file?: string }[]} nodes
326
+ * @returns {Map<string, object[]>} node id -> the threads, in the order of `items`
327
+ */
328
+ export function mentionsIndex(items, nodes) {
329
+ const index = new Map();
330
+ for (const t of items) {
331
+ for (const id of resolveMentions(t.refs || [], nodes).ids) index.set(id, [...(index.get(id) || []), t]);
332
+ }
333
+ return index;
334
+ }
335
+
336
+ /** A new idea's request body: the title and the text, trimmed, or null when there is no title */
337
+ export function ideaRequest(title, body) {
338
+ const t = String(title ?? '').trim();
339
+ return t ? { title: t, body: String(body ?? '').trim() } : null;
340
+ }
341
+
342
+ /** What a new idea came to, in words */
343
+ export function ideaWords(forgeName, res) {
344
+ if (res.ok) return `Opened idea #${res.body.number} on ${forgeName}. It shows in the list within a minute.`;
345
+ if (res.status === 401) return 'Your sign-in has ended. Sign in again to open an idea.';
346
+ return errorWords(res.status, res.body);
347
+ }
348
+
282
349
  // ---------------------------------------------------------------------------
283
350
  // The picture
284
351
  // ---------------------------------------------------------------------------
@@ -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`.
@@ -51,7 +56,7 @@
51
56
 
52
57
  import { state, nodeById, escHtml, touchScene, onSceneTouch } from './state.js';
53
58
  import { render, isNodeVisible } from './render.js';
54
- import { selectNode } from './panels.js';
59
+ import { selectNode, repaintThreads } from './panels.js';
55
60
  import { announce, centreOn } from './keyboard.js';
56
61
  import { isPhone, registerSheet, expandSheet, collapseSheet } from './sheets.js';
57
62
  import { relativeTime } from './review-model.js';
@@ -63,6 +68,7 @@ import {
63
68
  specBlock, buildOffers, planUrl, confirmSpec, confirmBuild, specBody, buildBody, existingWords,
64
69
  listKeyOf, litForDeploy, nodesForRestarts, deployWords, deployMeta,
65
70
  DRAFTS_URL, notesUrl, sinceChoices, notesSummary, validTag, confirmWords, postedWords, draftRequest,
71
+ threadsUrl, threadUrl, IDEAS_URL, threadKindWords, threadMeta, litForThread, mentionsIndex, ideaRequest, ideaWords,
66
72
  } from './forge-panel-model.js';
67
73
 
68
74
  const $ = (id) => document.getElementById(id);
@@ -85,6 +91,11 @@ const ui = {
85
91
  landing: null, // what waits to land on its approval for the selected pull request ({ slug, by, since }), or null
86
92
  detailError: '',
87
93
  lit: null, // litFor(detail, nodes)
94
+ thread: null, // the selected thread's number (Threads tab)
95
+ threadDetail: null, // { thread, at, rate, stale } of the selected one: its messages, as written
96
+ threadError: '',
97
+ litThread: null, // litForThread(thread, nodes)
98
+ idea: freshIdea(), // the New idea form
88
99
  deploy: null, // the selected deploy's id; the deploy itself is `deployItem`
89
100
  deployItem: null,
90
101
  litDeploy: null, // litForDeploy(deployItem, nodes)
@@ -101,7 +112,16 @@ function freshNotes() {
101
112
  return { since: '', data: null, loading: false, error: '', confirm: false, posting: false, message: '', bad: false, link: null };
102
113
  }
103
114
 
115
+ /** The New idea form: whether it is open, one send in flight, and what the last send came to */
116
+ function freshIdea() {
117
+ return { open: false, busy: false, message: '', bad: false, link: null };
118
+ }
119
+
104
120
  let wired = false;
121
+ let threadToken = 0;
122
+ let indexToken = 0;
123
+ let indexLoading = false;
124
+ let indexCache = null; // { items, nodes, map } the mentions index for the list and map it was made from
105
125
  let listToken = 0;
106
126
  let pullToken = 0;
107
127
  let notesToken = 0;
@@ -114,7 +134,7 @@ const listKey = () => listKeyOf(ui.kind, ui.filters.state);
114
134
  const current = () => (ui.kind === 'work' ? ui.work : ui.lists[listKey()]) || null;
115
135
  const groupsShown = () => (ui.work ? filterGroups(ui.work.grouped.groups, ui.workFilters) : []);
116
136
  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';
137
+ const kindWords = () => ({ pulls: 'pull request', issues: 'issue', threads: 'thread', deploys: 'deploy' })[ui.kind] || 'piece of work';
118
138
  /** A link only if it is the forge's web address: a forge's JSON is data, not trusted markup */
119
139
  const safeUrl = (u) => (typeof u === 'string' && /^https?:\/\//i.test(u) ? u : null);
120
140
 
@@ -167,7 +187,8 @@ async function loadList(refresh = false, quiet = false) {
167
187
  if (token !== listToken) return;
168
188
  ui.loading = false;
169
189
  if (res.ok) {
170
- ui.lists[key] = { items: res.body.items || [], at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
190
+ ui.lists[key] = { items: res.body.items || [], at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '', problem: res.body.problem || '' };
191
+ if (ui.kind === 'threads') repaintThreads();
171
192
  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
193
  } else {
173
194
  ui.error = errorWords(res.status, res.body);
@@ -180,6 +201,7 @@ async function loadList(refresh = false, quiet = false) {
180
201
  /** Selects a pull request: asks for its files and checks, then lights what they are on the map */
181
202
  async function selectPull(n) {
182
203
  const token = ++pullToken;
204
+ dropThread();
183
205
  ui.deploy = null;
184
206
  ui.deployItem = null;
185
207
  ui.litDeploy = null;
@@ -253,7 +275,10 @@ export function clearPull() {
253
275
  /** What the selected pull request lights, worked out again: for the nodes now on the map */
254
276
  function relight() {
255
277
  ui.nodesSeen = state.nodes;
256
- if (ui.deployItem) {
278
+ if (ui.threadDetail) {
279
+ ui.litThread = litForThread(ui.threadDetail.thread, state.nodes);
280
+ state.forgeLit = { changed: ui.litThread.changed, failing: ui.litThread.failing };
281
+ } else if (ui.deployItem) {
257
282
  ui.litDeploy = litForDeploy(ui.deployItem, state.nodes);
258
283
  state.forgeLit = { changed: ui.litDeploy.changed, failing: ui.litDeploy.failing };
259
284
  } else if (ui.detail) {
@@ -270,7 +295,8 @@ function relight() {
270
295
 
271
296
  /** A proposal opening, or the live layer, changes the nodes: the lights follow them */
272
297
  function onNodesChange() {
273
- if ((ui.detail || ui.deployItem) && state.nodes !== ui.nodesSeen) relight();
298
+ if ((ui.detail || ui.deployItem || ui.threadDetail) && state.nodes !== ui.nodesSeen) relight();
299
+ repaintThreads();
274
300
  }
275
301
 
276
302
  /** Selects a deploy: nothing to ask (the list holds what it restarted), so its nodes light at once */
@@ -278,6 +304,7 @@ export function selectDeploy(id) {
278
304
  const found = ui.lists.deploys && ui.lists.deploys.items.find((d) => d.id === id);
279
305
  if (!found) return;
280
306
  pullToken++;
307
+ dropThread();
281
308
  ui.selected = null;
282
309
  ui.detail = null;
283
310
  ui.detailError = '';
@@ -308,6 +335,184 @@ export function clearDeploy() {
308
335
  if (had) announce(`Deploy ${had.name} cleared`);
309
336
  }
310
337
 
338
+ // ---------------------------------------------------------------------------
339
+ // Threads
340
+ // ---------------------------------------------------------------------------
341
+
342
+ /** Forgets the selected thread without painting: another thing is being selected */
343
+ function dropThread() {
344
+ threadToken++;
345
+ ui.thread = null;
346
+ ui.threadDetail = null;
347
+ ui.threadError = '';
348
+ ui.litThread = null;
349
+ }
350
+
351
+ /** Selects a thread: asks for its messages (read-only), then lights the nodes they mention */
352
+ async function selectThread(n) {
353
+ const token = ++threadToken;
354
+ pullToken++;
355
+ ui.selected = null;
356
+ ui.detail = null;
357
+ ui.detailError = '';
358
+ ui.lit = null;
359
+ ui.landing = null;
360
+ ui.deploy = null;
361
+ ui.deployItem = null;
362
+ ui.litDeploy = null;
363
+ ui.thread = n;
364
+ ui.threadDetail = null;
365
+ ui.threadError = '';
366
+ ui.litThread = null;
367
+ state.forgeLit = null;
368
+ touchScene();
369
+ render();
370
+ paint();
371
+ const res = await request(threadUrl(n), false);
372
+ if (token !== threadToken) return;
373
+ if (!res.ok) {
374
+ ui.threadError = errorWords(res.status, res.body);
375
+ announce(ui.threadError);
376
+ paint();
377
+ return;
378
+ }
379
+ ui.threadDetail = { thread: res.body.thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
380
+ relight();
381
+ $('forge-panel').querySelector('.hx-forge-body').scrollTop = 0; // the thread's messages are what was asked for, not the row that was tapped
382
+ announce(threadSelectionWords());
383
+ }
384
+
385
+ /** Lets go of the selected thread: nothing is lit any more */
386
+ export function clearThread() {
387
+ const had = ui.thread;
388
+ dropThread();
389
+ state.forgeLit = null;
390
+ touchScene();
391
+ render();
392
+ paint();
393
+ if (had !== null) announce(`Thread ${had} cleared`);
394
+ }
395
+
396
+ /** What a screen reader hears when a thread is selected */
397
+ function threadSelectionWords() {
398
+ const { thread } = ui.threadDetail;
399
+ const lit = ui.litThread ? ui.litThread.nodes.length : 0;
400
+ const off = ui.litThread ? ui.litThread.unresolved.length : 0;
401
+ 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()}.`;
402
+ }
403
+
404
+ /** Shows a thread from the map (a node's "Mentioned in"): the panel opens on the Threads tab with that thread selected */
405
+ function openThread(n) {
406
+ if (ui.kind !== 'threads') {
407
+ ui.kind = 'threads';
408
+ ui.actNote = null;
409
+ ui.filters = defaultFilters('threads');
410
+ }
411
+ openForge();
412
+ paintFilters();
413
+ selectThread(n);
414
+ }
415
+
416
+ /** 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 */
417
+ function ensureThreadsList() {
418
+ if (!ui.config || ui.config.threads === false || ui.lists['threads|open'] || indexLoading) return;
419
+ indexLoading = true;
420
+ const token = ++indexToken;
421
+ request(threadsUrl('open'), false).then((res) => {
422
+ indexLoading = false;
423
+ if (token !== indexToken || !res.ok || ui.lists['threads|open']) return;
424
+ ui.lists['threads|open'] = { items: res.body.items || [], at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '', problem: res.body.problem || '' };
425
+ repaintThreads();
426
+ if (ui.open && ui.kind === 'threads') paint();
427
+ });
428
+ }
429
+
430
+ /** The threads that mention a node on the map now, or null while the list has not been read */
431
+ function threadsFor(nodeId) {
432
+ const list = ui.lists['threads|open'];
433
+ if (!list) return null;
434
+ if (!indexCache || indexCache.items !== list.items || indexCache.nodes !== state.nodes) indexCache = { items: list.items, nodes: state.nodes, map: mentionsIndex(list.items, state.nodes) };
435
+ return indexCache.map.get(nodeId) || [];
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // New idea
440
+ // ---------------------------------------------------------------------------
441
+
442
+ /** Opens or closes the form; the focus goes to the title, or back to the button */
443
+ function toggleIdea(open) {
444
+ ui.idea.open = open;
445
+ if (open) ui.idea.message = '';
446
+ paintIdea();
447
+ if (open) $('idea-title').focus(); else $('idea-open').focus();
448
+ }
449
+
450
+ /** The one write: a POST to cornfield's own server, which opens an issue with the label as the maintainer */
451
+ async function sendIdea() {
452
+ if (ui.idea.busy) return;
453
+ const asked = ideaRequest($('idea-title').value, $('idea-body').value);
454
+ if (!asked) {
455
+ ui.idea.message = 'Give the idea a title.';
456
+ ui.idea.bad = true;
457
+ ui.idea.link = null;
458
+ paintIdea();
459
+ announce(ui.idea.message);
460
+ $('idea-title').focus();
461
+ return;
462
+ }
463
+ ui.idea.busy = true;
464
+ ui.idea.message = '';
465
+ paintIdea();
466
+ announce(`Opening the idea on ${forgeName()}`);
467
+ const res = await post(IDEAS_URL, asked);
468
+ ui.idea.busy = false;
469
+ ui.idea.message = ideaWords(forgeName(), res);
470
+ ui.idea.bad = !res.ok;
471
+ ui.idea.link = res.ok && res.body.issue ? res.body.issue.url : null;
472
+ if (res.ok) {
473
+ ui.idea.open = false;
474
+ $('idea-title').value = '';
475
+ $('idea-body').value = '';
476
+ }
477
+ paintIdea();
478
+ announce(ui.idea.message);
479
+ if (res.ok) {
480
+ ui.lists = {};
481
+ indexCache = null;
482
+ await loadList(true, true);
483
+ ensureThreadsList();
484
+ $('idea-open').focus();
485
+ } else $('idea-title').focus();
486
+ }
487
+
488
+ /** The New idea bar and form, from `ui.idea`; typed text and the focus are left alone */
489
+ function paintIdea() {
490
+ const bar = $('forge-idea');
491
+ if (!bar) return;
492
+ bar.hidden = ui.kind !== 'threads';
493
+ $('idea-open').setAttribute('aria-expanded', String(ui.idea.open));
494
+ $('idea-form').hidden = !ui.idea.open;
495
+ $('idea-send').textContent = ui.idea.busy ? 'Opening …' : 'Open the idea';
496
+ $('idea-send').disabled = ui.idea.busy;
497
+ if (ui.idea.open) {
498
+ const label = (ui.config && ui.config.threads && ui.config.threads.label) || 'idea';
499
+ $('idea-words').textContent = `This opens an issue labelled "${label}" on ${forgeName()}, as you. The conversation lives there; cornfield keeps no copy of it.`;
500
+ }
501
+ const message = $('idea-message');
502
+ message.hidden = !ui.idea.message;
503
+ message.classList.toggle('hx-forge-msg--bad', ui.idea.bad);
504
+ message.textContent = ui.idea.message;
505
+ if (ui.idea.message && ui.idea.link && safeUrl(ui.idea.link)) {
506
+ const link = document.createElement('a');
507
+ link.className = 'hx-link';
508
+ link.href = ui.idea.link;
509
+ link.target = '_blank';
510
+ link.rel = 'noreferrer';
511
+ link.textContent = `Open it on ${forgeName()} (new tab)`;
512
+ message.append(' ', link);
513
+ }
514
+ }
515
+
311
516
  /** What a screen reader hears when a deploy is selected */
312
517
  function deploySelectionWords(turnedOn = []) {
313
518
  const { name, branch } = ui.deployItem;
@@ -480,10 +685,10 @@ function paintFilters() {
480
685
  $('forge-label').innerHTML = option('', 'Any label', !ui.filters.label) + labels.map((l) => option(l, l, l === ui.filters.label)).join('');
481
686
  $('forge-author').innerHTML = option('', 'Anyone', !ui.filters.author) + authors.map((a) => option(a, a, a === ui.filters.author)).join('');
482
687
  // 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';
688
+ $('forge-label').parentElement.hidden = ui.kind === 'deploys' || ui.kind === 'threads';
484
689
  $('forge-author').parentElement.hidden = ui.kind === 'deploys';
485
690
  // …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';
691
+ $('forge-text').placeholder = ui.kind === 'deploys' ? 'Workflow, branch, commit, who' : ui.kind === 'threads' ? 'Title, number, author' : 'Title, number, label';
487
692
  }
488
693
 
489
694
  /** What a deploy lights, said short, for its row: how many nodes, or that it is not mapped */
@@ -502,8 +707,19 @@ function deployHtml(d) {
502
707
  <span class="hx-forge-labels"><span class="hx-forge-label">${escHtml(lightsWords(d))}</span></span></button>${out}</li>`;
503
708
  }
504
709
 
710
+ /** One thread in the list: a row that opens it, and a link out to the forge */
711
+ function threadHtml(t) {
712
+ const link = safeUrl(t.url);
713
+ 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>` : '';
714
+ const note = [t.proposals && t.proposals.length ? `Proposal ${t.proposals.map((p) => p.slug).join(', ')}` : '', t.idea ? `Idea #${t.idea}` : ''].filter(Boolean).join(' · ');
715
+ return `<li class="hx-forge-item"><button type="button" class="hx-forge-row" data-forge-thread="${t.number}" aria-pressed="${ui.thread === t.number}">
716
+ <span class="hx-forge-title">${escHtml(t.title)}</span>
717
+ <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>`;
718
+ }
719
+
505
720
  function itemHtml(item, before = '', after = '') {
506
721
  if (ui.kind === 'deploys') return deployHtml(item);
722
+ if (ui.kind === 'threads') return threadHtml(item);
507
723
  const link = safeUrl(item.url);
508
724
  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
725
  const inner = `<span class="hx-forge-title">${escHtml(item.title)}</span>
@@ -561,7 +777,48 @@ function deployDetailHtml() {
561
777
  return `${head}${stale ? `<p class="hx-forge-notice">${escHtml(`This is an older answer. ${stale}`)}</p>` : ''}${body}`;
562
778
  }
563
779
 
780
+ /** A node button, as in a pull request's files: it selects the node on the map */
781
+ 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>`;
782
+
783
+ /** The selected thread: read-only, its messages as written, a way to reply on the forge, and the nodes its text lights */
784
+ function threadDetailHtml() {
785
+ 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>`;
786
+ if (ui.threadError) return `${head(`Thread ${ui.thread}`)}<p class="hx-forge-notice">${escHtml(ui.threadError)}</p>`;
787
+ if (!ui.threadDetail) return `${head(`Thread ${ui.thread}`)}<p class="hx-faint">Loading its messages …</p>`;
788
+ const { thread: t, stale } = ui.threadDetail;
789
+ const lit = ui.litThread;
790
+ const url = safeUrl(t.url);
791
+ const reply = url ? `<a class="hx-link" href="${escHtml(url)}" target="_blank" rel="noreferrer">Reply on ${escHtml(forgeName())} (new tab)</a>` : '';
792
+ const named = [
793
+ ...(t.proposals || []).map((p) => `Proposal <code class="hx-code">${escHtml(p.slug)}</code> in pull request #${p.pull}`),
794
+ ...(t.kind === 'proposal' ? [`Spec pull request for <code class="hx-code">${escHtml((t.slugs || []).join(', '))}</code>`] : []),
795
+ ...(t.idea ? [`Names idea #${t.idea}`] : []),
796
+ ];
797
+ const msgs = t.messages.map((m) => {
798
+ const own = lit.perMessage.get(m.id) || { nodes: [], unresolved: [] };
799
+ 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>` : '';
800
+ const chips = own.nodes.length || own.unresolved.length
801
+ ? `<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>`
802
+ : '';
803
+ 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>`;
804
+ }).join('');
805
+ 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>` : '';
806
+ return `${head(`#${t.number} ${escHtml(t.title)}`)}
807
+ <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>
808
+ <p class="hx-forge-why hx-faint">Read-only here: a thread is a conversation on ${escHtml(forgeName())}, so you reply there. ${reply}</p>
809
+ ${named.length ? `<p class="hx-forge-meta">${named.join(' · ')}</p>` : ''}
810
+ ${stale ? `<p class="hx-forge-notice">${escHtml(`This is an older answer. ${stale}`)}</p>` : ''}
811
+ <h4 class="hx-forge-sub">${escHtml(`Mentions: ${plural(lit.nodes.length, 'node')} on the map`)}</h4>
812
+ ${key}
813
+ ${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>'}
814
+ ${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>` : ''}
815
+ <h4 class="hx-forge-sub">${escHtml(plural(t.messages.length, 'message'))}</h4>
816
+ ${t.hidden ? `<p class="hx-forge-why hx-faint">${escHtml(plural(t.hidden, 'earlier message'))} not shown; they are on ${escHtml(forgeName())}.</p>` : ''}
817
+ <ol class="hx-thread-msgs" aria-label="Messages">${msgs}</ol>`;
818
+ }
819
+
564
820
  function detailHtml() {
821
+ if (ui.thread !== null) return threadDetailHtml();
565
822
  if (ui.deployItem) return deployDetailHtml();
566
823
  if (ui.selected === null) return '';
567
824
  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,6 +893,7 @@ function findAgain(el) {
636
893
  if (!el || !el.dataset) return null;
637
894
  if (el.dataset.forgePr) return `[data-forge-pr="${el.dataset.forgePr}"]`;
638
895
  if (el.dataset.forgeDeploy) return `[data-forge-deploy="${el.dataset.forgeDeploy}"]`;
896
+ if (el.dataset.forgeThread) return `[data-forge-thread="${el.dataset.forgeThread}"]`;
639
897
  if (el.dataset.forgeNode) return `[data-forge-node="${el.dataset.forgeNode}"]`;
640
898
  if (el.dataset.forgeMerge) return '[data-forge-merge]';
641
899
  if (el.dataset.forgeSpec) return `[data-forge-spec="${el.dataset.forgeSpec}"]`;
@@ -677,7 +935,7 @@ function draw(panel) {
677
935
  else status.textContent = `${shown.length} of ${plural(list.items.length, kindWords())}${list.at ? `, as of ${relativeTime(list.at, now())}` : ''}`;
678
936
 
679
937
  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}` : '');
938
+ 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
939
  notice.hidden = !words;
682
940
  notice.textContent = words;
683
941
 
@@ -695,11 +953,12 @@ function draw(panel) {
695
953
  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
954
 
697
955
  const detail = $('forge-detail');
698
- detail.hidden = ui.selected === null && !ui.deployItem;
699
- detail.setAttribute('aria-label', ui.deployItem ? 'Selected deploy' : 'Selected pull request');
956
+ detail.hidden = ui.selected === null && !ui.deployItem && ui.thread === null;
957
+ detail.setAttribute('aria-label', ui.thread !== null ? 'Selected thread' : ui.deployItem ? 'Selected deploy' : 'Selected pull request');
700
958
  detail.innerHTML = detailHtml();
701
959
 
702
- const rate = (ui.detail && ui.detail.rate) || (list && list.rate) || '';
960
+ paintIdea();
961
+ const rate = (ui.threadDetail && ui.threadDetail.rate) || (ui.detail && ui.detail.rate) || (list && list.rate) || '';
703
962
  $('forge-rate').textContent = rate;
704
963
  paintButton();
705
964
  }
@@ -713,6 +972,7 @@ function drawNotes() {
713
972
  notice.hidden = !words;
714
973
  notice.textContent = words;
715
974
  $('forge-detail').hidden = true;
975
+ paintIdea();
716
976
  $('forge-rate').textContent = (n.data && n.data.rate) || '';
717
977
  paintNotes();
718
978
  paintButton();
@@ -722,11 +982,11 @@ function drawNotes() {
722
982
  function paintButton() {
723
983
  const btn = $('forge-btn');
724
984
  if (!btn) return;
725
- const lit = ui.selected !== null || !!ui.deployItem;
985
+ const lit = ui.selected !== null || !!ui.deployItem || ui.thread !== null;
726
986
  const tag = $('forge-btn-lit');
727
987
  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');
988
+ tag.textContent = ui.thread !== null ? `thread #${ui.thread}` : ui.deployItem ? 'deploy' : lit ? `#${ui.selected}` : '';
989
+ 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
990
  btn.setAttribute('aria-expanded', String(ui.open));
731
991
  }
732
992
 
@@ -981,6 +1241,15 @@ export async function refreshForge({ quiet = false } = {}) {
981
1241
  if (again) { ui.deployItem = again; relight(); }
982
1242
  return;
983
1243
  }
1244
+ if (ui.thread !== null) {
1245
+ const at = ui.thread;
1246
+ const token = ++threadToken;
1247
+ const res = await request(threadUrl(at), true);
1248
+ if (token !== threadToken || !res.ok) return;
1249
+ ui.threadDetail = { thread: res.body.thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
1250
+ relight();
1251
+ return;
1252
+ }
984
1253
  if (n !== null && ui.selected === n) {
985
1254
  const token = ++pullToken;
986
1255
  const res = await request(pullUrl(n), true);
@@ -1027,6 +1296,12 @@ function onClick(e) {
1027
1296
  if (ui.selected === n) clearPull(); else selectPull(n);
1028
1297
  return;
1029
1298
  }
1299
+ const thr = t.closest('[data-forge-thread]');
1300
+ if (thr) {
1301
+ const n = Number(thr.dataset.forgeThread);
1302
+ if (ui.thread === n) clearThread(); else selectThread(n);
1303
+ return;
1304
+ }
1030
1305
  const dep = t.closest('[data-forge-deploy]');
1031
1306
  if (dep) {
1032
1307
  if (ui.deploy === dep.dataset.forgeDeploy) clearDeploy(); else selectDeploy(dep.dataset.forgeDeploy);
@@ -1062,6 +1337,10 @@ function onClick(e) {
1062
1337
  else if (action.dataset.action === 'cancel-notes') { closeNotesConfirm(); announce('Cancelled. Nothing was posted.'); }
1063
1338
  else if (action.dataset.action === 'clear-forge-pr') { clearPull(); $('forge-title').focus(); }
1064
1339
  else if (action.dataset.action === 'clear-forge-deploy') { clearDeploy(); $('forge-title').focus(); }
1340
+ else if (action.dataset.action === 'clear-forge-thread') { clearThread(); $('forge-title').focus(); }
1341
+ else if (action.dataset.action === 'idea-open') toggleIdea(!ui.idea.open);
1342
+ else if (action.dataset.action === 'idea-send') sendIdea();
1343
+ else if (action.dataset.action === 'idea-cancel') { toggleIdea(false); announce('Cancelled. Nothing was opened.'); }
1065
1344
  }
1066
1345
 
1067
1346
  function onChange(e) {
@@ -1126,6 +1405,11 @@ export function initForgePanel(config, opts = {}) {
1126
1405
  if (opts.clock) now = opts.clock;
1127
1406
  btn.hidden = false;
1128
1407
  panel.hidden = false;
1408
+ // `threads: false` in the config turns the tab off; a server that does not say has threads
1409
+ const threadsOn = config.threads !== false;
1410
+ const tab = panel.querySelector('[data-forge-kind="threads"]');
1411
+ if (tab) tab.hidden = !threadsOn;
1412
+ state.threadIndex = threadsOn ? { for: threadsFor, ensure: ensureThreadsList, open: openThread } : null;
1129
1413
  registerSheet('forge', panel);
1130
1414
  paintFilters();
1131
1415
  paint();
@@ -1142,6 +1426,12 @@ export function initForgePanel(config, opts = {}) {
1142
1426
  confirm.addEventListener('keydown', onConfirmKey);
1143
1427
  }
1144
1428
  offScene = onSceneTouch(onNodesChange);
1429
+ // A node's panel lists the threads that mention it; a click there shows one in this panel
1430
+ const drawer = $('spec-drawer');
1431
+ if (drawer) drawer.addEventListener('click', (e) => {
1432
+ const open = e.target.closest && e.target.closest('[data-thread-open]');
1433
+ if (open && state.threadIndex) openThread(Number(open.dataset.threadOpen));
1434
+ });
1145
1435
  }
1146
1436
  return true;
1147
1437
  }
@@ -1157,6 +1447,11 @@ export function resetForgePanelForTests(clock = () => Date.now()) {
1157
1447
  listToken++;
1158
1448
  pullToken++;
1159
1449
  notesToken++;
1450
+ threadToken++;
1451
+ indexToken++;
1452
+ indexLoading = false;
1453
+ indexCache = null;
1160
1454
  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() });
1455
+ state.threadIndex = null;
1456
+ 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() });
1162
1457
  }