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.
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @module explorer/runs-map
3
+ *
4
+ * Agent runs on the map (issue #82, spec 0005 D9): the nodes an open run is changing **pulse**, and a node two or more open runs are
5
+ * changing carries a **collision** mark. The marks are a layer of their own over the map (`#run-overlay`, a DOM layer, so one drawing serves the
6
+ * Canvas 2D and the WebGL renderer, and the pulse is plain CSS that `prefers-reduced-motion` turns into a still ring):
7
+ *
8
+ * - in flight a ring that pulses (still under reduced motion: a thicker ring, no movement)
9
+ * - collision a dashed ring, a warning sign and the words "Collision" with how many runs: shape and text, never colour alone
10
+ *
11
+ * The layer is a toggle in the Layers menu ("Runs in flight", on unless turned off). The layer is decoration for the eye (`aria-hidden`):
12
+ * what a screen reader gets is the Runs view, which names each run's nodes and collisions in words, and the live region, which says when a
13
+ * state or a collision changes. The positions come from the scene the map just drew, so they follow every view, zoom and pan.
14
+ */
15
+
16
+ import { state } from './state.js';
17
+ import { render, onPaint } from './render.js';
18
+ import { overlayOf } from './runs-model.js';
19
+ import { flowReduceMotion } from './flows.js';
20
+
21
+ /** The most marks drawn at once: past this the Runs view still lists them all */
22
+ export const MAX_MARKS = 80;
23
+
24
+ /** The key of the layer in the Layers menu and `state.activeLayers` */
25
+ export const RUNS_LAYER = 'runs';
26
+
27
+ /** @type {Map<string, HTMLElement>} the marks on the page by `<kind>:<node id>`, kept so a pulse is not restarted by every pan */
28
+ const marks = new Map();
29
+ let wired = false;
30
+ /** What the marks were last placed for: a paint with the same scene, camera, runs, layer and motion moves nothing (packet frames) */
31
+ let placed = '';
32
+ let placedFor = null;
33
+ let placedScene = null;
34
+ let placedLayer = null;
35
+ let reduce = null;
36
+
37
+ /** Whether motion is reduced: the page's setting, or a test's */
38
+ const reducedMotion = () => (reduce === null ? flowReduceMotion : reduce);
39
+ /** For tests: force reduced motion on or off (null: follow the page's setting) */
40
+ export function setReducedMotionForTests(on) { reduce = on; }
41
+
42
+ /** Whether the layer is on */
43
+ export const runsLayerOn = () => state.activeLayers[RUNS_LAYER] !== false;
44
+
45
+ /** Hands the map the runs the API listed: what pulses and what collides is worked out here, and the map is drawn again */
46
+ export function setRunsOnMap(runs) {
47
+ state.runsMap = Array.isArray(runs) && runs.length ? { ...overlayOf(runs), runs } : null;
48
+ wire();
49
+ paintLegend();
50
+ render();
51
+ }
52
+
53
+ /** Takes the marks off (the panel is reset, or there is no forge) */
54
+ export function clearRunsOnMap() {
55
+ state.runsMap = null;
56
+ clearMarks();
57
+ paintLegend();
58
+ }
59
+
60
+ function wire() {
61
+ if (wired) return;
62
+ wired = true;
63
+ onPaint((scene, camera) => syncRunOverlay(scene, camera));
64
+ }
65
+
66
+ function clearMarks() {
67
+ for (const el of marks.values()) el.remove();
68
+ marks.clear();
69
+ placed = '';
70
+ }
71
+
72
+ /** The words on a collision mark */
73
+ export const collisionTag = (n) => `Collision · ${n} runs`;
74
+
75
+ /**
76
+ * Puts the marks on the nodes the scene drew, at the camera. Marks are kept between frames (only their place changes), so a pan does not
77
+ * restart the pulse.
78
+ * @param {{ nodes: { id: string, x: number, y: number, r: number, alpha: number }[] }} scene
79
+ * @param {{ x: number, y: number, zoom: number }} camera
80
+ */
81
+ export function syncRunOverlay(scene, camera) {
82
+ const layer = typeof document !== 'undefined' ? document.getElementById('run-overlay') : null;
83
+ if (!layer) return;
84
+ const map = state.runsMap;
85
+ if (!map || !runsLayerOn() || (!map.flight.size && !map.collide.size)) { if (marks.size) clearMarks(); layer.hidden = true; placed = ''; return; }
86
+ // The map paints every frame while packets move; the marks only move with the scene or the camera
87
+ const key = `${scene.version}|${camera.x}|${camera.y}|${camera.zoom}|${reducedMotion()}`;
88
+ if (key === placed && placedFor === map && placedScene === scene && placedLayer === layer) return;
89
+ placed = key;
90
+ placedLayer = layer;
91
+ placedFor = map;
92
+ placedScene = scene;
93
+ layer.hidden = false;
94
+ layer.classList.toggle('is-static', reducedMotion());
95
+ const seen = new Set();
96
+ let n = 0;
97
+ for (const node of scene.nodes) {
98
+ if (n >= MAX_MARKS) break;
99
+ const runs = map.collide.get(node.id) || map.flight.get(node.id);
100
+ if (!runs || !(node.alpha > 0.2)) continue;
101
+ const kind = map.collide.has(node.id) ? 'collision' : 'flight';
102
+ const key = `${kind}:${node.id}`;
103
+ n += 1;
104
+ seen.add(key);
105
+ let el = marks.get(key);
106
+ if (!el) {
107
+ el = document.createElement('span');
108
+ el.className = `hx-run-mark hx-run-mark--${kind}`;
109
+ el.dataset.node = node.id;
110
+ el.dataset.kind = kind;
111
+ el.innerHTML = `<span class="hx-run-ring"></span>${kind === 'collision' ? `<span class="hx-run-tag"><span class="hx-run-sign">⚠</span> ${collisionTag(runs.length)}</span>` : ''}`;
112
+ layer.append(el);
113
+ marks.set(key, el);
114
+ }
115
+ const r = Math.max(10, (node.r + 9) * camera.zoom);
116
+ el.style.left = `${(node.x * camera.zoom + camera.x).toFixed(1)}px`;
117
+ el.style.top = `${(node.y * camera.zoom + camera.y).toFixed(1)}px`;
118
+ el.style.setProperty('--r', `${r.toFixed(1)}px`);
119
+ }
120
+ for (const [key, el] of marks) if (!seen.has(key)) { el.remove(); marks.delete(key); }
121
+ }
122
+
123
+ /** The legend in the Layers menu: shown while runs are on the map, saying in words what each mark is */
124
+ export function paintLegend() {
125
+ const el = typeof document !== 'undefined' ? document.getElementById('runs-legend') : null;
126
+ if (!el) return;
127
+ el.hidden = !(state.runsMap && (state.runsMap.flight.size || state.runsMap.collide.size));
128
+ }
129
+
130
+ /** For tests: forget the page */
131
+ export function resetRunsMapForTests() {
132
+ clearMarks();
133
+ state.runsMap = null;
134
+ reduce = null;
135
+ }
@@ -123,3 +123,149 @@ export function confirmStartWords(refs, forgeName) {
123
123
  const where = refs.proposal != null ? `the spec pull request of proposal ${refs.proposal}` : refs.thread != null ? `thread #${refs.thread}` : `issue #${refs.issue}`;
124
124
  return `This posts a request as a comment on ${where} on ${forgeName}, as you. Your runner picks it up. cornfield starts nothing and sends no credentials.`;
125
125
  }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Runs on the map (issue #82): the Runs view, the compare, the timeline, and what the map shows
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /** Runs asked for within this long of each other, on the same subject, kind and brief, are one request under different profiles */
132
+ export const COMPARE_WINDOW_MS = 3 * 60_000;
133
+
134
+ const plural = (n, one) => `${n} ${n === 1 ? one : `${one}s`}`;
135
+ const listWords = (items) => (items.length <= 2 ? items.join(' and ') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`);
136
+
137
+ /** The subject of a run as a key: the same issue, thread and proposal */
138
+ const subjectKey = (refs) => JSON.stringify([refs?.issue ?? null, refs?.thread ?? null, refs?.proposal ?? null]);
139
+
140
+ /**
141
+ * The runs for the view, newest first: a run on its own, or a **compare**: the same request (same subject, kind and brief) asked for under
142
+ * different profiles within `COMPARE_WINDOW_MS`, shown side by side. A group sits where its newest run is; inside it the runs are in profile order.
143
+ * @param {object[]} runs as the API lists them (newest first)
144
+ * @returns {({ kind: 'run', run: object } | { kind: 'compare', key: string, runs: object[] })[]}
145
+ */
146
+ export function groupRuns(runs) {
147
+ const clusters = [];
148
+ const byKey = new Map();
149
+ for (const run of runs) {
150
+ const key = `${subjectKey(run.refs)}|${run.kind}|${typeof run.brief === 'string' ? run.brief : ''}`;
151
+ const at = Date.parse(run.requestedAt);
152
+ const open = byKey.get(key) ?? [];
153
+ // Newest first: a run joins the cluster its time is within the window of, if that cluster has not got its profile yet
154
+ const home = open.find((c) => Math.abs(c.newest - at) <= COMPARE_WINDOW_MS && !c.runs.some((r) => (r.profile ?? '') === (run.profile ?? '')));
155
+ if (home) { home.runs.push(run); continue; }
156
+ const fresh = { key, newest: at, runs: [run], first: clusters.length };
157
+ open.push(fresh);
158
+ byKey.set(key, open);
159
+ clusters.push(fresh);
160
+ }
161
+ return clusters.map((c) => (c.runs.length > 1
162
+ ? { kind: 'compare', key: c.runs.map((r) => r.runId).join('+'), runs: [...c.runs].sort((a, b) => String(a.profile ?? '').localeCompare(String(b.profile ?? ''))) }
163
+ : { kind: 'run', run: c.runs[0] }));
164
+ }
165
+
166
+ /** What the group is titled: "Code on issue #7 under 2 profiles: claude and qwen" */
167
+ export function compareTitle(runs) {
168
+ const first = runs[0];
169
+ const kind = (KINDS.find(([k]) => k === first.kind) || [first.kind, first.kind])[1].split(':')[0];
170
+ return `${kind} on ${aboutWords(first.refs)} under ${plural(runs.length, 'profile')}: ${listWords(runs.map((r) => r.profile || 'no profile'))}`;
171
+ }
172
+
173
+ /** Limits beside what the runner reported: a row for each of minutes, turns, cost and tokens that has either, in words (never a bare dash) */
174
+ export function limitRows(run) {
175
+ const l = run.limits || {};
176
+ const r = run.report || {};
177
+ const rows = [];
178
+ const add = (name, limit, reported) => {
179
+ if (limit == null && reported == null) return;
180
+ rows.push({ name, limit: limit == null ? 'no limit asked' : String(limit), reported: reported == null ? 'not reported' : String(reported) });
181
+ };
182
+ add('Minutes', l.minutes, null);
183
+ add('Turns', l.turns, null);
184
+ add('Cost', l.cost, r.cost);
185
+ add('Tokens', null, r.tokens);
186
+ return rows;
187
+ }
188
+
189
+ /** The checks of a run's pull request, said short */
190
+ export function checksWords(run) {
191
+ if (!run.pull) return 'No pull request yet, so no checks.';
192
+ return { success: 'Checks passing', failure: 'Checks failing', running: 'Checks running', none: 'The pull request has no checks' }[run.checks] || 'Checks not read yet';
193
+ }
194
+
195
+ /** One line of a run's timeline, and the runner's words under it (data for the page to escape) */
196
+ export function timelineWords(entry) {
197
+ const by = entry.actor ? ` by ${entry.actor}` : '';
198
+ switch (entry.kind) {
199
+ case 'requested': return { what: `Requested${by}${entry.ref ? ` under the profile ${entry.ref}` : ''}`, note: '' };
200
+ case 'claimed': return { what: `Claimed${by}`, note: entry.note || '' };
201
+ case 'report': return { what: `Reported ${entry.ref || 'in'}${by}`, note: entry.note || '' };
202
+ // When cornfield saw it, not when it was pushed: the forge does not say
203
+ case 'push': return { what: `Push seen: ${String(entry.ref || '').slice(0, 7)}${entry.note ? ` on ${entry.note}` : ''}`, note: '' };
204
+ case 'pull-opened': return { what: `Opened pull request ${entry.ref || ''}${by}`.trim(), note: '' };
205
+ case 'checks': return { what: { success: 'Checks passed', failure: 'Checks failed', running: 'Checks running', none: 'No checks' }[entry.ref] || 'Checks changed', note: '' };
206
+ case 'stop-requested': return { what: `Asked to stop${by}`, note: entry.note || '' };
207
+ case 'merged': return { what: `Landed: pull request ${entry.ref || ''} merged`, note: '' };
208
+ case 'closed': return { what: `Pull request ${entry.ref || ''} closed without landing`, note: '' };
209
+ case 'quiet': return { what: 'Went quiet: nothing reported or pushed for a while', note: '' };
210
+ default: return { what: entry.kind, note: entry.note || '' };
211
+ }
212
+ }
213
+
214
+ /** The ids of the nodes a run names, as the names the map shows (an id the map lacks is shown as it is) */
215
+ export function nodeLabels(ids, nodes) {
216
+ const byId = new Map((nodes || []).map((n) => [n.id, n.label || n.id]));
217
+ return (ids || []).map((id) => byId.get(id) || id);
218
+ }
219
+
220
+ /**
221
+ * What the map shows of the runs: the nodes of the open runs (in flight), and those two or more open runs share (collisions). A run that
222
+ * is finished or quiet marks nothing.
223
+ * @param {object[]} runs
224
+ * @returns {{ flight: Map<string, string[]>, collide: Map<string, string[]> }} node id to the ids of the open runs that name it
225
+ */
226
+ export function overlayOf(runs) {
227
+ const flight = new Map();
228
+ for (const run of runs || []) {
229
+ if (!run.open) continue;
230
+ for (const id of run.nodes || []) flight.set(id, [...(flight.get(id) || []), run.runId]);
231
+ }
232
+ const collide = new Map([...flight].filter(([, ids]) => ids.length > 1));
233
+ return { flight, collide };
234
+ }
235
+
236
+ /** What a run lights when it is selected, like a pull request: its nodes, whatever its state */
237
+ export const litOfRun = (run) => ({ changed: new Set(run ? run.nodes || [] : []), failing: new Set() });
238
+
239
+ /** The collision of a run in words: "Collides with run-… on orders and cart" (null when it does not) */
240
+ export function collisionWords(run, runs, nodes) {
241
+ const others = (run.collidesWith || []);
242
+ if (!run.open || !others.length) return null;
243
+ const mine = new Set(run.nodes || []);
244
+ const shared = [...new Set(others.flatMap((id) => ((runs.find((r) => r.runId === id) || {}).nodes || []).filter((n) => mine.has(n))))];
245
+ return `Collides with ${listWords(others)}${shared.length ? ` on ${listWords(nodeLabels(shared, nodes))}` : ''}`;
246
+ }
247
+
248
+ /**
249
+ * What changed between two answers, as sentences for the live region: a run that appeared, a new state, a collision begun or cleared.
250
+ * Nothing before the first answer (`before` is null): the page does not read its whole list out on load.
251
+ * @param {object[] | null} before
252
+ * @param {object[]} after
253
+ * @returns {string[]}
254
+ */
255
+ export function changeWords(before, after) {
256
+ if (!before) return [];
257
+ const was = new Map(before.map((r) => [r.runId, r]));
258
+ const out = [];
259
+ for (const run of after) {
260
+ const old = was.get(run.runId);
261
+ if (!old) { out.push(`New run ${run.runId}: ${run.kind} on ${aboutWords(run.refs)}, ${statusWords(run).toLowerCase()}`); continue; }
262
+ if (old.status !== run.status) out.push(`Run ${run.runId} is now ${statusWords(run).toLowerCase()}`);
263
+ const was2 = new Set(old.open ? old.collidesWith || [] : []);
264
+ const now2 = new Set(run.open ? run.collidesWith || [] : []);
265
+ const added = [...now2].filter((id) => !was2.has(id));
266
+ const gone = [...was2].filter((id) => !now2.has(id));
267
+ if (added.length) out.push(`Run ${run.runId} now collides with ${listWords(added)}`);
268
+ if (gone.length && !now2.size) out.push(`Run ${run.runId} no longer collides`);
269
+ }
270
+ return out;
271
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @module explorer/runs-view
3
+ *
4
+ * The markup of the Runs view in the Agents tab (issue #82), as strings: each run's state as a word, its profile, its limits beside what
5
+ * the runner reported, its thread, branch, pull request and checks, the nodes it changes and any collision, a timeline in order, and the
6
+ * runner's own `url` as "Runner's log" (cornfield streams no log). Runs of one request under different profiles are a **compare**: side by
7
+ * side on a wide panel, stacked on a narrow one. Pure: no DOM, no clock; the panel hands in what it knows and paints the string.
8
+ * Every word from a runner or the forge is escaped.
9
+ */
10
+
11
+ import { relativeTime } from './review-model.js';
12
+ import { aboutWords, statusWords, statusWhy, runMeta, canStop, groupRuns, compareTitle, limitRows, checksWords, timelineWords, nodeLabels, collisionWords } from './runs-model.js';
13
+
14
+ const TONE = { done: 'success', merged: 'success', failed: 'failure', quiet: 'queued', 'stop-requested': 'queued' };
15
+
16
+ /**
17
+ * @typedef {object} ViewContext
18
+ * @property {(s: unknown) => string} esc HTML escaping
19
+ * @property {(u: unknown) => string | null} safeUrl an http(s) address, or null
20
+ * @property {string} forgeName "GitHub"
21
+ * @property {string | null} repoUrl the repo's address on the forge ("https://github.com/owner/name"), or null
22
+ * @property {string} forgeType 'github' | 'forgejo'
23
+ * @property {number} now
24
+ * @property {{ id: string, label?: string }[]} nodes the map's nodes, for names
25
+ * @property {string | null} selected the run lit on the map
26
+ * @property {Set<string>} timelines the runs whose timeline is open
27
+ * @property {boolean} busy an action is in flight
28
+ */
29
+
30
+ /** A link that opens a new tab, said so for a screen reader */
31
+ const link = (ctx, href, text, after = '') => (ctx.safeUrl(href) ? `<a class="hx-link" href="${ctx.esc(href)}" target="_blank" rel="noreferrer">${ctx.esc(text)} <span class="hx-sr-only">(new tab)</span></a>${after}` : '');
32
+
33
+ /** The address of a branch on the forge, or null */
34
+ function branchUrl(ctx, branch) {
35
+ if (!ctx.repoUrl || !branch) return null;
36
+ const at = branch.split('/').map(encodeURIComponent).join('/');
37
+ return ctx.forgeType === 'forgejo' ? `${ctx.repoUrl}/src/branch/${at}` : `${ctx.repoUrl}/tree/${at}`;
38
+ }
39
+
40
+ /** The links of a run: the request, its thread, its branch, its pull request, and the runner's log (the runner's own `url`; nothing is streamed here) */
41
+ function linksHtml(ctx, run) {
42
+ const thread = run.refs.thread ?? run.refs.issue;
43
+ return [
44
+ link(ctx, run.comment && run.comment.url, `Request on ${ctx.forgeName}`),
45
+ thread != null && ctx.repoUrl ? link(ctx, `${ctx.repoUrl}/issues/${thread}`, `Thread #${thread}`) : '',
46
+ run.branch ? link(ctx, branchUrl(ctx, run.branch), `Branch ${run.branch}`) : '',
47
+ run.pull ? link(ctx, run.pull.url, `Pull request #${run.pull.number}`, run.pull.trusted ? '' : ' <span class="hx-faint">(not from a runner, so it moves nothing)</span>') : '',
48
+ link(ctx, run.report && run.report.url, 'Runner\'s log'),
49
+ ].filter(Boolean).join(' · ');
50
+ }
51
+
52
+ function timelineHtml(ctx, run) {
53
+ const items = (run.timeline || []).map((e) => {
54
+ const w = timelineWords(e);
55
+ return `<li><time datetime="${ctx.esc(e.at)}" title="${ctx.esc(e.at)}">${ctx.esc(relativeTime(e.at, ctx.now) || e.at)}</time>${ctx.esc(w.what)}${w.note ? `<span class="hx-run-note">${ctx.esc(w.note)}</span>` : ''}</li>`;
56
+ });
57
+ return `<ol class="hx-run-timeline" id="run-tl-${ctx.esc(run.runId)}" aria-label="Timeline of ${ctx.esc(run.runId)}, oldest first">${items.join('')}</ol>`;
58
+ }
59
+
60
+ /** One run */
61
+ export function runHtml(ctx, run, all) {
62
+ const lit = ctx.selected === run.runId;
63
+ const open = ctx.timelines.has(run.runId);
64
+ const tone = TONE[run.status] || 'running';
65
+ const why = statusWhy(run);
66
+ const note = run.report && run.report.note ? `<p class="hx-forge-meta">Runner: ${ctx.esc(run.report.note)}</p>` : '';
67
+ const stop = canStop(run) ? `<button type="button" class="hx-btn hx-forge-act" data-forge-stop="${ctx.esc(run.runId)}" aria-label="Stop run ${ctx.esc(run.runId)}"${ctx.busy ? ' disabled' : ''}>Stop</button>` : '';
68
+ const names = nodeLabels(run.nodes, ctx.nodes);
69
+ const collision = collisionWords(run, all, ctx.nodes);
70
+ const limits = limitRows(run);
71
+ const count = (run.timeline || []).length;
72
+ const links = linksHtml(ctx, run);
73
+ return `<li class="hx-run hx-forge-file" data-run="${ctx.esc(run.runId)}">
74
+ <div class="hx-run-head"><span class="hx-forge-state hx-forge-state--${tone}">${ctx.esc(statusWords(run))}</span> <span class="hx-name">${ctx.esc(run.kind)} on ${ctx.esc(aboutWords(run.refs))}</span>${run.profile ? ` <span class="hx-faint">profile ${ctx.esc(run.profile)}</span>` : ''}</div>
75
+ ${collision ? `<p class="hx-run-collide"><span aria-hidden="true">⚠</span> ${ctx.esc(collision)}</p>` : ''}
76
+ <p class="hx-forge-meta">${ctx.esc(runMeta(run, ctx.now))}</p>${why ? `<p class="hx-forge-why hx-faint">${ctx.esc(why)}</p>` : ''}${note}
77
+ <dl class="hx-run-limits" aria-label="Limits and what the runner reported">${limits.length ? limits.map((r) => `<dt>${ctx.esc(r.name)}</dt><dd>limit: ${ctx.esc(r.limit)}; reported: ${ctx.esc(r.reported)}</dd>`).join('') : '<dt>Limits</dt><dd>none asked, nothing reported</dd>'}</dl>
78
+ <p class="hx-forge-meta">${ctx.esc(checksWords(run))}</p>
79
+ <p class="hx-forge-meta">${names.length ? `Changing ${run.nodesFrom === 'files' ? '' : 'or expected to change '}${ctx.esc(names.join(', '))}` : 'No node on the map yet.'}</p>
80
+ ${links ? `<p class="hx-forge-meta">${links}</p>` : ''}
81
+ <div class="hx-run-actions">${stop}
82
+ <button type="button" class="hx-btn hx-forge-act" data-run-select="${ctx.esc(run.runId)}" aria-pressed="${lit}" aria-label="Light the nodes of ${ctx.esc(run.runId)} on the map"${(run.nodes || []).length ? '' : ' disabled'}>${lit ? 'Lit on the map' : 'Light on the map'}</button>
83
+ <button type="button" class="hx-btn hx-forge-act" data-run-timeline="${ctx.esc(run.runId)}" aria-expanded="${open}" aria-controls="run-tl-${ctx.esc(run.runId)}">Timeline (${count})</button></div>
84
+ ${open ? timelineHtml(ctx, run) : ''}</li>`;
85
+ }
86
+
87
+ /**
88
+ * The list: runs on their own and compares, as the items of the list.
89
+ * @param {ViewContext} ctx
90
+ * @param {object[]} runs
91
+ */
92
+ export function runsHtml(ctx, runs) {
93
+ return groupRuns(runs).map((g) => {
94
+ if (g.kind === 'run') return runHtml(ctx, g.run, runs);
95
+ const id = `cmp-${g.runs[0].runId}`;
96
+ return `<li class="hx-run-group"><section class="hx-run-compare" aria-labelledby="${ctx.esc(id)}">
97
+ <h4 class="hx-run-compare-title" id="${ctx.esc(id)}">${ctx.esc(compareTitle(g.runs))}</h4>
98
+ <p class="hx-forge-meta hx-faint">The same request under each profile, side by side.</p>
99
+ <ul class="hx-run-columns" aria-label="${ctx.esc(g.runs.length)} profiles compared">${g.runs.map((r) => runHtml(ctx, r, runs)).join('')}</ul></section></li>`;
100
+ }).join('');
101
+ }
package/explorer/state.js CHANGED
@@ -119,6 +119,12 @@ export const state = {
119
119
  */
120
120
  forgeLit: null,
121
121
 
122
+ /**
123
+ * The runs on the map (explorer/runs-map.js): `{ flight: Map<nodeId, runId[]>, collide: Map<nodeId, runId[]>, runs }` for the open runs'
124
+ * nodes, or null when there are none. The map's marks over the nodes are drawn from it.
125
+ */
126
+ runsMap: null,
127
+
122
128
  /**
123
129
  * Which threads mention which nodes (forge-panel.js, docs/threads.md), for a node's panel: `{ for(nodeId) -> thread[] | null,
124
130
  * ensure(), open(number) }`, or null when threads are off or there is no forge. `for` answers null until the list has been
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cornfield",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "A self-hostable dev dashboard with the topology map at its centre: proposals on the map, scenarios, outages and what-if edits, for any repo.",
5
5
  "license": "MIT",
6
6
  "author": "popcorn245 (https://github.com/popcorn245)",