@lovek4d/kickflip 0.7.0 → 0.7.2
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/CONTEXT.md +53 -49
- package/SKILL.md +17 -13
- package/bin/event-lines.mjs +1 -1
- package/bin/kickflip.mjs +3 -3
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +113 -105
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -8
- package/board/hub-lines.mjs +1 -0
- package/board/onboarding.mjs +3 -3
- package/board/public/app-arch.js +1 -1
- package/board/public/app-cards.js +6 -7
- package/board/public/app-header.js +1 -1
- package/board/public/app-lanes.js +39 -62
- package/board/public/app-needs.js +16 -11
- package/board/public/app-reader.js +9 -5
- package/board/public/app.js +3 -3
- package/board/public/attach.js +18 -2
- package/board/public/dialogs.js +11 -13
- package/board/public/hub-kit.js +1 -1
- package/board/public/hub.css +0 -1
- package/board/public/linear-key.js +2 -2
- package/board/public/new-task.js +1 -1
- package/board/public/prototype-kit.js +2 -5
- package/board/public/router.js +1 -1
- package/board/public/sidebar.css +3 -3
- package/board/public/sidebar.js +2 -2
- package/board/public/style.css +43 -43
- package/board/public/ui-cards.js +25 -20
- package/board/public/ui-hub.js +1 -5
- package/board/public/ui-links.js +5 -5
- package/board/public/ui-order.js +6 -7
- package/board/public/ui-questions.js +17 -13
- package/board/public/ui-sidebar.js +1 -1
- package/board/public/ui-threads.js +29 -21
- package/board/public/ui.js +23 -6
- package/board/public/view-hub-settings.js +11 -19
- package/board/public/view-hub.js +25 -11
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +18 -5
- package/board/public/view-settings.js +7 -7
- package/board/render-check.mjs +10 -10
- package/board/review.mjs +1 -1
- package/board/server.mjs +18 -19
- package/board/stop.mjs +1 -1
- package/board/store.mjs +26 -26
- package/board/tickets.mjs +3 -3
- package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
- package/hub/awake.mjs +1 -1
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/linear.mjs +1 -1
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/readiness.mjs +1 -1
- package/hub/review-send.mjs +1 -1
- package/hub/slack.mjs +1 -1
- package/hub/try.mjs +6 -7
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/usage-gate.mjs +1 -1
- package/starter/server.mjs +3 -3
package/board/public/ui-cards.js
CHANGED
|
@@ -31,7 +31,10 @@
|
|
|
31
31
|
return `${badge(look.tone, `${esc(name)}${look.mark}`, { href: pr.url, ...label })}${ci}${codex}`;
|
|
32
32
|
}
|
|
33
33
|
const linearChips = (it) => (it.linear || []).map((l) => badge('quiet', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
|
|
34
|
-
const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge('quiet', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === '
|
|
34
|
+
const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge('quiet', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === 'github' ? ' on GitHub' : ' in Linear'}`, attrs: EXTERNAL });
|
|
35
|
+
// docs/adr/2026-11-09-1500-a-task-is-a-card-with-no-ticket-keyed-t-n.md: a card with no ticket is a task, its T-<n>
|
|
36
|
+
// only in the badge's title.
|
|
37
|
+
const taskChip = (it) => badge('quiet', 'Task', { title: `${it.ref} · a task, no ticket` });
|
|
35
38
|
const REVIEW = {
|
|
36
39
|
approved: { tone: 'ready', mark: '✓', label: 'approved' },
|
|
37
40
|
changes: { tone: 'danger', mark: '✗', label: 'asked for changes' },
|
|
@@ -177,7 +180,7 @@
|
|
|
177
180
|
const ALL_DONE = `all ${BUILD_PHASES.length} steps done`;
|
|
178
181
|
// A card's progress bar (ui-progress-bar.js), In Progress's phase, or null off In Progress and on a card only a planner
|
|
179
182
|
// works on: how many phases it reached, which of them is running, whether it dims because the card waits on something
|
|
180
|
-
// other than a builder (its questions, a blocker or queue, a reopening)
|
|
183
|
+
// other than a builder (its questions, a blocker or queue, a reopening), its build is stale or paused, and its label. An install
|
|
181
184
|
// is no build: its card keeps the phase its build reached, undimmed
|
|
182
185
|
// (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md, docs/adr/2026-11-11-1200-every-lane-has-phases.md).
|
|
183
186
|
function phaseStrip(it) {
|
|
@@ -191,7 +194,7 @@
|
|
|
191
194
|
const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
|
|
192
195
|
if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
|
|
193
196
|
const done = BUILD_PHASES.indexOf(it.builder.phase) + 1;
|
|
194
|
-
return { count: all, done, now: done ? done - 1 : undefined, dim: asking || Boolean(it.builder.stale || it.builder.quiet), label: `${done ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
|
|
197
|
+
return { count: all, done, now: done ? done - 1 : undefined, dim: asking || Boolean(it.builder.stale || it.builder.quiet || it.builder.pause?.paused), label: `${done ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
|
|
195
198
|
}
|
|
196
199
|
const dim = !it.builder || asking;
|
|
197
200
|
if (it.reopened) return { count: all, done: all, dim, label: `Reopened, ${ALL_DONE}${answers}` };
|
|
@@ -228,12 +231,12 @@
|
|
|
228
231
|
if (it.builder.stopped) return `<div class="${classes}">${esc(builderLine(it.builder))}</div>`;
|
|
229
232
|
return `<div class="${classes}">${quiet ? '' : DOT}${esc(builderLine(it.builder))}</div>`;
|
|
230
233
|
}
|
|
231
|
-
// The open card's Status row: what runs on the card, else where its lane leaves it, never the
|
|
234
|
+
// The open card's Status row: what runs on the card, else where its lane leaves it, never the item file's raw status
|
|
232
235
|
// (docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
|
|
233
236
|
function statusWord(it) {
|
|
234
237
|
const b = it.builder;
|
|
235
238
|
if (b?.planner) return b.kind === 'prototype' ? 'prototyping' : 'grilling';
|
|
236
|
-
if (b && b.kind !== BUILDER_KIND.install) return b.stopped || (b.alive === false ? 'stopped' : b.phase === 'landing' ? 'landing' : 'implementing');
|
|
239
|
+
if (b && b.kind !== BUILDER_KIND.install) return b.stopped || (b.pause ? (b.pause.paused ? 'paused' : 'pausing') : b.alive === false ? 'stopped' : b.phase === 'landing' ? 'landing' : 'implementing');
|
|
237
240
|
if (it.waitsOn === 'checking') return 'Orchestrator review';
|
|
238
241
|
if (it.lane === 'done') return 'done';
|
|
239
242
|
if (it.grilling || it.prototyping) return 'preheating';
|
|
@@ -274,21 +277,26 @@
|
|
|
274
277
|
const [word, tone] = PHASE_LOOK[it.phase];
|
|
275
278
|
return `<span class="card-phase ph-${tone}">${word}</span>`;
|
|
276
279
|
}
|
|
280
|
+
// docs/adr/2026-11-09-1300-a-card-pause-stops-its-builder-at-its-next-stopping-point.md
|
|
281
|
+
// A paused build's word and cause, which stand in for the hold or block that caused it: "pausing · blocked by #61" in
|
|
282
|
+
// orange while its builder runs on to its stopping point, then "paused · blocked by #61" in grey.
|
|
283
|
+
const pauseOf = (it) => (it.lane === 'progress' ? it.builder?.pause ?? null : null);
|
|
284
|
+
const pauseWords = (pause) => pause && `<span class="card-${pause.paused ? 'paused' : 'pausing'}">${pause.paused ? 'paused' : 'pausing'} · ${esc(pause.words)}</span>`;
|
|
277
285
|
// A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed,
|
|
278
|
-
//
|
|
279
|
-
//
|
|
286
|
+
// held while host recovers on a bumped ready card a busy host holds, in grey
|
|
287
|
+
// (docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md), stalled in red or a build's stale
|
|
280
288
|
// in grey (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md), a stale review reading it
|
|
281
|
-
// once, as its phase, then the phase word (phaseWord), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
|
|
289
|
+
// once, as its phase, then the phase word (phaseWord), then a paused build's words (pauseWords), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
|
|
282
290
|
// takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
|
|
283
291
|
// docs/adr/2026-11-03-0516-a-new-card-is-ready-grilling-or-prototyping.md, docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
|
|
284
292
|
const SEP = '<span class="sep"> · </span>';
|
|
285
293
|
const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
|
|
286
294
|
function timeLine(it, unread, bars) {
|
|
287
295
|
const iso = it.sortAt;
|
|
288
|
-
const
|
|
289
|
-
|
|
296
|
+
const pause = pauseOf(it);
|
|
297
|
+
const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, pause?.cause !== 'held' && holdWords(it), pause?.cause !== 'blocked' && blockedWords(it), actionWords(it),
|
|
290
298
|
it.hostHeld && '<span class="card-host-held">held while host recovers</span>',
|
|
291
|
-
it.stalled ? '<span class="card-stalled">stalled</span>' : it.stale && it.phase !== 'stale' && '<span class="card-stale">stale</span>', phaseWord(it)].filter(Boolean);
|
|
299
|
+
it.stalled ? '<span class="card-stalled">stalled</span>' : it.stale && it.phase !== 'stale' && '<span class="card-stale">stale</span>', phaseWord(it), pauseWords(pause)].filter(Boolean);
|
|
292
300
|
const news = newWords(unread, 'message');
|
|
293
301
|
const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
|
|
294
302
|
const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
|
|
@@ -296,7 +304,7 @@
|
|
|
296
304
|
return `<div class="line6 sub card-time">${parts.join(SEP)}${tail}${count}</div>`;
|
|
297
305
|
}
|
|
298
306
|
// The Hold sheet's picker (app-cards.js): /api/board's holdChoices, the chips hub/duration.mjs shares with Snooze and
|
|
299
|
-
//
|
|
307
|
+
// Pause hub, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
|
|
300
308
|
// durationPicker's markup also uses. Each chip's length (ms, null for Forever) is worded as when it would end if picked
|
|
301
309
|
// at `now`, here rather than on the server, so the board's version doesn't change with the clock
|
|
302
310
|
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
@@ -319,13 +327,10 @@
|
|
|
319
327
|
// still, until the orchestrator picks it up (the watcher's working mark), then "thinking".
|
|
320
328
|
const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting</span>';
|
|
321
329
|
const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' ? thinkingLine() : ask.state === 'waiting' && PICKUP_WAIT].filter(Boolean).join(' · ');
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
// A work card always shows its ticket; an ask, which has none, shows New or Ask there (askChip).
|
|
327
|
-
const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
|
|
328
|
-
const ids = it.kind === 'ask' ? askChip(it) : chip;
|
|
330
|
+
function workLines(it, { unread = 0, top = false } = {}) {
|
|
331
|
+
const ticket = it.ticket ? ticketChip(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : it.linear?.length ? linearChips(it) : taskChip(it);
|
|
332
|
+
// A work card shows its ticket, or Task when it has none; an ask shows New or Ask there (askChip).
|
|
333
|
+
const ids = it.kind === 'ask' ? askChip(it) : ticket;
|
|
329
334
|
const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
|
|
330
335
|
// A handled ask's asked time sits under its title, since its Ask badge holds the ticket's spot
|
|
331
336
|
// (docs/adr/2026-11-10-1200-an-ask-is-titled-in-place-and-wears-an-ask-badge.md).
|
|
@@ -343,7 +348,7 @@
|
|
|
343
348
|
// The owner step's code check, which closes the card when it passes (docs/adr/2026-11-04-2308-an-owner-step-can-carry-a-code-check.md).
|
|
344
349
|
const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${escText(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
|
|
345
350
|
: `<div class="line3 sub check">${escText(`Checks: ${it.ownerCheck.words}`)}</div>`;
|
|
346
|
-
return `${
|
|
351
|
+
return `${cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
|
|
347
352
|
${asked}${status}${check}${eta}
|
|
348
353
|
${chips ? `<div class="line2 meta">${chips}</div>` : ''}
|
|
349
354
|
${timeLine(it, unread, progressBar(phaseStrip(it)))}`;
|
package/board/public/ui-hub.js
CHANGED
|
@@ -68,10 +68,6 @@
|
|
|
68
68
|
+ `<p class="u-share-desc">${off ? 'Off: work keeps going until usage runs out.' : `Claude’s new work waits when either window passes this, and resumes at that window’s reset; Codex hands its work to ${builder} implementers.`}</p></div>`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// What polling does now, the last and next check and the queue, with Send now: hub/views.mjs pollingNow's words.
|
|
72
|
-
const nowRow = (now, icon = '') => row({ icon, title: esc(now.title), sub: now.lines.map((l) => `<span class="sub wrap">${esc(l)}</span>`).join(''),
|
|
73
|
-
trail: `<form method="post" action="/_hub/push/flush"><button class="btn quiet" ${now.count ? '' : 'disabled'}>Send now</button></form>` });
|
|
74
|
-
|
|
75
71
|
// This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet, busy}} or null
|
|
76
72
|
// while unknown, which keeps the row hidden. A busy action waits (ui.js BUSY) while it turns on or tests.
|
|
77
73
|
function deviceRow(state, { compact = false } = {}) {
|
|
@@ -122,5 +118,5 @@
|
|
|
122
118
|
return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}${buffer}</div></section>`;
|
|
123
119
|
}
|
|
124
120
|
|
|
125
|
-
globalThis.HubUI = { esc, TILE_PATHS, tile, CHEVRON, row, flash, holdBanner, pauseBanner, machineRow,
|
|
121
|
+
globalThis.HubUI = { esc, TILE_PATHS, tile, CHEVRON, row, flash, holdBanner, pauseBanner, machineRow, deviceRow, usageCard };
|
|
126
122
|
})();
|
package/board/public/ui-links.js
CHANGED
|
@@ -14,21 +14,21 @@
|
|
|
14
14
|
// from its ref, to its card's key, the issue url prefix of each Linear team the board's cards link to (none on a
|
|
15
15
|
// board without a workspace), that item's repo for a bare #n that is no card's ref, and `base`, which a page other
|
|
16
16
|
// than the board sets to './' so a card link opens the board. A ref shaped like a Linear id, such as INT-<n>, or a
|
|
17
|
-
// ticket #n links from anywhere;
|
|
18
|
-
//
|
|
17
|
+
// ticket #n links from anywhere; an item file's own short id, such as B1, links only from that file's cards, since
|
|
18
|
+
// another item file's plan reuses those names.
|
|
19
19
|
function linkContext(items = [], key = null, { base = '' } = {}) {
|
|
20
|
-
const
|
|
20
|
+
const file = key && !key.startsWith('ask:') ? key.slice(0, key.indexOf('/')) : null;
|
|
21
21
|
const byRef = new Map();
|
|
22
22
|
for (const i of items) {
|
|
23
23
|
if (i.kind === 'ask') continue;
|
|
24
24
|
for (const ref of new Set([i.ref, i.id])) {
|
|
25
|
-
if (!ref || (!LINEAR_ID.test(ref) && !ISSUE.test(ref) && i.
|
|
25
|
+
if (!ref || (!LINEAR_ID.test(ref) && !ISSUE.test(ref) && i.file !== file)) continue;
|
|
26
26
|
byRef.set(ref, [...(byRef.get(ref) || []), i]);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
const refs = Object.create(null);
|
|
30
30
|
for (const [ref, list] of byRef) {
|
|
31
|
-
const pick = list.length === 1 ? list[0] : list.find((i) => i.
|
|
31
|
+
const pick = list.length === 1 ? list[0] : list.find((i) => i.file === file);
|
|
32
32
|
if (pick) refs[ref] = pick.key;
|
|
33
33
|
}
|
|
34
34
|
const teams = Object.create(null);
|
package/board/public/ui-order.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
// The board's order of its items, each lane together, which board/domain.mjs sorts the board by and the page sorts a
|
|
2
2
|
// tap it draws at once by, so the card lands where the server will put it
|
|
3
|
-
// (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt,
|
|
3
|
+
// (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, sortAt, order and key.
|
|
4
4
|
// Needs nothing else: board/domain.mjs runs this file in a vm context.
|
|
5
5
|
(function () {
|
|
6
|
-
const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3
|
|
6
|
+
const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3 };
|
|
7
7
|
// Every lane but Done lists bumped cards first, the earliest bump on top, a bumped New ask included
|
|
8
8
|
// (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
|
|
9
|
-
// docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md)
|
|
10
|
-
// the
|
|
11
|
-
// time last, the first added to its wave file on top, so a New ask and every new card join the bottom and the pull takes the
|
|
9
|
+
// docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md). Todo lists the rest oldest first by sortAt, a card with no
|
|
10
|
+
// time last, the first added to its item file on top, so a New ask and every new card join the bottom and the pull takes the
|
|
12
11
|
// top ready card (docs/adr/2026-11-05-2345-new-todo-cards-join-the-bottom.md,
|
|
13
12
|
// docs/adr/2026-11-06-0300-a-new-task-lands-at-the-bottom-of-todo-and-flashes-blue.md). Every other
|
|
14
|
-
// lane lists its cards newest first by sortAt, a card with no time last, the last added to its
|
|
13
|
+
// lane lists its cards newest first by sortAt, a card with no time last, the last added to its item file on top.
|
|
15
14
|
// Present times first, the earliest on top.
|
|
16
15
|
const earliest = (x, y) => (x ? 0 : 1) - (y ? 0 : 1) || String(x || '').localeCompare(String(y || ''));
|
|
17
16
|
function laneOrder(a, b) {
|
|
@@ -20,7 +19,7 @@
|
|
|
20
19
|
const timeless = (i) => (at(i) ? 0 : 1);
|
|
21
20
|
const age = oldestFirst ? timeless(a) - timeless(b) || at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
|
|
22
21
|
const added = a.lane !== 'todo' ? b.order - a.order : a.order - b.order;
|
|
23
|
-
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || earliest(a.bumpedAt, b.bumpedAt) ||
|
|
22
|
+
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || earliest(a.bumpedAt, b.bumpedAt) || age || added || a.key.localeCompare(b.key);
|
|
24
23
|
}
|
|
25
24
|
Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
|
|
26
25
|
})();
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
// Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
|
|
2
2
|
(function () {
|
|
3
|
-
const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText,
|
|
3
|
+
const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
|
|
4
|
+
// ui-markdown.js loads after this file, so markdown is looked up when a question draws.
|
|
5
|
+
const markdown = (src, links) => globalThis.BoardUI.markdown(src, links);
|
|
4
6
|
// A short id such as A or C2 leads the label; a word id such as "inline" would repeat it.
|
|
5
7
|
const lead = (o) => (/^[A-Z][0-9]?$/i.test(o.id) ? `${o.id}. ` : '');
|
|
6
8
|
const choiceLabel = (q, id) => { const o = q.options.find((x) => x.id === id); return o ? `${q.multi ? '' : lead(o)}${o.label}` : id || ''; };
|
|
7
|
-
// suggested: the line under a suggested option
|
|
8
|
-
const option = (o, { type = 'radio', name, checked = false, numbered = true, locked = false, suggested = '' }) => `<label class="opt"><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
|
|
9
|
-
<span><span class="lbl">${numbered ? escText(lead(o)) : ''}${escText(o.label)}</span>${
|
|
9
|
+
// suggested: the orchestrator's blue line under a suggested option's label, as HTML.
|
|
10
|
+
const option = (o, { type = 'radio', name, checked = false, numbered = true, locked = false, suggested = '', links = {} }) => `<label class="opt"><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
|
|
11
|
+
<span><span class="lbl">${numbered ? escText(lead(o)) : ''}${escText(o.label)}</span>${o.recommended ? '<span class="rec">recommended</span>' : ''}${suggested ? `<div class="suggested-why">Orchestrator choice: ${suggested}</div>` : ''}<div class="det">${o.detail ? markdown(o.detail, links) : ''}</div></span></label>`;
|
|
10
12
|
// docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
|
|
11
13
|
// A question's pick on this device: its unsent draft, else its last answer, else the option the overnight triage
|
|
12
14
|
// suggested, pre-checked so the topic's Send sends it.
|
|
13
|
-
const choiceOf = (q, draft) => draft.choice
|
|
15
|
+
const choiceOf = (q, draft) => 'choice' in draft ? draft.choice : (q.answers.length ? q.answers[q.answers.length - 1].choice : q.suggested?.option ?? null);
|
|
14
16
|
// A question's unsent note: its draft, else on a reopened question the note its last answer sent, so a changed pick
|
|
15
17
|
// sends it again.
|
|
16
18
|
const noteOf = (q, draft) => draft.note ?? (q.reopened ? q.answers.at(-1)?.note || '' : '');
|
|
@@ -22,7 +24,7 @@
|
|
|
22
24
|
// phone's deck (app-needs.js) and the prototype viewer's Choose this (prototype-switcher.js) write it.
|
|
23
25
|
const getDraft = (id) => { try { return JSON.parse(localStorage.getItem('qdraft:' + id) || '{}'); } catch { return {}; } };
|
|
24
26
|
const setDraft = (id, v) => { try { localStorage.setItem('qdraft:' + id, JSON.stringify(v)); } catch {} };
|
|
25
|
-
const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(`
|
|
27
|
+
const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(q.suggested.reason ?? `you answered this on ${q.suggested.card}: "${q.suggested.answer}"`, links) : '';
|
|
26
28
|
|
|
27
29
|
const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
|
|
28
30
|
// A question's eye sends `prototype-request {key?, question, note, choice}` for that question alone, which then reads
|
|
@@ -42,10 +44,12 @@
|
|
|
42
44
|
: r.state === 'closed' ? badge('quiet', `Round ${r.n} closed · take ${esc(r.take)}`)
|
|
43
45
|
: badge('normal', `Prototype round ${r.n}`);
|
|
44
46
|
const RULE_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>';
|
|
47
|
+
// Each of an answer's replies, oldest first.
|
|
48
|
+
const repliesOf = (a, links) => (a.replies?.length ? a.replies : a.reply ? [a.reply] : []).map((r) => `<div class="rep${r.md ? ' md' : ''}"><div class="muted small">Claude</div>${replyBody(r, links)}</div>`).join('');
|
|
45
49
|
function answerHistory(q, links = {}) {
|
|
46
50
|
if (!q.answers.length) return '';
|
|
47
51
|
const body = q.answers.map((a) => `<div class="ans"><div class="muted small">You · ${esc(time(a.ts))}</div>${escText(choiceLabel(q, a.choice))}${a.note ? `<div>${linkText(a.note, links)}</div>` : ''}${attachments(a.files)}</div>
|
|
48
|
-
${
|
|
52
|
+
${repliesOf(a, links)}`).join('');
|
|
49
53
|
const last = q.answers[q.answers.length - 1];
|
|
50
54
|
return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
|
|
51
55
|
}
|
|
@@ -83,7 +87,7 @@
|
|
|
83
87
|
|
|
84
88
|
function takes(q, chosen, links, locked = false) {
|
|
85
89
|
const name = 'c-' + q.id;
|
|
86
|
-
return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })}${o.diagram ? diagram(o.diagram) + practice(o.practice || [], links) : ''}</article>`).join('')}</div>`;
|
|
90
|
+
return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links), links })}${o.diagram ? diagram(o.diagram) + practice(o.practice || [], links) : ''}</article>`).join('')}</div>`;
|
|
87
91
|
}
|
|
88
92
|
// A confirm says why it is asked; its View prototypes opens the viewer on its one take (?variant=).
|
|
89
93
|
const confirmLine = (q) => q.confirm_of && q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : '';
|
|
@@ -97,15 +101,15 @@
|
|
|
97
101
|
const outcome = q.resolved ? `${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'}` : '';
|
|
98
102
|
const acted = q.resolved_at ? ` <span class="acted" title="${esc(time(q.resolved_at))}">acted on ${esc(ago(q.resolved_at))}</span>.` : '';
|
|
99
103
|
const said = last && (last.note || last.reply || last.files?.length) ? `<div class="ans"><div class="muted small">You · ${esc(time(last.ts))}</div>${last.note ? `<div>${linkText(last.note, links)}</div>` : ''}${attachments(last.files)}</div>
|
|
100
|
-
${
|
|
104
|
+
${repliesOf(last, links)}` : '';
|
|
101
105
|
return `<section class="q resolved" id="${esc(q.id)}" data-q="${esc(q.id)}">
|
|
102
106
|
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge('ready', '✓ Resolved', { button: true, title: 'Reopen this question', attrs: { type: 'button', 'data-reopen': q.id } })}</div>
|
|
103
107
|
<h3>${linkText(q.question, links)}</h3>
|
|
104
108
|
${q.answers.length ? '<div class="sent-line">Sent. Tap Resolved to reopen it.</div>' : ''}
|
|
105
|
-
${q.context ? `<div class="ctx">${
|
|
109
|
+
${q.context ? `<div class="ctx">${markdown(q.context, links)}</div>` : ''}
|
|
106
110
|
${questionLink(q.url)}
|
|
107
111
|
${outcome || acted ? `<div class="small muted resolved-line">${outcome}${acted}</div>` : ''}
|
|
108
|
-
${q.options.map((o) => option(o, { name: 'c-' + q.id, checked: last?.choice === o.id, locked: true })).join('')}
|
|
112
|
+
${q.options.map((o) => option(o, { name: 'c-' + q.id, checked: last?.choice === o.id, locked: true, links })).join('')}
|
|
109
113
|
${said}
|
|
110
114
|
${q.reask ? '<div class="small muted reask-line">re-ask requested</div>' : ''}
|
|
111
115
|
</section>`;
|
|
@@ -121,10 +125,10 @@
|
|
|
121
125
|
return `<section class="q${q.urgent && q.state === 'open' ? ' urgent' : ''}${locked ? ' pr-busy' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
|
|
122
126
|
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, QUESTION_STATE[q.state].label)}</div>
|
|
123
127
|
<h3>${linkText(q.question, links)}</h3>
|
|
124
|
-
${q.context ? `<div class="ctx">${
|
|
128
|
+
${q.context ? `<div class="ctx">${markdown(q.context, links)}</div>` : ''}
|
|
125
129
|
${questionLink(q.confirm_of && isPrototype(q.url) ? withParam(q.url, 'variant', q.options[0].id) : q.url)}
|
|
126
130
|
${confirmLine(q)}
|
|
127
|
-
${drawn(q) ? takes(q, chosen, links, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })).join('')}
|
|
131
|
+
${drawn(q) ? takes(q, chosen, links, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links), links })).join('')}
|
|
128
132
|
${locked ? (q.request.note ? `<div class="ans"><div class="muted small">You · ${esc(time(q.request.ts))}</div><div>${linkText(q.request.note, links)}</div></div>` : '') : noteComposer(q, noteOf(q, draft))}
|
|
129
133
|
${answerHistory(q, links)}
|
|
130
134
|
</section>`;
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
+ '<p class="sc-foot">Press h twice anywhere to open this. Esc to close.</p></div>';
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
const ticketsLabel = (t) => (t.source === '
|
|
132
|
+
const ticketsLabel = (t) => (t.source === 'github' ? `GitHub issues${t.account ? ` · ${t.account}` : ''}` : t.source === 'linear' ? `Linear · ${t.team}` : 'None');
|
|
133
133
|
// A board's context window: its owner session's latest percent at the row's top right, red from 90%, dimmed once the
|
|
134
134
|
// reading is older than ten minutes, with that age in its tooltip.
|
|
135
135
|
const CTX_STALE_MIN = 10;
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
// A card's thread: the orchestrator's lines (who claude, named Orchestrator),
|
|
2
|
-
// their replies. Needs ui.js, ui-links.js and ui-
|
|
1
|
+
// A card's thread: the orchestrator's lines (who claude, named Orchestrator), the board's status lines, its builders'
|
|
2
|
+
// asks, the owner's messages and their replies. Needs ui.js, ui-links.js, ui-enums.js and ui-markdown.js.
|
|
3
3
|
(function () {
|
|
4
4
|
const { esc, escText, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, thinkingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
|
|
5
5
|
const VIA = { slack: ' · Slack', github: ' · GitHub' };
|
|
6
|
+
// ui-markdown.js loads after this file, so markdown is looked up when a thread draws.
|
|
7
|
+
const markdown = (src, links) => globalThis.BoardUI.markdown(src, links);
|
|
8
|
+
// The orchestrator's own messages are markdown in compact styles; the owner's, a shared person's and GitHub's or
|
|
9
|
+
// Slack's stay as written (docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md).
|
|
10
|
+
const drawsMarkdown = (m) => m.who === 'claude' && !m.html;
|
|
6
11
|
// A link to a card on this board (#selectedCard=<key>) or on another board (/<route>/#selectedCard=<key>, an ask's move), or ''.
|
|
7
12
|
function cardLink(link) {
|
|
8
13
|
const key = /^(?:\/[\w-]+\/)?#(?:selectedCard|item)=([^&]+)/.exec(link)?.[1];
|
|
@@ -25,9 +30,9 @@
|
|
|
25
30
|
const withReplies = (msgs) => {
|
|
26
31
|
// A folded submit's answers each reply, quoting their own block.
|
|
27
32
|
const out = msgs.flatMap((m, i) => [{ m, at: String(m.ts), i, part: 0 },
|
|
28
|
-
...(m.answers || [m]).filter((a) => a.eventId && a.reply).
|
|
33
|
+
...(m.answers || [m]).filter((a) => a.eventId && a.reply).flatMap((a) => (a.replies?.length ? a.replies : [a.reply]).map((reply, k) => ({ m: { ts: reply.at || m.ts, who: 'claude', kind: 'reply', reply }, of: m, quoted: m.answers ? a.text : m.text, later: k > 0, at: String(reply.at || m.ts), i, part: 1 })))]);
|
|
29
34
|
out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
|
|
30
|
-
return out.map(({ m, of, quoted }, n) => (of ? { ...m, quote: out[n - 1]?.m === of && !(of.answers?.length > 1) ? '' : quoted || '' } : m));
|
|
35
|
+
return out.map(({ m, of, quoted, later }, n) => (of ? { ...m, quote: later || out[n - 1]?.m === of && !(of.answers?.length > 1) ? '' : quoted || '' } : m));
|
|
31
36
|
};
|
|
32
37
|
// A message being edited shows its edit box in place of its text; app-asks.js handles the form, and attach.js the
|
|
33
38
|
// files it adds to the message, under composer id edit:<event id>.
|
|
@@ -35,23 +40,26 @@
|
|
|
35
40
|
<div class="pending-files" data-files></div>
|
|
36
41
|
<div class="send-actions"><button type="button" data-edit-cancel>Cancel</button>${sendButton('Save', 0)}</div></form>`;
|
|
37
42
|
const line = (m, links, edits) => (m.kind === 'reply'
|
|
38
|
-
? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${
|
|
39
|
-
: m.kind === 'ask-link' ? `<div class="msg link">${statusMark('ask-link')}<a href="${esc(m.link)}">${escText(m.text)} →</a></div>`
|
|
43
|
+
? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body${m.reply.md ? ' md' : ''}">${replyBody(m.reply, links)}</div></div>`
|
|
44
|
+
: m.kind === 'ask-link' ? `<div class="msg status-line link">${statusMark('ask-link')}<a href="${esc(m.link)}">${escText(m.text)} →</a></div>`
|
|
40
45
|
// docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md: a visual's page opens full page.
|
|
41
|
-
: m.kind === 'visual' ? `<div class="msg link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
46
|
+
: m.kind === 'visual' ? `<div class="msg status-line link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
42
47
|
// A request line's mark is its words' icon, or m.mark's for a line whose words vary; a Full person's names them.
|
|
43
|
-
: m.request && m.card ? `<div class="msg request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
44
|
-
: m.request ? `<div class="msg request${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
|
|
48
|
+
: m.request && m.card ? `<div class="msg status-line request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
49
|
+
: m.request ? `<div class="msg status-line request${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
|
|
50
|
+
// docs/adr/2026-11-09-1200-the-board-writes-status-lines-and-only-the-orchestrator-writes-messages.md: the board's
|
|
51
|
+
// own line, its kind's mark and words, red for a fault.
|
|
52
|
+
: m.kind === 'hub' ? `<div class="msg status-line request hub${m.fault ? ' fault' : ''}">${statusMark(m.mark)}${m.link ? `<a href="${esc(m.link)}">${escText(m.text)}</a>` : escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
45
53
|
// docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md: a card's move between lanes.
|
|
46
|
-
: m.kind === 'move' ? `<div class="msg request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
47
|
-
: m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
54
|
+
: m.kind === 'move' ? `<div class="msg status-line request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
55
|
+
: m.kind === 'questions' ? `<div class="msg status-line builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
48
56
|
: m.folded ? foldedRun(m, links, edits)
|
|
49
|
-
: m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
50
|
-
: m.kind === 'calls' ? `<details class="msg calls"><summary>${statusMark('judgment calls')}${escText(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
|
|
51
|
-
: m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
|
|
57
|
+
: m.kind === 'status' ? `<div class="msg status-line builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
58
|
+
: m.kind === 'calls' ? `<details class="msg status-line calls"><summary>${statusMark('judgment calls')}${escText(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
|
|
59
|
+
: m.kind === 'agent' ? `<div class="msg status-line agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
|
|
52
60
|
${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
|
|
53
61
|
<div class="who">${m.who === 'love' ? (m.by ? esc(m.by) : 'You') : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
|
|
54
|
-
<div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
|
|
62
|
+
<div class="body${drawsMarkdown(m) ? ' md' : ''}"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : drawsMarkdown(m) ? markdown(m.text, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
|
|
55
63
|
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">View transcript</a></div>` : ''}
|
|
56
64
|
${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
|
|
57
65
|
${m.eventId ? msgFooter(m) : ''}
|
|
@@ -65,7 +73,7 @@
|
|
|
65
73
|
const tail = m.folded.at(-1).finished ? 'finished' : 'running';
|
|
66
74
|
const log = m.folded.find((f) => f.log)?.log;
|
|
67
75
|
const logLine = log ? `<div class="small fold-log"><a href="log.html?file=${encodeURIComponent(log)}">View transcript</a></div>` : '';
|
|
68
|
-
return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${tail === 'finished' ? ' class="finished"' : ''}>${statusMark('status lines')}${escText(m.agent)} · ${tail}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}${logLine}</details>`;
|
|
76
|
+
return `<details class="msg status-line calls status-fold${m.planner ? ' planner' : ''}"><summary${tail === 'finished' ? ' class="finished"' : ''}>${statusMark('status lines')}${escText(m.agent)} · ${tail}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}${logLine}</details>`;
|
|
69
77
|
}
|
|
70
78
|
function foldStatus(lines) {
|
|
71
79
|
const runs = new Map();
|
|
@@ -80,12 +88,12 @@
|
|
|
80
88
|
const STEP = 3;
|
|
81
89
|
const UNREAD_CAP = 10;
|
|
82
90
|
// Every kind of system line; each leads with a mark, and a test renders each, so a new kind needs an entry in ui.js MARK_ICONS.
|
|
83
|
-
const SYSTEM_KINDS = ['status', 'agent', 'calls', 'ask-link', 'questions', 'visual', 'command', 'move'];
|
|
84
|
-
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions', 'move']);
|
|
91
|
+
const SYSTEM_KINDS = ['status', 'agent', 'calls', 'ask-link', 'questions', 'visual', 'command', 'move', 'hub'];
|
|
92
|
+
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions', 'move', 'hub']);
|
|
85
93
|
const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
|
|
86
|
-
// Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt,
|
|
87
|
-
// orchestrator's answer to a builder's ask never is.
|
|
88
|
-
const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.kind !== 'command' && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
|
|
94
|
+
// Only Claude's and GitHub's messages and the board's fault lines are unread, as on the badge (domain.mjs othersAt,
|
|
95
|
+
// ui-cards.js unreadCount); the orchestrator's answer to a builder's ask never is.
|
|
96
|
+
const unread = (m, seenAt) => Boolean(seenAt) && (m.fault || (isMessage(m) && m.kind !== 'command' && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github'))) && String(m.ts) > seenAt;
|
|
89
97
|
// The window opens from the oldest unread message, at most UNREAD_CAP messages back; a message being edited always shows.
|
|
90
98
|
function threadWindow(lines, { seenAt = null, more = 0, upto = null }, edits) {
|
|
91
99
|
const counted = lines.flatMap((m, i) => (isMessage(m) && (!upto || String(m.ts) <= upto) ? [i] : []));
|
package/board/public/ui.js
CHANGED
|
@@ -35,7 +35,10 @@
|
|
|
35
35
|
const day = d.toLocaleDateString([], { month: 'numeric', day: 'numeric', ...(now - d > 365 * 86400000 ? { year: 'numeric' } : {}) });
|
|
36
36
|
return `${day}, ${at}`;
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
// A reply as HTML with its refs linked: a markdown reply drawn by ui-markdown.js, which, like ui-links.js, loads after
|
|
39
|
+
// this file; an older HTML one as written. Its box takes class md for a markdown reply's compact styles.
|
|
40
|
+
const replyBody = (reply, links) => (reply.md ? globalThis.BoardUI.markdown(reply.md, links)
|
|
41
|
+
: globalThis.BoardUI.linkHtml(reply.html ?? esc(JSON.stringify(reply.json)), links));
|
|
39
42
|
|
|
40
43
|
// Attachments are uploads from POST /api/upload: {name, url, path, type, size}. Only uploads/ URLs become links.
|
|
41
44
|
const uploadUrl = (f) => (/^uploads\/[\w.-]+$/.test(f.url || '') ? f.url : null);
|
|
@@ -107,6 +110,9 @@
|
|
|
107
110
|
'Marked as ready': mini('<path d="M4 22V4"/><path d="M4 4h13l-2 4 2 4H4"/>'),
|
|
108
111
|
Held: mini('<path d="M9 5v14"/><path d="M15 5v14"/>'),
|
|
109
112
|
Resumed: mini('<path d="M7 4v16l13-8z"/>'),
|
|
113
|
+
// The board's status lines (board/hub-lines.mjs): a block's circle with a bar, a fault's warning triangle.
|
|
114
|
+
Blocked: mini('<circle cx="12" cy="12" r="9"/><path d="M5.6 5.6l12.8 12.8"/>'),
|
|
115
|
+
Fault: mini('<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/>'),
|
|
110
116
|
'Related to': LINK, 'Unrelated from': LINK, 'Follow-up': LINK, 'Follow-up of': LINK,
|
|
111
117
|
'Marked done': mini('<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>'),
|
|
112
118
|
Reply: mini('<path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 6 6v5"/>'),
|
|
@@ -148,11 +154,16 @@
|
|
|
148
154
|
return `<${tag} class="chip tone-${tone}"${extra}>${html}</${tag}>`;
|
|
149
155
|
}
|
|
150
156
|
// A composer's files before sending: {id, name, type, preview, status: uploading|done|error, progress (0–1), error}.
|
|
151
|
-
|
|
157
|
+
// An errored chip retries on click, except "Over 20 MB", which a retry can't change.
|
|
158
|
+
const pendingFiles = (list) => list.map((p) => {
|
|
159
|
+
const retry = p.status === 'error' && p.error !== 'Over 20 MB';
|
|
160
|
+
const title = retry ? `${p.name}: tap to retry` : p.status === 'error' ? `${p.name}: a retry can't succeed, remove it` : p.name;
|
|
161
|
+
return `<span class="pfile ${esc(p.status)}${retry ? ' retry' : ''}" title="${esc(title)}"${retry ? ` data-retry="${esc(p.id)}"` : ''}>
|
|
152
162
|
${isImage(p) && p.preview ? `<img src="${esc(p.preview)}" alt="${esc(p.name)}">` : `<span class="pname">${esc(p.name)}</span>`}
|
|
153
163
|
${p.status === 'uploading' ? `<span class="pbar" role="progressbar" aria-label="Uploading ${esc(p.name)}" aria-valuenow="${Math.round((p.progress || 0) * 100)}"><span style="width:${Math.max(4, Math.round((p.progress || 0) * 100))}%"></span></span>` : ''}
|
|
154
|
-
${p.status === 'error' ? `<span class="perr">${esc(p.error || 'Upload failed')}</span>` : ''}
|
|
155
|
-
<button type="button" class="unattach" data-unattach="${esc(p.id)}" aria-label="Remove ${esc(p.name)}">✕</button></span
|
|
164
|
+
${p.status === 'error' ? `<span class="perr">${esc(p.error || 'Upload failed')}${retry ? ' · tap to retry' : ''}</span>` : ''}
|
|
165
|
+
<button type="button" class="unattach" data-unattach="${esc(p.id)}" aria-label="Remove ${esc(p.name)}">✕</button></span>`;
|
|
166
|
+
}).join('');
|
|
156
167
|
|
|
157
168
|
// Replacing innerHTML would close every <details> inside, collapsing what the reader had open, and replace the field
|
|
158
169
|
// being typed in, dropping its text, cursor and focus, and on iOS the keyboard. So setHTML patches el in place: a
|
|
@@ -401,7 +412,7 @@
|
|
|
401
412
|
const copyNote = (what, savedAt) => `<p class="note copy-note">Copy of ${esc(what)} as of ${since(savedAt)}. Read only.</p>`;
|
|
402
413
|
// The buttons that send, which a board out of reach holds (style.css .offline): every one greyed and stopped, keeping its
|
|
403
414
|
// label and size. Reading controls, folds, links, Edit and drafts stay live.
|
|
404
|
-
const SENDS = `form button[type="submit"], .actions button, button[data-confirm], button[data-deck-pick], button[data-review-answer], button[data-prompt-send], button[data-prompt-choice], button[data-quick], button[data-card-bump], button[data-bump], button[data-withdraw], button[data-
|
|
415
|
+
const SENDS = `form button[type="submit"], .actions button, button[data-confirm], button[data-deck-pick], button[data-review-answer], button[data-prompt-send], button[data-prompt-choice], button[data-quick], button[data-card-bump], button[data-bump], button[data-withdraw], button[data-grill], button[data-proto], button[data-visual], button[data-plan], button[data-main-pull], button[data-arch-run], button[data-try], button[data-reopen]`;
|
|
405
416
|
// While held(), a tap on one of root's sending buttons, and any form's submit there, Enter's included, goes nowhere, and onHeld
|
|
406
417
|
// tells the tap it is waiting. on is the view's B.on.
|
|
407
418
|
function holdSends(on, root, held, onHeld) {
|
|
@@ -447,8 +458,14 @@
|
|
|
447
458
|
}
|
|
448
459
|
}
|
|
449
460
|
|
|
461
|
+
// Whether a click dismisses a modal: it landed on the backdrop element and the press that made it began there too, so a
|
|
462
|
+
// drag that starts inside (selecting text) and ends on the backdrop leaves the modal open.
|
|
463
|
+
let pressedOn = null;
|
|
464
|
+
if (typeof document !== 'undefined') document.addEventListener('pointerdown', (e) => { pressedOn = e.target; }, true);
|
|
465
|
+
const onBackdrop = (ev, backdrop) => ev.target === backdrop && pressedOn === backdrop;
|
|
466
|
+
|
|
450
467
|
globalThis.BoardUI = {
|
|
451
|
-
LINK_ARROW, escText,
|
|
468
|
+
LINK_ARROW, escText, onBackdrop,
|
|
452
469
|
esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, WAITING, RESTARTING, restartNotice, downOf, downLanding, copyNote, SENDS, holdSends,
|
|
453
470
|
attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, visualButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, thinkingLine, isPaused, PAUSED, BADGE_TONES, badge,
|
|
454
471
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// HubSettings: summary rows
|
|
2
|
-
// settingsView's words from GET /_hub/api/settings.
|
|
3
|
-
//
|
|
1
|
+
// HubSettings: summary rows open centred settings modals ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)), using
|
|
2
|
+
// settingsView's words from GET /_hub/api/settings. Linear's key and Slack's token open the same modal from the button on their Integrations rows (view-hub.js),
|
|
3
|
+
// with no summary row of their own ([Linear and Slack keys open in modals from their Integrations rows](docs/adr/2026-11-09-2230-linear-and-slack-keys-open-in-modals-from-their-integrations-rows.md)). Pause hub, first, posts to /_hub/settings/hub-pause. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
|
|
4
|
+
// hours' times and hold to /_hub/settings/quiet, and saves at once, as Linear's key and Slack's token do through linear-key.js; Done waits for
|
|
4
5
|
// a key's save to land. The rules editor saves through POST /_hub/settings/rules
|
|
5
6
|
// and marks [data-dirty] while an edit is unsaved, so the shell asks before leaving then. The data refreshes every 30 seconds while nothing is being edited.
|
|
6
7
|
(function () {
|
|
@@ -15,10 +16,6 @@
|
|
|
15
16
|
action: '/_hub/settings/hub-pause', name: 'pause', choices: p.choices, on: p.on, button: 'Resume' });
|
|
16
17
|
const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
|
|
17
18
|
choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
|
|
18
|
-
// Grey while off and orange while on, as the machine working (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md).
|
|
19
|
-
const awakeRow = (a) => durationPicker({ id: 'awake', title: 'Keep awake', icon: HubUI.tile('coffee', a.on ? 'var(--color-notice)' : 'var(--color-quiet)'),
|
|
20
|
-
action: '/_hub/settings/keep-awake', name: 'keep_awake', choices: a.choices, on: a.on, button: 'Turn off' });
|
|
21
|
-
|
|
22
19
|
// The on/off, snooze and weekend rows (docs/adr/2026-10-29-1639-notifications-can-be-off-snoozed-or-off-on-weekends.md).
|
|
23
20
|
function notificationRows(d) {
|
|
24
21
|
const { tile } = HubUI;
|
|
@@ -40,10 +37,12 @@
|
|
|
40
37
|
+ `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
const sections = [['pause', 'Pause hub'], ['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['
|
|
40
|
+
const sections = [['pause', 'Pause hub'], ['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
41
|
+
// Linear and Slack open from their Integrations rows, so they have modals and no summary rows.
|
|
42
|
+
const keys = [['linear', 'Linear'], ['slack', 'Slack']];
|
|
44
43
|
const sectionOf = (hash) => {
|
|
45
44
|
const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
|
|
46
|
-
return sections.some(([section]) => section === id) ? id : null;
|
|
45
|
+
return [...sections, ...keys].some(([section]) => section === id) ? id : null;
|
|
47
46
|
};
|
|
48
47
|
const markup = () => `${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="${id}" data-settings-open="${id}" aria-haspopup="dialog"><span class="text"><span class="title">${title}</span><span class="sub" data-summary="${id}">Loading…</span></span><span class="chev" aria-hidden="true">›</span></button>`).join('')}
|
|
49
48
|
<dialog class="settings-modal hub-modal" id="hubSettingsModal" aria-labelledby="hubSettingsTitle"><div class="settings-head"><div><h2 id="hubSettingsTitle" tabindex="-1"></h2><p class="muted small">Hub settings</p></div><button type="button" class="ghost" data-settings-dismiss aria-label="Close hub settings">✕</button></div><div class="set settings-body"><div class="hs-flash"></div><div class="setting-content"></div></div><div class="settings-footer"><button type="button" data-settings-dismiss>Done</button></div></dialog>`;
|
|
@@ -75,9 +74,6 @@
|
|
|
75
74
|
content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}</div>`;
|
|
76
75
|
} else if (active === 'quiet') {
|
|
77
76
|
content.innerHTML = quietRows(last.quiet);
|
|
78
|
-
} else if (active === 'keep-awake') {
|
|
79
|
-
content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
|
|
80
|
-
: `<div class="group">${awakeRow(last.awake)}</div><p class="note">Runs <code>caffeinate -ims</code> on the host so it doesn’t sleep while boards work. The screen can still turn off.</p>`;
|
|
81
77
|
} else if (active === 'linear' || active === 'slack') {
|
|
82
78
|
if (tokenSlot) tokenSlot.set(last[active]);
|
|
83
79
|
else {
|
|
@@ -90,14 +86,10 @@
|
|
|
90
86
|
}
|
|
91
87
|
pickers.painted();
|
|
92
88
|
B.$('.settings-body').scrollTop = scrollTop;
|
|
93
|
-
B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
|
|
94
89
|
const summaries = {
|
|
95
90
|
pause: last.pause.summary,
|
|
96
91
|
notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
|
|
97
92
|
quiet: last.quiet.summary,
|
|
98
|
-
'keep-awake': last.awake.summary,
|
|
99
|
-
linear: last.linear?.summary || 'No key',
|
|
100
|
-
slack: last.slack?.summary || 'No token',
|
|
101
93
|
rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
|
|
102
94
|
closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
|
|
103
95
|
};
|
|
@@ -156,7 +148,8 @@
|
|
|
156
148
|
active = id;
|
|
157
149
|
if (id) {
|
|
158
150
|
returnFocus = opener || B.$(`#${id}`);
|
|
159
|
-
B.$('#hubSettingsTitle').textContent = sections.find(([section]) => section === id)[1];
|
|
151
|
+
B.$('#hubSettingsTitle').textContent = [...sections, ...keys].find(([section]) => section === id)[1];
|
|
152
|
+
B.$('.settings-head .muted').textContent = keys.some(([key]) => key === id) ? 'Integrations' : 'Hub settings';
|
|
160
153
|
content.innerHTML = id === 'rules' ? '<div id="hubRules" class="rules-box"></div>' : '<p class="note">Loading…</p>';
|
|
161
154
|
flash = null;
|
|
162
155
|
paint();
|
|
@@ -180,7 +173,7 @@
|
|
|
180
173
|
const opener = e.target.closest?.('[data-settings-open]');
|
|
181
174
|
if (opener) { B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener); return; }
|
|
182
175
|
if (e.target.closest?.('[data-settings-dismiss]')) { dismiss(); return; }
|
|
183
|
-
if (e
|
|
176
|
+
if (BoardUI.onBackdrop(e, modal)) {
|
|
184
177
|
const r = modal.getBoundingClientRect();
|
|
185
178
|
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) dismiss();
|
|
186
179
|
}
|
|
@@ -216,7 +209,6 @@
|
|
|
216
209
|
else on.checked = true;
|
|
217
210
|
});
|
|
218
211
|
const forms = HubKit.forms(B, {
|
|
219
|
-
accepts: (action) => action !== '/_hub/push/flush',
|
|
220
212
|
answer(out) {
|
|
221
213
|
last = out;
|
|
222
214
|
flash = null;
|