@popcorn245/cornfield 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/config.js +3 -2
- package/explorer/explorer.css +49 -4
- package/explorer/forge-panel-model.js +4 -2
- package/explorer/forge-panel.js +207 -34
- package/explorer/index.html +9 -0
- package/explorer/layers.js +2 -0
- package/explorer/panels.js +6 -3
- package/explorer/render.js +8 -0
- package/explorer/runs-map.js +135 -0
- package/explorer/runs-model.js +146 -0
- package/explorer/runs-view.js +101 -0
- package/explorer/state.js +6 -0
- package/package.json +1 -1
- package/server/agent-runs.js +290 -10
- package/server/db.js +26 -0
- package/server/forge-forgejo.js +7 -0
- package/server/forge-github.js +5 -0
- package/server/forge.js +5 -0
- package/server/server.js +2 -0
package/core/config.js
CHANGED
|
@@ -152,7 +152,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
152
152
|
personas: { roles: null, parent: 'root' },
|
|
153
153
|
cloud: { parent: 'root' },
|
|
154
154
|
releaseNotes: { breakingLabel: 'breaking' },
|
|
155
|
-
agents: { authors: [], labels: [], branchPrefixes: [], bots: true, transport: 'forge', maxOpen: DEFAULT_MAX_OPEN, profiles: [], runners: [], quietAfter: DEFAULT_QUIET_AFTER, limits: {}, on: {} },
|
|
155
|
+
agents: { authors: [], labels: [], branchPrefixes: [], bots: true, transport: 'forge', maxOpen: DEFAULT_MAX_OPEN, profiles: [], runners: [], quietAfter: DEFAULT_QUIET_AFTER, limits: {}, on: {}, refuseCollisions: false },
|
|
156
156
|
hooks: [],
|
|
157
157
|
hookPoll: { specs: '5m' },
|
|
158
158
|
threads: { label: 'idea' },
|
|
@@ -165,7 +165,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
165
165
|
const KEYS = Object.keys(DEFAULTS);
|
|
166
166
|
/** The keys of `proposals` when it is an object: the folder, and whether an approval also lands the spec PR (opt-in, off unless true) */
|
|
167
167
|
export const PROPOSALS_KEYS = Object.freeze(['dir', 'approveLands']);
|
|
168
|
-
const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots', 'transport', 'maxOpen', 'profiles', 'runners', 'quietAfter', 'limits', 'on'];
|
|
168
|
+
const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots', 'transport', 'maxOpen', 'profiles', 'runners', 'quietAfter', 'limits', 'on', 'refuseCollisions'];
|
|
169
169
|
const PROFILE_NAME = new RegExp(PROFILE_SOURCE);
|
|
170
170
|
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
171
171
|
const isText = (v) => typeof v === 'string' && v.trim() !== '';
|
|
@@ -363,6 +363,7 @@ export const triggersOf = (config) => Object.entries(isObject(config.agents?.on)
|
|
|
363
363
|
function agentRunProblems(a) {
|
|
364
364
|
const out = [];
|
|
365
365
|
if (a.transport !== undefined && !AGENT_TRANSPORTS.includes(a.transport)) out.push(`agents.transport is ${AGENT_TRANSPORTS.map((t) => JSON.stringify(t)).join(', ')}: where a request goes: "forge" posts it as a comment (the default), "push" sends it as an agent.requested hook event, "pull" keeps it for a runner to claim (docs/agents.md#transports).`);
|
|
366
|
+
if (a.refuseCollisions !== undefined && typeof a.refuseCollisions !== 'boolean') out.push('agents.refuseCollisions is true or false: whether a new request that names a node an open run is already changing is refused (false unless set).');
|
|
366
367
|
if (a.maxOpen !== undefined && !(Number.isInteger(a.maxOpen) && a.maxOpen >= 1 && a.maxOpen <= MAX_OPEN_CEILING)) out.push(`agents.maxOpen is a whole number from 1 to ${MAX_OPEN_CEILING}: the most open runs cornfield asks for at once (${DEFAULT_MAX_OPEN} unless set).`);
|
|
367
368
|
if (a.profiles !== undefined) {
|
|
368
369
|
if (!Array.isArray(a.profiles) || a.profiles.length > MAX_PROFILES || !a.profiles.every((p) => typeof p === 'string' && PROFILE_NAME.test(p)) || new Set(a.profiles).size !== a.profiles.length) {
|
package/explorer/explorer.css
CHANGED
|
@@ -796,11 +796,13 @@ button.hx-proposal-item:focus-visible { outline: 2px solid var(--hx-indigo); out
|
|
|
796
796
|
.hx-forge * { touch-action: pan-y; }
|
|
797
797
|
.hx-forge.forge-open { pointer-events: auto; }
|
|
798
798
|
@media (min-width: 768px) {
|
|
799
|
-
|
|
799
|
+
/* The panel's width is the stage's `--hx-forge-w`, so what makes room for it reads the same number (the Runs view's compare widens it, below) */
|
|
800
|
+
.hx-stage { --hx-forge-w: min(400px, calc(100% - 24px)); }
|
|
801
|
+
.hx-forge { position: absolute; top: 0; left: 0; bottom: 0; width: var(--hx-forge-w); border-left: 0; transform: translateX(-100%); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); }
|
|
800
802
|
.hx-forge.forge-open { transform: translateX(0); }
|
|
801
|
-
/* The proposal bar and the player make room for it */
|
|
802
|
-
.hx-stage:has(.forge-open) .hx-proposal-bar { left:
|
|
803
|
-
.hx-stage:has(.forge-open):has(.drawer-open) .hx-proposal-bar { width: min(720px, calc(100% -
|
|
803
|
+
/* The proposal bar and the player make room for it: 12px past its edge, and the node panel's 480px (+24) when that is open too */
|
|
804
|
+
.hx-stage:has(.forge-open) .hx-proposal-bar { left: calc(var(--hx-forge-w) + 12px); width: min(720px, calc(100% - var(--hx-forge-w) - 36px)); }
|
|
805
|
+
.hx-stage:has(.forge-open):has(.drawer-open) .hx-proposal-bar { width: min(720px, calc(100% - var(--hx-forge-w) - 540px)); }
|
|
804
806
|
}
|
|
805
807
|
@media (max-width: 767px) {
|
|
806
808
|
.hx-forge:not(.forge-open) { display: none; }
|
|
@@ -924,3 +926,46 @@ a.hx-forge-row:hover, button.hx-forge-row:hover { background: var(--hx-control-h
|
|
|
924
926
|
.hx-thread-compose { font-family: inherit; min-height: 8em; }
|
|
925
927
|
.hx-thread-open { display: block; max-width: 100%; text-align: left; overflow-wrap: anywhere; }
|
|
926
928
|
#forge-idea[hidden], #idea-form[hidden], #idea-message[hidden] { display: none; }
|
|
929
|
+
|
|
930
|
+
/* ---- agent runs on the map (explorer/runs-map.js, issue #82) ---- */
|
|
931
|
+
.hx-run-overlay { position: absolute; inset: 0; z-index: 2; overflow: hidden; pointer-events: none; }
|
|
932
|
+
.hx-run-overlay[hidden] { display: none; }
|
|
933
|
+
.hx-run-mark { position: absolute; width: 0; height: 0; --r: 14px; }
|
|
934
|
+
.hx-run-ring { position: absolute; left: calc(var(--r) * -1); top: calc(var(--r) * -1); width: calc(var(--r) * 2); height: calc(var(--r) * 2); box-sizing: border-box; border-radius: 50%; border: 2px solid var(--hx-teal-text); }
|
|
935
|
+
/* In flight: the ring pulses outward and fades; when motion is reduced it is a still, slightly thicker ring */
|
|
936
|
+
.hx-run-mark--flight .hx-run-ring { animation: hx-run-pulse 1.8s ease-out infinite; }
|
|
937
|
+
@keyframes hx-run-pulse { 0% { transform: scale(0.9); opacity: 1; } 100% { transform: scale(1.55); opacity: 0.1; } }
|
|
938
|
+
.hx-run-overlay.is-static .hx-run-ring { animation: none; transform: none; opacity: 1; border-width: 3px; }
|
|
939
|
+
@media (prefers-reduced-motion: reduce) { .hx-run-ring { animation: none !important; transform: none; opacity: 1; border-width: 3px; } }
|
|
940
|
+
/* Collision: dashed, a sign and words; the same ring whether or not colour is seen */
|
|
941
|
+
.hx-run-mark--collision .hx-run-ring { border: 3px dashed var(--hx-amber); animation: none; }
|
|
942
|
+
.hx-run-tag { position: absolute; left: 0; top: calc(var(--r) + 4px); transform: translateX(-50%); white-space: nowrap; padding: 1px 7px; border-radius: 9999px; border: 1px solid var(--hx-amber); background: var(--hx-well); color: var(--hx-amber); font-size: 11px; font-weight: 700; }
|
|
943
|
+
.hx-run-swatch { display: inline-block; width: 14px; height: 14px; flex: none; box-sizing: border-box; border-radius: 50%; border: 2px solid var(--hx-teal-text); }
|
|
944
|
+
.hx-run-swatch--collision { border: 2px dashed var(--hx-amber); }
|
|
945
|
+
|
|
946
|
+
/* The Runs view (forge-panel.js, runs-view.js): each run, its limits beside what was reported, its timeline; the compare puts profiles side by side */
|
|
947
|
+
.hx-runs-list { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
|
948
|
+
.hx-run { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border: 1px solid var(--hx-line); border-radius: 10px; background: var(--hx-control); overflow-wrap: anywhere; min-width: 0; }
|
|
949
|
+
.hx-run-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; }
|
|
950
|
+
.hx-run-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
951
|
+
.hx-run-collide { margin: 0; padding: 2px 8px; border: 1px dashed var(--hx-amber); border-radius: 8px; color: var(--hx-amber); font-weight: 600; }
|
|
952
|
+
.hx-run-limits { display: grid; grid-template-columns: auto 1fr; gap: 0 10px; margin: 0; font-size: 11px; }
|
|
953
|
+
.hx-run-limits dt { color: var(--hx-slate-300); font-weight: 600; }
|
|
954
|
+
.hx-run-limits dd { margin: 0; color: var(--hx-text-2); }
|
|
955
|
+
.hx-run-timeline { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0 0 0 18px; font-size: 11px; }
|
|
956
|
+
.hx-run-timeline li { color: var(--hx-text-2); }
|
|
957
|
+
.hx-run-timeline time { color: var(--hx-slate-300); margin-right: 6px; }
|
|
958
|
+
.hx-run-note { display: block; color: var(--hx-slate-300); white-space: pre-wrap; }
|
|
959
|
+
.hx-run-compare { border: 1px solid var(--hx-line); border-left: 4px solid var(--hx-teal-strong); border-radius: 10px; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
|
960
|
+
.hx-run-compare-title { margin: 0; font-size: 12px; font-weight: 700; color: var(--hx-text-1); }
|
|
961
|
+
.hx-run-columns { display: grid; grid-template-columns: 1fr; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
|
962
|
+
@media (min-width: 1100px) {
|
|
963
|
+
.hx-stage:has(.hx-forge .hx-run-compare) { --hx-forge-w: min(780px, calc(100% - 24px)); }
|
|
964
|
+
.hx-run-columns { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
965
|
+
}
|
|
966
|
+
/* With the node panel open as well, a 780px compare only fits beside it from 1600px: below that the panel keeps its width and the
|
|
967
|
+
profiles stack, so neither panel nor the proposal bar between them is covered */
|
|
968
|
+
@media (min-width: 1100px) and (max-width: 1599px) {
|
|
969
|
+
.hx-stage:has(.hx-forge .hx-run-compare):has(.drawer-open) { --hx-forge-w: min(400px, calc(100% - 24px)); }
|
|
970
|
+
.hx-stage:has(.drawer-open) .hx-run-columns { grid-template-columns: 1fr; }
|
|
971
|
+
}
|
|
@@ -304,10 +304,12 @@ export function mentionsOf(refs, nodes) {
|
|
|
304
304
|
* @returns {{ nodes: object[], unresolved: string[], changed: Set<string>, failing: Set<string>, perMessage: Map<string, { nodes: object[], unresolved: string[] }> }}
|
|
305
305
|
*/
|
|
306
306
|
export function litForThread(thread, nodes) {
|
|
307
|
-
|
|
307
|
+
// A thread that is not there (not loaded, or an answer without one) lights nothing, and never throws
|
|
308
|
+
const messages = thread && Array.isArray(thread.messages) ? thread.messages.filter((m) => m && typeof m.body === 'string') : [];
|
|
309
|
+
const all = linksOf(messages.map((m) => m.body));
|
|
308
310
|
const got = mentionsOf(all.refs, nodes);
|
|
309
311
|
const perMessage = new Map();
|
|
310
|
-
for (const m of
|
|
312
|
+
for (const m of messages) perMessage.set(m.id, messageNodes(m.body, nodes));
|
|
311
313
|
return { nodes: got.nodes, unresolved: got.unresolved, changed: new Set(got.ids), failing: new Set(), perMessage };
|
|
312
314
|
}
|
|
313
315
|
|
package/explorer/forge-panel.js
CHANGED
|
@@ -69,8 +69,10 @@ import { relativeTime } from './review-model.js';
|
|
|
69
69
|
import { LAYER_DEFS, toggleLayer } from './layers.js';
|
|
70
70
|
import { WORK_URL, groupWork, filterGroups, groupWords, workStatus, markWords } from './agents-model.js';
|
|
71
71
|
import {
|
|
72
|
-
RUNS_URL, runsUrl, stopUrl, KINDS, aboutWords, statusWords, statusWhy, runMeta, canStop, runsStatus, triggerNotice, startBody, startWords, stopWords, confirmStartWords,
|
|
72
|
+
RUNS_URL, runsUrl, stopUrl, KINDS, aboutWords, statusWords, statusWhy, runMeta, canStop, runsStatus, triggerNotice, startBody, startWords, stopWords, confirmStartWords, litOfRun, changeWords,
|
|
73
73
|
} from './runs-model.js';
|
|
74
|
+
import { runsHtml } from './runs-view.js';
|
|
75
|
+
import { setRunsOnMap, clearRunsOnMap } from './runs-map.js';
|
|
74
76
|
import {
|
|
75
77
|
STATES, defaultFilters, listUrl, pullUrl, serverState, filterItems, facetsOf, stateWords, metaWords, checkWords, checksWords, litFor, errorWords,
|
|
76
78
|
landingUrl, cancelLandingUrl, landingWords, mergeBlock, rerunWhy, confirmMerge, mergeBody, actionUrl, actionWords,
|
|
@@ -116,6 +118,8 @@ const ui = {
|
|
|
116
118
|
notes: freshNotes(), // the Release notes view
|
|
117
119
|
runs: null, // { data, error } of the Agents tab's runs: what /api/agent-runs answered
|
|
118
120
|
runNote: null, // { text, ok }: what the last Stop came to
|
|
121
|
+
runSel: null, // the run lit on the map (Light on the map), by id
|
|
122
|
+
runTimelines: new Set(), // the runs whose timeline is open
|
|
119
123
|
agent: null, // { opener, refs, posting } while the Start an agent dialog is open
|
|
120
124
|
};
|
|
121
125
|
|
|
@@ -126,10 +130,14 @@ function freshNotes() {
|
|
|
126
130
|
|
|
127
131
|
/** The New idea form: whether it is open, one send in flight, and what the last send came to */
|
|
128
132
|
function freshIdea() {
|
|
129
|
-
|
|
133
|
+
// `opener` is the id of the node whose panel button that opened the form ('' when the bar's own button did), `node` the node it was about
|
|
134
|
+
return { open: false, busy: false, message: '', bad: false, link: null, opener: '', node: null };
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
let wired = false;
|
|
138
|
+
let runsTimer = 0;
|
|
139
|
+
/** How often the runs are read again while one is open */
|
|
140
|
+
export const RUNS_POLL_MS = 60_000;
|
|
133
141
|
let threadToken = 0;
|
|
134
142
|
let indexToken = 0;
|
|
135
143
|
let indexLoading = false;
|
|
@@ -188,11 +196,46 @@ async function loadWork(refresh, token) {
|
|
|
188
196
|
}
|
|
189
197
|
|
|
190
198
|
/** Loads the runs for the Agents tab (a refresh also has cornfield look at the forge for their reports first); a failure is a line, never a blank panel */
|
|
191
|
-
async function loadRuns(refresh) {
|
|
199
|
+
async function loadRuns(refresh, { say = true } = {}) {
|
|
200
|
+
const before = ui.runs && ui.runs.data ? ui.runs.data.runs : null;
|
|
192
201
|
const res = await request(runsUrl(refresh), refresh);
|
|
193
202
|
ui.runs = res.ok && Array.isArray(res.body.runs) ? { data: res.body, error: '' } : { data: ui.runs && ui.runs.data, error: errorWords(res.status, res.body) };
|
|
203
|
+
if (!(ui.runs && ui.runs.data)) return;
|
|
204
|
+
// The map shows the open runs' nodes; a run lit on the map follows its nodes; what changed is said, once the first answer is in
|
|
205
|
+
setRunsOnMap(ui.runs.data.runs);
|
|
206
|
+
syncRunsPoll();
|
|
207
|
+
if (ui.runSel) relightRun();
|
|
208
|
+
const said = say ? changeWords(before, ui.runs.data.runs) : [];
|
|
209
|
+
if (said.length) announce(said.join('. '));
|
|
194
210
|
}
|
|
195
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Looks at the runs again in `RUNS_POLL_MS`, but only while one is open: a run's answer that has none open disarms it, and the next answer
|
|
214
|
+
* with one open (a run requested here, or a look from elsewhere) arms it again. One timer at most; never while the page is hidden or put
|
|
215
|
+
* away (`pagehide`). So the marks on the map and the words follow the runs.
|
|
216
|
+
*/
|
|
217
|
+
function syncRunsPoll() {
|
|
218
|
+
const open = !!(ui.runs && ui.runs.data && ui.runs.data.open > 0);
|
|
219
|
+
if (!open || pageAway) {
|
|
220
|
+
clearTimeout(runsTimer);
|
|
221
|
+
runsTimer = 0;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (runsTimer) return;
|
|
225
|
+
runsTimer = setTimeout(async () => {
|
|
226
|
+
runsTimer = 0;
|
|
227
|
+
if (!(typeof document !== 'undefined' && document.hidden)) {
|
|
228
|
+
await loadRuns(false);
|
|
229
|
+
paint();
|
|
230
|
+
}
|
|
231
|
+
syncRunsPoll();
|
|
232
|
+
}, RUNS_POLL_MS);
|
|
233
|
+
}
|
|
234
|
+
/** The page is being put away (closed, or into the back-forward cache): no look until it is shown again */
|
|
235
|
+
let pageAway = false;
|
|
236
|
+
function onPageHide() { pageAway = true; syncRunsPoll(); }
|
|
237
|
+
function onPageShow() { pageAway = false; syncRunsPoll(); }
|
|
238
|
+
|
|
196
239
|
/** Loads the list the kind and state name (again, past the server's cache, on a refresh) */
|
|
197
240
|
async function loadList(refresh = false, quiet = false) {
|
|
198
241
|
const token = ++listToken;
|
|
@@ -293,7 +336,10 @@ export function clearPull() {
|
|
|
293
336
|
/** What the selected pull request lights, worked out again: for the nodes now on the map */
|
|
294
337
|
function relight() {
|
|
295
338
|
ui.nodesSeen = state.nodes;
|
|
296
|
-
if (ui.
|
|
339
|
+
if (ui.runSel) {
|
|
340
|
+
const run = runById(ui.runSel);
|
|
341
|
+
state.forgeLit = run ? litOfRun(run) : null;
|
|
342
|
+
} else if (ui.threadDetail && ui.threadDetail.thread) {
|
|
297
343
|
ui.litThread = litForThread(ui.threadDetail.thread, state.nodes);
|
|
298
344
|
state.forgeLit = { changed: ui.litThread.changed, failing: ui.litThread.failing };
|
|
299
345
|
} else if (ui.deployItem) {
|
|
@@ -359,6 +405,7 @@ export function clearDeploy() {
|
|
|
359
405
|
|
|
360
406
|
/** Forgets the selected thread without painting: another thing is being selected */
|
|
361
407
|
function dropThread() {
|
|
408
|
+
ui.runSel = null;
|
|
362
409
|
threadToken++;
|
|
363
410
|
ui.thread = null;
|
|
364
411
|
ui.threadDetail = null;
|
|
@@ -367,9 +414,13 @@ function dropThread() {
|
|
|
367
414
|
}
|
|
368
415
|
|
|
369
416
|
/** Selects a thread: asks for its messages (read-only), then lights the nodes they mention */
|
|
417
|
+
/** The thread a successful answer holds, or null (a failure, or an answer without one) */
|
|
418
|
+
const threadOf = (res) => (res && res.ok && res.body && res.body.thread && typeof res.body.thread === 'object' && Array.isArray(res.body.thread.messages) ? res.body.thread : null);
|
|
419
|
+
|
|
370
420
|
async function selectThread(n) {
|
|
371
421
|
const token = ++threadToken;
|
|
372
422
|
pullToken++;
|
|
423
|
+
ui.runSel = null;
|
|
373
424
|
ui.selected = null;
|
|
374
425
|
ui.detail = null;
|
|
375
426
|
ui.detailError = '';
|
|
@@ -388,13 +439,15 @@ async function selectThread(n) {
|
|
|
388
439
|
paint();
|
|
389
440
|
const res = await request(threadUrl(n), false);
|
|
390
441
|
if (token !== threadToken) return;
|
|
391
|
-
|
|
392
|
-
|
|
442
|
+
// An answer without a thread in it is a failure too: nothing to show or light
|
|
443
|
+
const thread = threadOf(res);
|
|
444
|
+
if (!thread) {
|
|
445
|
+
ui.threadError = res.ok ? `Thread ${n} could not be read: the answer had no thread in it. Refresh to ask again.` : errorWords(res.status, res.body);
|
|
393
446
|
announce(ui.threadError);
|
|
394
447
|
paint();
|
|
395
448
|
return;
|
|
396
449
|
}
|
|
397
|
-
ui.threadDetail = { thread
|
|
450
|
+
ui.threadDetail = { thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
|
|
398
451
|
relight();
|
|
399
452
|
$('forge-panel').querySelector('.hx-forge-body').scrollTop = 0; // the thread's messages are what was asked for, not the row that was tapped
|
|
400
453
|
announce(threadSelectionWords());
|
|
@@ -460,9 +513,45 @@ function threadsFor(nodeId) {
|
|
|
460
513
|
/** Opens or closes the form; the focus goes to the title, or back to the button */
|
|
461
514
|
function toggleIdea(open) {
|
|
462
515
|
ui.idea.open = open;
|
|
463
|
-
if (open) ui.idea.message = '';
|
|
516
|
+
if (open) { ui.idea.message = ''; ui.idea.opener = ''; ui.idea.node = null; }
|
|
464
517
|
paintIdea();
|
|
465
|
-
if (open) $('idea-title').focus(); else
|
|
518
|
+
if (open) $('idea-title').focus(); else focusIdeaOpener();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Puts the focus back on what opened the form: the node panel's button while it is still there, else the New idea button */
|
|
522
|
+
function focusIdeaOpener() {
|
|
523
|
+
const from = ui.idea.opener ? [...document.querySelectorAll('[data-thread-start]')].find((b) => b.dataset.threadStart === ui.idea.opener) : null;
|
|
524
|
+
(from && !from.closest('[inert]') ? from : $('idea-open')).focus();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** A node id that reads back as one mention: no brackets or line breaks, as core/mentions.js parses them */
|
|
528
|
+
const MENTIONABLE = /^[^[\]\r\n]{1,200}$/;
|
|
529
|
+
|
|
530
|
+
/** "Start a thread about this" (a node's panel): the New idea form, with the node's label as the title and its mention in the text */
|
|
531
|
+
function startThread(node) {
|
|
532
|
+
if (!node || ui.idea.busy || !ui.config || ui.config.threads === false) return;
|
|
533
|
+
if (ui.kind !== 'threads') {
|
|
534
|
+
ui.kind = 'threads';
|
|
535
|
+
ui.actNote = null;
|
|
536
|
+
ui.filters = defaultFilters('threads');
|
|
537
|
+
}
|
|
538
|
+
openForge();
|
|
539
|
+
paintFilters();
|
|
540
|
+
ui.idea.open = true;
|
|
541
|
+
ui.idea.message = '';
|
|
542
|
+
ui.idea.bad = false;
|
|
543
|
+
ui.idea.link = null;
|
|
544
|
+
ui.idea.node = { id: node.id, label: node.label };
|
|
545
|
+
ui.idea.opener = node.id;
|
|
546
|
+
// Never lose a draft: a title already typed stays, and text already typed stays under the mention, which is added once (`.value` is
|
|
547
|
+
// text, never markup, so the label and the id need no escaping here)
|
|
548
|
+
const title = $('idea-title');
|
|
549
|
+
if (title.value.trim() === '') title.value = String(node.label || node.id).slice(0, 256);
|
|
550
|
+
const text = $('idea-body');
|
|
551
|
+
const mention = MENTIONABLE.test(node.id) ? `[[${node.id}]]` : '';
|
|
552
|
+
if (mention && !text.value.includes(mention)) text.value = text.value.trim() === '' ? `${mention}\n\n` : `${mention}\n\n${text.value}`;
|
|
553
|
+
paintIdea();
|
|
554
|
+
$('idea-title').focus();
|
|
466
555
|
}
|
|
467
556
|
|
|
468
557
|
/** The one write: a POST to cornfield's own server, which opens an issue with the label as the maintainer */
|
|
@@ -494,13 +583,36 @@ async function sendIdea() {
|
|
|
494
583
|
}
|
|
495
584
|
paintIdea();
|
|
496
585
|
announce(ui.idea.message);
|
|
497
|
-
if (res.ok)
|
|
586
|
+
if (res.ok) await afterIdea(res);
|
|
587
|
+
else $('idea-title').focus();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* After an idea is opened: the list read again, the new thread selected (so what it mentions lights), the focus back on what opened
|
|
592
|
+
* the form, and all of it said once. The idea is on the forge whatever happens here, so a failure is said, never thrown.
|
|
593
|
+
*/
|
|
594
|
+
async function afterIdea(res) {
|
|
595
|
+
const about = ui.idea.node;
|
|
596
|
+
const n = res.body && res.body.number;
|
|
597
|
+
let trouble = '';
|
|
598
|
+
try {
|
|
498
599
|
ui.lists = {};
|
|
499
600
|
indexCache = null;
|
|
500
601
|
await loadList(true, true);
|
|
501
602
|
ensureThreadsList();
|
|
502
|
-
|
|
503
|
-
|
|
603
|
+
// The new thread is selected in the list, so what it mentions lights on the map once its messages are in
|
|
604
|
+
if (Number.isInteger(n)) await selectThread(n);
|
|
605
|
+
} catch (err) {
|
|
606
|
+
trouble = ` The list could not be read again (${err instanceof Error ? err.message : err}); Refresh asks for it.`;
|
|
607
|
+
}
|
|
608
|
+
// Then the focus goes back to what opened the form: after the selection, which draws the node's panel (and its button) again
|
|
609
|
+
try { focusIdeaOpener(); } catch { /* the page moved on */ }
|
|
610
|
+
ui.idea.opener = '';
|
|
611
|
+
ui.idea.node = null;
|
|
612
|
+
const shown = ui.thread === n && ui.threadDetail && ui.threadDetail.thread ? ` ${threadSelectionWords()}` : ui.thread === n && ui.threadError ? ` ${ui.threadError}` : '';
|
|
613
|
+
// Opened from a node: say that its "Mentioned in" lists the new thread
|
|
614
|
+
const listed = about && (threadsFor(about.id) || []).some((t) => t.number === n);
|
|
615
|
+
announce(`${ui.idea.message}${shown}${listed ? ` It is listed under Mentioned in for ${about.label}.` : ''}${trouble}`);
|
|
504
616
|
}
|
|
505
617
|
|
|
506
618
|
/** The New idea bar and form, from `ui.idea`; typed text and the focus are left alone */
|
|
@@ -924,6 +1036,8 @@ function findAgain(el) {
|
|
|
924
1036
|
if (el.dataset.forgeRerun) return `[data-forge-rerun="${el.dataset.forgeRerun}"]`;
|
|
925
1037
|
if (el.dataset.forgeAgent) return `[data-forge-agent="${el.dataset.forgeAgent}"]`;
|
|
926
1038
|
if (el.dataset.forgeStop) return `[data-forge-stop="${el.dataset.forgeStop}"]`;
|
|
1039
|
+
if (el.dataset.runSelect) return `[data-run-select="${el.dataset.runSelect}"]`;
|
|
1040
|
+
if (el.dataset.runTimeline) return `[data-run-timeline="${el.dataset.runTimeline}"]`;
|
|
927
1041
|
return null;
|
|
928
1042
|
}
|
|
929
1043
|
|
|
@@ -1010,20 +1124,51 @@ function paintRuns() {
|
|
|
1010
1124
|
}
|
|
1011
1125
|
const list = $('forge-runs-list');
|
|
1012
1126
|
if (!data) { list.innerHTML = ''; return; }
|
|
1013
|
-
list.innerHTML = data.runs.length ? data.runs
|
|
1127
|
+
list.innerHTML = data.runs.length ? runsHtml(viewContext(), data.runs) : '<li class="hx-forge-empty hx-faint">No agent has been asked for yet. Start one from an issue or a proposal.</li>';
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** What the Runs view needs to know of the page */
|
|
1131
|
+
function viewContext() {
|
|
1132
|
+
const f = ui.config && ui.config.forge;
|
|
1133
|
+
const base = f && safeUrl(f.url) ? f.url.replace(/\/+$/, '') : null;
|
|
1134
|
+
return {
|
|
1135
|
+
esc: escHtml, safeUrl, forgeName: forgeName(), forgeType: f ? f.type : 'github', repoUrl: base && f.repo ? `${base}/${f.repo}` : null,
|
|
1136
|
+
now: now(), nodes: state.nodes, selected: ui.runSel, timelines: ui.runTimelines, busy: !!ui.busy,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const runById = (id) => ((ui.runs && ui.runs.data && ui.runs.data.runs) || []).find((r) => r.runId === id) || null;
|
|
1141
|
+
|
|
1142
|
+
/** The nodes of a selected run, worked out again (the runs were read again, or the map changed) */
|
|
1143
|
+
function relightRun() {
|
|
1144
|
+
if (!runById(ui.runSel)) ui.runSel = null;
|
|
1145
|
+
relight();
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** Lights the nodes of a run on the map, like a pull request; the same button lets go of it */
|
|
1149
|
+
function selectRun(id) {
|
|
1150
|
+
const run = runById(id);
|
|
1151
|
+
if (!run) return;
|
|
1152
|
+
if (ui.runSel === id) {
|
|
1153
|
+
ui.runSel = null;
|
|
1154
|
+
relight();
|
|
1155
|
+
announce(`Run ${id} no longer lit on the map`);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
const had = ui.runSel;
|
|
1159
|
+
pullToken++;
|
|
1160
|
+
dropThread();
|
|
1161
|
+
Object.assign(ui, { selected: null, detail: null, detailError: '', lit: null, landing: null, note: null, deploy: null, deployItem: null, litDeploy: null });
|
|
1162
|
+
ui.runSel = id;
|
|
1163
|
+
relight();
|
|
1164
|
+
const n = (run.nodes || []).length;
|
|
1165
|
+
announce(n ? `Run ${id}: ${n} ${n === 1 ? 'node' : 'nodes'} lit on the map${had ? '' : ''}` : `Run ${id} has no node on the map yet`);
|
|
1014
1166
|
}
|
|
1015
1167
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
run.report && safeUrl(run.report.url) ? `<a class="hx-link" href="${escHtml(run.report.url)}" target="_blank" rel="noreferrer">Runner\'s logs <span class="hx-sr-only">(new tab)</span></a>` : '',
|
|
1021
|
-
].filter(Boolean).join(' · ');
|
|
1022
|
-
const why = statusWhy(run);
|
|
1023
|
-
const note = run.report && run.report.note ? `<p class="hx-forge-meta">Runner: ${escHtml(run.report.note)}</p>` : '';
|
|
1024
|
-
const stop = canStop(run) ? `<button type="button" class="hx-btn hx-forge-act" data-forge-stop="${escHtml(run.runId)}" aria-label="Stop run ${escHtml(run.runId)}"${ui.busy ? ' disabled' : ''}>Stop</button>` : '';
|
|
1025
|
-
return `<li class="hx-forge-file"><span class="hx-forge-state hx-forge-state--${escHtml(run.status === 'done' || run.status === 'merged' ? 'success' : run.status === 'failed' ? 'failure' : run.status === 'quiet' || run.status === 'stop-requested' ? 'queued' : 'running')}">${escHtml(statusWords(run))}</span> <span class="hx-name">${escHtml(run.kind)} on ${escHtml(aboutWords(run.refs))}</span> ${stop}
|
|
1026
|
-
<p class="hx-forge-meta">${escHtml(runMeta(run, now()))}</p>${why ? `<p class="hx-forge-why hx-faint">${escHtml(why)}</p>` : ''}${note}${links ? `<p class="hx-forge-meta">${links}</p>` : ''}</li>`;
|
|
1168
|
+
/** Opens or closes the timeline of a run (it stays open when the list is drawn again) */
|
|
1169
|
+
function toggleTimeline(id) {
|
|
1170
|
+
if (ui.runTimelines.has(id)) ui.runTimelines.delete(id); else ui.runTimelines.add(id);
|
|
1171
|
+
paint();
|
|
1027
1172
|
}
|
|
1028
1173
|
|
|
1029
1174
|
/** The panel's lines for the Release notes view: the status, a failure, the rate limit; the rest is paintNotes */
|
|
@@ -1045,11 +1190,11 @@ function drawNotes() {
|
|
|
1045
1190
|
function paintButton() {
|
|
1046
1191
|
const btn = $('forge-btn');
|
|
1047
1192
|
if (!btn) return;
|
|
1048
|
-
const lit = ui.selected !== null || !!ui.deployItem || ui.thread !== null;
|
|
1193
|
+
const lit = ui.selected !== null || !!ui.deployItem || ui.thread !== null || !!ui.runSel;
|
|
1049
1194
|
const tag = $('forge-btn-lit');
|
|
1050
1195
|
tag.hidden = !lit;
|
|
1051
|
-
tag.textContent = ui.thread !== null ? `thread #${ui.thread}` : ui.deployItem ? 'deploy' : lit ? `#${ui.selected}` : '';
|
|
1052
|
-
btn.setAttribute('aria-label', ui.thread !== null ? `Issues and pull requests, thread ${ui.thread} selected` : ui.deployItem ? 'Issues and pull requests, a deploy selected' : lit ? `Issues and pull requests, pull request ${ui.selected} selected` : 'Issues and pull requests');
|
|
1196
|
+
tag.textContent = ui.runSel ? 'run' : ui.thread !== null ? `thread #${ui.thread}` : ui.deployItem ? 'deploy' : lit ? `#${ui.selected}` : '';
|
|
1197
|
+
btn.setAttribute('aria-label', ui.runSel ? 'Issues and pull requests, a run lit on the map' : ui.thread !== null ? `Issues and pull requests, thread ${ui.thread} selected` : ui.deployItem ? 'Issues and pull requests, a deploy selected' : lit ? `Issues and pull requests, pull request ${ui.selected} selected` : 'Issues and pull requests');
|
|
1053
1198
|
btn.setAttribute('aria-expanded', String(ui.open));
|
|
1054
1199
|
}
|
|
1055
1200
|
|
|
@@ -1380,7 +1525,7 @@ async function stopRun(runId) {
|
|
|
1380
1525
|
ui.busy = '';
|
|
1381
1526
|
ui.runNote = { text: stopWords(res.status, res.body, runId), ok: res.ok };
|
|
1382
1527
|
announce(ui.runNote.text);
|
|
1383
|
-
await loadRuns(false);
|
|
1528
|
+
await loadRuns(false, { say: false });
|
|
1384
1529
|
paint();
|
|
1385
1530
|
}
|
|
1386
1531
|
|
|
@@ -1438,8 +1583,9 @@ export async function refreshForge({ quiet = false } = {}) {
|
|
|
1438
1583
|
const at = ui.thread;
|
|
1439
1584
|
const token = ++threadToken;
|
|
1440
1585
|
const res = await request(threadUrl(at), true);
|
|
1441
|
-
|
|
1442
|
-
|
|
1586
|
+
const thread = threadOf(res);
|
|
1587
|
+
if (token !== threadToken || !thread) return;
|
|
1588
|
+
ui.threadDetail = { thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
|
|
1443
1589
|
relight();
|
|
1444
1590
|
return;
|
|
1445
1591
|
}
|
|
@@ -1514,6 +1660,10 @@ function onClick(e) {
|
|
|
1514
1660
|
if (agent) { openAgent(agent.dataset.forgeAgent, `[data-forge-agent="${agent.dataset.forgeAgent}"]`); return; }
|
|
1515
1661
|
const stop = t.closest('[data-forge-stop]');
|
|
1516
1662
|
if (stop) { stopRun(stop.dataset.forgeStop); return; }
|
|
1663
|
+
const pick = t.closest('[data-run-select]');
|
|
1664
|
+
if (pick) { selectRun(pick.dataset.runSelect); return; }
|
|
1665
|
+
const timeline = t.closest('[data-run-timeline]');
|
|
1666
|
+
if (timeline) { toggleTimeline(timeline.dataset.runTimeline); return; }
|
|
1517
1667
|
const unland = t.closest('[data-forge-landing-cancel]');
|
|
1518
1668
|
if (unland) { cancelLanding(Number(unland.dataset.forgeLandingCancel)); return; }
|
|
1519
1669
|
const merge = t.closest('[data-forge-merge]');
|
|
@@ -1536,7 +1686,16 @@ function onClick(e) {
|
|
|
1536
1686
|
else if (action.dataset.action === 'clear-forge-deploy') { clearDeploy(); $('forge-title').focus(); }
|
|
1537
1687
|
else if (action.dataset.action === 'clear-forge-thread') { clearThread(); $('forge-title').focus(); }
|
|
1538
1688
|
else if (action.dataset.action === 'idea-open') toggleIdea(!ui.idea.open);
|
|
1539
|
-
else if (action.dataset.action === 'idea-send')
|
|
1689
|
+
else if (action.dataset.action === 'idea-send') {
|
|
1690
|
+
// Nothing of it may escape as an unhandled rejection: what went wrong is said, and the form is usable again
|
|
1691
|
+
sendIdea().catch((err) => {
|
|
1692
|
+
ui.idea.busy = false;
|
|
1693
|
+
ui.idea.message = `Something went wrong in the page: ${err instanceof Error ? err.message : err}`;
|
|
1694
|
+
ui.idea.bad = true;
|
|
1695
|
+
paintIdea();
|
|
1696
|
+
announce(ui.idea.message);
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1540
1699
|
else if (action.dataset.action === 'idea-cancel') { toggleIdea(false); announce('Cancelled. Nothing was opened.'); }
|
|
1541
1700
|
}
|
|
1542
1701
|
|
|
@@ -1606,10 +1765,14 @@ export function initForgePanel(config, opts = {}) {
|
|
|
1606
1765
|
const threadsOn = config.threads !== false;
|
|
1607
1766
|
const tab = panel.querySelector('[data-forge-kind="threads"]');
|
|
1608
1767
|
if (tab) tab.hidden = !threadsOn;
|
|
1609
|
-
state.threadIndex = threadsOn ? { for: threadsFor, ensure: ensureThreadsList, open: openThread } : null;
|
|
1768
|
+
state.threadIndex = threadsOn ? { for: threadsFor, ensure: ensureThreadsList, open: openThread, start: startThread, forge: forgeName } : null;
|
|
1610
1769
|
registerSheet('forge', panel);
|
|
1611
1770
|
paintFilters();
|
|
1612
1771
|
paint();
|
|
1772
|
+
// The map shows the open runs whether or not the panel is open: read them once now, and again now and then while any is open
|
|
1773
|
+
if (config.agents && config.agents.runs) {
|
|
1774
|
+
loadRuns(false).then(() => { if (ui.kind === 'work') paint(); }, () => {});
|
|
1775
|
+
}
|
|
1613
1776
|
if (!wired) {
|
|
1614
1777
|
wired = true;
|
|
1615
1778
|
btn.addEventListener('click', toggleForge);
|
|
@@ -1617,6 +1780,10 @@ export function initForgePanel(config, opts = {}) {
|
|
|
1617
1780
|
panel.addEventListener('change', onChange);
|
|
1618
1781
|
panel.addEventListener('input', onInput);
|
|
1619
1782
|
panel.addEventListener('keydown', onKey);
|
|
1783
|
+
if (typeof window !== 'undefined') {
|
|
1784
|
+
window.addEventListener('pagehide', onPageHide);
|
|
1785
|
+
window.addEventListener('pageshow', onPageShow);
|
|
1786
|
+
}
|
|
1620
1787
|
const agent = agentEl();
|
|
1621
1788
|
if (agent) {
|
|
1622
1789
|
agent.addEventListener('click', onAgentClick);
|
|
@@ -1633,7 +1800,9 @@ export function initForgePanel(config, opts = {}) {
|
|
|
1633
1800
|
const drawer = $('spec-drawer');
|
|
1634
1801
|
if (drawer) drawer.addEventListener('click', (e) => {
|
|
1635
1802
|
const open = e.target.closest && e.target.closest('[data-thread-open]');
|
|
1636
|
-
if (open && state.threadIndex) openThread(Number(open.dataset.threadOpen));
|
|
1803
|
+
if (open && state.threadIndex) { openThread(Number(open.dataset.threadOpen)); return; }
|
|
1804
|
+
const start = e.target.closest && e.target.closest('[data-thread-start]');
|
|
1805
|
+
if (start && state.threadIndex && state.selectedNode) startThread(state.selectedNode);
|
|
1637
1806
|
});
|
|
1638
1807
|
}
|
|
1639
1808
|
return true;
|
|
@@ -1657,5 +1826,9 @@ export function resetForgePanelForTests(clock = () => Date.now()) {
|
|
|
1657
1826
|
indexCache = null;
|
|
1658
1827
|
state.forgeLit = null;
|
|
1659
1828
|
state.threadIndex = null;
|
|
1660
|
-
|
|
1829
|
+
clearTimeout(runsTimer);
|
|
1830
|
+
runsTimer = 0;
|
|
1831
|
+
pageAway = false;
|
|
1832
|
+
clearRunsOnMap();
|
|
1833
|
+
Object.assign(ui, { runSel: null, runTimelines: new Set(), thread: null, threadDetail: null, threadError: '', litThread: null, idea: freshIdea(), config: null, open: false, kind: 'pulls', filters: defaultFilters(), lists: {}, work: null, workFilters: { agentsOnly: false, finished: false }, loading: false, error: '', selected: null, detail: null, landing: null, detailError: '', lit: null, deploy: null, deployItem: null, litDeploy: null, nodesSeen: null, busy: '', note: null, confirm: null, actNote: null, notes: freshNotes(), runs: null, runNote: null, agent: null });
|
|
1661
1834
|
}
|
package/explorer/index.html
CHANGED
|
@@ -48,6 +48,13 @@
|
|
|
48
48
|
<div id="flow-legend" hidden></div>
|
|
49
49
|
<div id="cloud-legend" class="hx-menu-section" hidden></div>
|
|
50
50
|
|
|
51
|
+
<!-- What the marks of agent runs mean, while there are runs on the map (runs-map.js) -->
|
|
52
|
+
<div id="runs-legend" class="hx-menu-section" hidden>
|
|
53
|
+
<div class="hx-menu-title"><span>Agent runs</span></div>
|
|
54
|
+
<div class="hx-legend-row"><span class="hx-run-swatch hx-run-swatch--flight" aria-hidden="true"></span><span>In flight: a pulsing ring (still under reduced motion)</span></div>
|
|
55
|
+
<div class="hx-legend-row"><span class="hx-run-swatch hx-run-swatch--collision" aria-hidden="true"></span><span>Collision: dashed ring, ⚠ and the words, where two open runs change one node</span></div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
51
58
|
<!-- What a proposal's marks mean, while one is open (proposal.js) -->
|
|
52
59
|
<div id="proposal-legend" class="hx-menu-section" hidden>
|
|
53
60
|
<div class="hx-menu-title"><span>Proposal marks</span></div>
|
|
@@ -171,6 +178,8 @@
|
|
|
171
178
|
<p id="map-announcer" class="hx-sr-only" role="status" aria-live="polite"></p>
|
|
172
179
|
<!-- Every label, drawn on its own transparent canvas over the map (explorer/labels.js); the map canvas still gets the pointer -->
|
|
173
180
|
<canvas id="label-canvas" aria-hidden="true"></canvas>
|
|
181
|
+
<!-- The nodes agent runs are changing, marked over the map (explorer/runs-map.js): a picture; the Runs view says the same in words -->
|
|
182
|
+
<div id="run-overlay" class="hx-run-overlay" aria-hidden="true" hidden></div>
|
|
174
183
|
|
|
175
184
|
<!-- Below 768px the proposal bar, the scenario player and the node panel stack here as bottom sheets (sheets.js); from 768px the dock is display: contents -->
|
|
176
185
|
<div id="sheet-dock" class="hx-dock">
|
package/explorer/layers.js
CHANGED
|
@@ -34,6 +34,7 @@ export const LAYER_DEFS = [
|
|
|
34
34
|
{ key: 'container', label: 'Containers', color: '#d6d3d1', desc: 'One service of a stack, from its compose file' },
|
|
35
35
|
{ key: 'device', label: 'Devices', color: '#2dd4bf', desc: 'Cameras, and live: phones, NFC tags, Home Assistant devices', live: true },
|
|
36
36
|
{ key: 'house', label: 'House', color: '#a3e635', desc: 'Floors and rooms (live)', live: true },
|
|
37
|
+
{ key: 'runs', label: 'Runs in flight', color: '#5eead4', desc: 'A pulsing ring on the nodes an open agent run is changing; a dashed ring and a warning where two runs change the same node', overlay: true },
|
|
37
38
|
{ key: 'persona', label: 'People', color: '#fb7185', desc: 'Personas, with a live count of their accounts (never a name)', live: true },
|
|
38
39
|
];
|
|
39
40
|
|
|
@@ -68,6 +69,7 @@ export function renderLayerToggles() {
|
|
|
68
69
|
const counts = {};
|
|
69
70
|
state.nodes.forEach((n) => { const key = n.type === 'external' ? externalLayer(n) : layerOf(n.type); counts[key] = (counts[key] || 0) + 1; });
|
|
70
71
|
counts.flow = state.edges.filter((e) => e.kind === 'flow').length;
|
|
72
|
+
counts.runs = state.runsMap ? state.runsMap.flight.size : 0;
|
|
71
73
|
|
|
72
74
|
container.innerHTML = LAYER_DEFS.map((layer) => {
|
|
73
75
|
const on = state.activeLayers[layer.key] !== false;
|
package/explorer/panels.js
CHANGED
|
@@ -132,10 +132,13 @@ function dataSummary(node) {
|
|
|
132
132
|
/** "Mentioned in": the threads whose text names the node (an idea, or a spec PR's conversation); each opens in the issues and PRs panel */
|
|
133
133
|
function threadsSection(node) {
|
|
134
134
|
const index = state.threadIndex;
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
if (!index) return '';
|
|
136
|
+
const threads = index.for(node.id);
|
|
137
|
+
// "Start a thread about this" shows whenever threads are on and a maintainer is signed in (the panel only sets `start` then)
|
|
138
|
+
const start = index.start ? `<div class="hx-line"><button type="button" class="hx-btn" data-thread-start="${escHtml(node.id)}" aria-label="Start a thread about this: opens the New idea form for ${escHtml(index.forge())}, with this node mentioned">Start a thread about this</button></div>` : '';
|
|
139
|
+
if (!threads || !threads.length) return start;
|
|
137
140
|
const rows = threads.map((t) => `<div class="hx-line"><span class="hx-glyph" style="--glyph:#fbbf24">◆</span><span><button type="button" class="hx-btn hx-thread-open" data-thread-open="${t.number}" aria-label="Open thread #${t.number}, ${escHtml(t.title)}, in the issues and pull requests panel">#${t.number} ${escHtml(t.title)}</button> <span class="hx-faint">${t.kind === 'proposal' ? 'proposal' : 'idea'}</span></span></div>`);
|
|
138
|
-
return section(`Mentioned in ${threads.length} ${threads.length === 1 ? 'thread' : 'threads'}:`, rows);
|
|
141
|
+
return section(`Mentioned in ${threads.length} ${threads.length === 1 ? 'thread' : 'threads'}:`, rows) + start;
|
|
139
142
|
}
|
|
140
143
|
|
|
141
144
|
/** Draws "Mentioned in" again in the open panel (the list was just read, or the map changed); nothing else in the panel moves */
|
package/explorer/render.js
CHANGED
|
@@ -150,6 +150,14 @@ function paint() {
|
|
|
150
150
|
// The labels repaint only when the camera, the scene or the size changed, never on a packet frame
|
|
151
151
|
const labels = getLabelLayer();
|
|
152
152
|
if (labels) labels.draw(scene, state.camera);
|
|
153
|
+
paintHooks.forEach((fn) => fn(scene, state.camera));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Things drawn over the map by the page itself (the runs' marks, explorer/runs-map.js): called after each draw with the scene and the camera */
|
|
157
|
+
const paintHooks = new Set();
|
|
158
|
+
export function onPaint(fn) {
|
|
159
|
+
paintHooks.add(fn);
|
|
160
|
+
return () => paintHooks.delete(fn);
|
|
153
161
|
}
|
|
154
162
|
|
|
155
163
|
const scheduler = createFrameScheduler({
|