@popcorn245/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.
- package/README.md +1 -0
- package/core/agent-contract.js +5 -2
- package/core/agent-contract.schema.json +9 -9
- package/core/config.js +94 -11
- package/core/mentions.js +239 -0
- package/explorer/explorer.css +12 -1
- package/explorer/forge-panel-model.js +88 -21
- package/explorer/forge-panel.js +512 -19
- package/explorer/index.html +46 -0
- package/explorer/panels.js +24 -0
- package/explorer/review-tokens-model.js +5 -1
- package/explorer/review-tokens.js +11 -7
- package/explorer/runs-model.js +107 -0
- package/explorer/state.js +7 -0
- package/package.json +1 -1
- package/server/agent-api.js +227 -0
- package/server/agent-runs.js +769 -0
- package/server/db.js +68 -0
- package/server/forge-actions.js +106 -5
- package/server/forge-forgejo.js +33 -5
- package/server/forge-github.js +12 -4
- package/server/forge-routes.js +33 -1
- package/server/forge.js +24 -7
- package/server/hooks.js +100 -1
- package/server/proposal-pulls.js +3 -3
- package/server/review-api.js +26 -12
- package/server/review-tokens.js +42 -25
- package/server/server.js +32 -8
- package/server/spec-poll.js +71 -14
- package/server/threads.js +275 -0
package/explorer/forge-panel.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|