@lovek4d/kickflip 0.6.0 → 0.7.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/CONTEXT.md +42 -26
- package/SKILL.md +16 -12
- package/bin/doctor.mjs +5 -4
- package/bin/event-lines.mjs +1 -0
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +7 -7
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/bin/board.mjs +70 -42
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -9
- package/board/onboarding.mjs +1 -1
- package/board/prototype-page.mjs +1 -1
- package/board/public/app-arch.js +2 -2
- package/board/public/app-banner.js +1 -1
- package/board/public/app-cards.js +36 -48
- package/board/public/app-lanes.js +6 -9
- package/board/public/app-needs.js +107 -26
- package/board/public/app-reader.js +52 -42
- package/board/public/app-threads.js +14 -10
- package/board/public/app.html +1 -1
- package/board/public/app.js +22 -12
- package/board/public/progress-review.js +4 -4
- package/board/public/prototype-kit.js +25 -11
- package/board/public/prototype-switcher.js +53 -33
- package/board/public/prototype.html +7 -4
- package/board/public/router.js +34 -6
- package/board/public/shell.js +23 -16
- package/board/public/sidebar.css +7 -2
- package/board/public/sidebar.js +3 -0
- package/board/public/style.css +69 -30
- package/board/public/ui-cards.js +61 -38
- package/board/public/ui-enums.js +12 -3
- package/board/public/ui-header.js +4 -6
- package/board/public/ui-hub.js +7 -4
- package/board/public/ui-links.js +1 -1
- package/board/public/ui-order.js +9 -6
- package/board/public/ui-page-header.js +1 -1
- package/board/public/ui-progress-bar.js +6 -7
- package/board/public/ui-questions.js +18 -3
- package/board/public/ui-sidebar.js +12 -5
- package/board/public/ui-snapshot.js +1 -1
- package/board/public/ui-threads.js +10 -12
- package/board/public/ui.js +7 -4
- package/board/public/view-hub.js +3 -1
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +19 -3
- package/board/public/view-settings.js +60 -28
- package/board/server.mjs +19 -19
- package/board/stop.mjs +1 -1
- package/board/store.mjs +27 -25
- package/board/tickets.mjs +1 -1
- package/board/transcript.mjs +4 -2
- package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
- package/hub/front-door.mjs +1 -1
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +6 -6
- package/hub/sessions.mjs +2 -2
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/package.json +1 -1
- package/shared/card-prs.mjs +1 -1
- package/shared/machine-gate.mjs +2 -2
- package/shared/tailscale.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
package/board/public/ui-cards.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js, ui-enums.js and ui-progress-bar.js.
|
|
2
2
|
(function () {
|
|
3
|
-
const { esc, escText, ago, lasted, clock, cardTime, etaLine, workingLine, thinkingLine, isPaused, PAUSED, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, LINK_ARROW, progressBar } = globalThis.BoardUI;
|
|
3
|
+
const { esc, escText, ago, lasted, clock, cardTime, etaLine, workingLine, thinkingLine, isPaused, PAUSED, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, PHASES, LINK_ARROW, progressBar } = globalThis.BoardUI;
|
|
4
4
|
const EXTERNAL = { target: '_blank', rel: 'noopener' };
|
|
5
5
|
// The PR badge is the one place a PR's state shows: its mark and tone, marked like the reviewer and CI chips, and its
|
|
6
6
|
// title (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). A card with several PRs shows a
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
[PR_STATE.merged]: { tone: 'merged', mark: ' ✓', label: 'Merged' },
|
|
12
12
|
[PR_STATE.closed]: { tone: 'danger', mark: ' ✗', label: 'Closed' },
|
|
13
13
|
};
|
|
14
|
+
// docs/adr/2026-11-06-1547-a-cards-pr-badges-stay-once-added.md: a PR no longer open keeps its CI, Codex and reviewer
|
|
15
|
+
// chips at their last state, gray.
|
|
16
|
+
const keeps = (pr) => pr?.status === PR_STATE.merged || pr?.status === PR_STATE.closed;
|
|
14
17
|
// An open PR reads green only while it waits on a reviewer alone: CI passed and no reviewer asked for changes.
|
|
15
18
|
const waitsOnReviewer = (pr) => pr.checks?.state === 'pass' && pr.reviewDecision !== 'CHANGES_REQUESTED';
|
|
16
19
|
const prChips = (it) => (it.prs || []).map(prChip).join('');
|
|
@@ -20,8 +23,9 @@
|
|
|
20
23
|
const base = PR_LOOK[pr.status] || { tone: 'quiet', mark: '', label: '' };
|
|
21
24
|
const look = pr.status === PR_STATE.open && waitsOnReviewer(pr) ? { ...base, tone: 'ready', label: 'Ready for review' } : base;
|
|
22
25
|
const CI = { pass: { tone: 'ready', mark: '✓' }, fail: { tone: 'danger', mark: '✗' }, pending: { tone: 'notice', mark: '●' } }[pr.checks?.state] || { tone: 'quiet', mark: '?' };
|
|
23
|
-
const
|
|
24
|
-
const
|
|
26
|
+
const live = pr.state === 'OPEN';
|
|
27
|
+
const ci = pr.checks ? badge(live ? CI.tone : 'quiet', `CI ${CI.mark}`, { title: `CI ${pr.checks.state}` }) : '';
|
|
28
|
+
const codex = pr.codexOpen ? badge(live ? 'notice' : 'quiet', `codex ${esc(pr.codexOpen)}`, { title: `${pr.codexOpen} Codex finding${pr.codexOpen === 1 ? '' : 's'} not answered yet` }) : '';
|
|
25
29
|
const name = prName(pr);
|
|
26
30
|
const label = look.label ? { title: look.label, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${look.label}` } } : { attrs: EXTERNAL };
|
|
27
31
|
return `${badge(look.tone, `${esc(name)}${look.mark}`, { href: pr.url, ...label })}${ci}${codex}`;
|
|
@@ -39,12 +43,13 @@
|
|
|
39
43
|
return days >= 1 ? ` ${days}d` : '';
|
|
40
44
|
};
|
|
41
45
|
function reviewChips(it) {
|
|
42
|
-
|
|
46
|
+
const live = it.pr?.state === 'OPEN';
|
|
47
|
+
if (!live && !keeps(it.pr)) return '';
|
|
43
48
|
return (it.reviewers || []).map((r) => {
|
|
44
49
|
const rv = globalThis.BoardUI.record(it.review, r);
|
|
45
50
|
const s = REVIEW[rv?.state || 'unsent'];
|
|
46
|
-
const age = reviewAge(rv).trim();
|
|
47
|
-
return badge(s.tone, `${esc(rv?.github || r)}${age ? ` (${age})` : ''} ${s.mark}`, { title: `${r}${rv?.github ? ` (${rv.github})` : ''}: ${s.label}` });
|
|
51
|
+
const age = live ? reviewAge(rv).trim() : '';
|
|
52
|
+
return badge(live ? s.tone : 'quiet', `${esc(rv?.github || r)}${age ? ` (${age})` : ''} ${s.mark}`, { title: `${r}${rv?.github ? ` (${rv.github})` : ''}: ${s.label}` });
|
|
48
53
|
}).join('');
|
|
49
54
|
}
|
|
50
55
|
const pendingQuestions = (it) => (it.questions && it.questions.state !== 'resolved' ? it.questions.open + it.questions.answered + it.questions.replied : 0);
|
|
@@ -63,7 +68,7 @@
|
|
|
63
68
|
function blockedWords(it) {
|
|
64
69
|
const deps = it.blockedBy || [];
|
|
65
70
|
const waits = it.bumpedAt && !it.held;
|
|
66
|
-
const refs = deps.map((d, i) => `<a class="card-blocked" href="#
|
|
71
|
+
const refs = deps.map((d, i) => `<a class="card-blocked" href="#selectedCard=${encodeURIComponent(d.key)}">${i ? '' : waits ? 'waits on ' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
|
|
67
72
|
const until = it.blockedUntil ? `${deps.length ? ' and until' : waits ? 'waits until' : 'blocked until'} ${it.blockedUntil}` : '';
|
|
68
73
|
const tail = until + (it.blockReason && (refs || until) ? `, ${it.blockReason}` : '');
|
|
69
74
|
return refs + (tail ? `<span class="card-blocked">${esc(tail)}</span>` : '');
|
|
@@ -94,7 +99,7 @@
|
|
|
94
99
|
if (!t) return null;
|
|
95
100
|
const words = tryWords(t.kind);
|
|
96
101
|
if (t.state === 'running') return { tone: 'notice', html: spinner + esc(words.going), attrs: { role: 'status' } };
|
|
97
|
-
if (t.state === 'failed') return { tone: 'danger', html: `${esc(words.failed)} ${LINK_ARROW}`, href: `#
|
|
102
|
+
if (t.state === 'failed') return { tone: 'danger', html: `${esc(words.failed)} ${LINK_ARROW}`, href: `#selectedCard=${encodeURIComponent(it.key)}`,
|
|
98
103
|
title: 'The build script stopped with an error. Tap for its log in the card’s thread.', attrs: { 'data-try-log': it.key } };
|
|
99
104
|
if (t.state !== 'ready' || !t.url) return null;
|
|
100
105
|
return { tone: t.stale ? 'quiet' : 'ready', html: `${esc(words.done)}${t.stale ? ' (stale)' : ''} ${LINK_ARROW}`, href: t.url,
|
|
@@ -167,23 +172,15 @@
|
|
|
167
172
|
// Five bars at the end of an In Progress card's time row: reached phases filled, a running one marked. The phases are
|
|
168
173
|
// store.mjs PHASES (docs/adr/2026-11-03-0128-a-builder-status-line-is-a-phase-and-a-short-detail.md,
|
|
169
174
|
// docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
|
|
170
|
-
const BUILD_PHASES =
|
|
175
|
+
const BUILD_PHASES = PHASES.progress;
|
|
171
176
|
const stepWords = (phase) => `step ${BUILD_PHASES.indexOf(phase) + 1} of ${BUILD_PHASES.length}`;
|
|
172
177
|
const ALL_DONE = `all ${BUILD_PHASES.length} steps done`;
|
|
173
|
-
// A card's progress bar (ui-progress-bar.js), or null off In Progress and on a card only a planner
|
|
174
|
-
// phases it reached, which of them is running, whether it dims because the card waits on something
|
|
175
|
-
// questions, a blocker or queue, a reopening) or its build is stale, and its label. An install
|
|
176
|
-
// build reached, undimmed
|
|
177
|
-
// (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md).
|
|
178
|
-
// A Todo card a grill or prototype runs on draws the planner's dashes instead, in Todo's grey, the grill's first and the
|
|
179
|
-
// prototype's second running (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md,
|
|
180
|
-
// docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md).
|
|
178
|
+
// 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
|
+
// 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) or its build is stale, and its label. An install
|
|
181
|
+
// is no build: its card keeps the phase its build reached, undimmed
|
|
182
|
+
// (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).
|
|
181
183
|
function phaseStrip(it) {
|
|
182
|
-
if (it.lane === 'todo' && it.builder?.planner) {
|
|
183
|
-
const prototype = it.builder.kind === 'prototype';
|
|
184
|
-
const done = prototype ? 2 : 1;
|
|
185
|
-
return { count: BUILD_PHASES.length, done, now: done - 1, tone: 'in-todo', label: prototype ? 'Prototyping' : 'Grilling' };
|
|
186
|
-
}
|
|
187
184
|
if (it.lane !== 'progress' || it.builder?.planner) return null;
|
|
188
185
|
const all = BUILD_PHASES.length;
|
|
189
186
|
// Orchestrator review is the checking phase, before landing (docs/adr/2026-11-04-2130-the-hub-launches-every-agent-from-the-card.md).
|
|
@@ -194,7 +191,7 @@
|
|
|
194
191
|
const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
|
|
195
192
|
if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
|
|
196
193
|
const done = BUILD_PHASES.indexOf(it.builder.phase) + 1;
|
|
197
|
-
return { count: all, done, now: done ? done - 1 : undefined, dim: asking || Boolean(it.builder.
|
|
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}` };
|
|
198
195
|
}
|
|
199
196
|
const dim = !it.builder || asking;
|
|
200
197
|
if (it.reopened) return { count: all, done: all, dim, label: `Reopened, ${ALL_DONE}${answers}` };
|
|
@@ -221,16 +218,15 @@
|
|
|
221
218
|
const words = isPaused() ? `${PAUSED} · ${esc(lasted(picked || since))}` : picked ? `${DOT}orchestrator review · ${esc(lasted(picked))}` : `waiting for orchestrator · ${esc(lasted(since))}`;
|
|
222
219
|
return `<div class="line3 sub agent${tone(it)}${picked && !isPaused() ? '' : ' waiting-gray'}">${words}</div>`;
|
|
223
220
|
}
|
|
224
|
-
//
|
|
225
|
-
// A
|
|
221
|
+
// docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md
|
|
222
|
+
// A stale build's status line is grey, its dot still, since the orchestrator is already told; a quiet one is grey with
|
|
223
|
+
// no dot, and one the orchestrator stopped plain.
|
|
226
224
|
function builderStatus(it) {
|
|
227
225
|
const { quiet } = it.builder;
|
|
228
|
-
const
|
|
226
|
+
const stale = it.builder.stale && !it.builder.stopped && it.lane !== 'done';
|
|
227
|
+
const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}${tone(it)}${quiet ? ' quiet' : ''}${stale ? ' stale' : ''}`;
|
|
229
228
|
if (it.builder.stopped) return `<div class="${classes}">${esc(builderLine(it.builder))}</div>`;
|
|
230
|
-
|
|
231
|
-
if (!it.builder.stalled || it.lane === 'done') return `<div class="${classes}">${words}</div>`;
|
|
232
|
-
const nudging = BoardUI.pending.has(`${it.key}:nudge`) ? ' nudging' : '';
|
|
233
|
-
return `<button type="button" class="${classes} stalled${nudging}" data-confirm="nudge" data-key="${esc(it.key)}" title="No recent progress. Nudge Claude to check this implementer">${words}</button>`;
|
|
229
|
+
return `<div class="${classes}">${quiet ? '' : DOT}${esc(builderLine(it.builder))}</div>`;
|
|
234
230
|
}
|
|
235
231
|
// The open card's Status row: what runs on the card, else where its lane leaves it, never the wave file's raw status
|
|
236
232
|
// (docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
|
|
@@ -241,7 +237,7 @@
|
|
|
241
237
|
if (it.waitsOn === 'checking') return 'Orchestrator review';
|
|
242
238
|
if (it.lane === 'done') return 'done';
|
|
243
239
|
if (it.grilling || it.prototyping) return 'preheating';
|
|
244
|
-
if (it.lane === 'todo') return it.
|
|
240
|
+
if (it.lane === 'todo') return it.phase && (it.ready || it.phase !== 'ready') ? PHASE_LOOK[it.phase][0] : 'to do';
|
|
245
241
|
if (it.lane === 'progress') return it.reachedPhase ? `stopped at ${it.reachedPhase}` : 'in progress';
|
|
246
242
|
return 'in review';
|
|
247
243
|
}
|
|
@@ -263,15 +259,36 @@
|
|
|
263
259
|
: `Held until ${d.toLocaleDateString([], { weekday: 'short', month: 'numeric', day: 'numeric' })}, ${clock(d)}. ${it.lane === 'todo' ? 'It starts on its own then, once nothing else blocks it.' : 'Claude picks it up again then.'}`;
|
|
264
260
|
return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
|
|
265
261
|
}
|
|
262
|
+
// docs/adr/2026-11-11-1200-every-lane-has-phases.md
|
|
263
|
+
// A Todo, In Review or Done card's phase as a word, toned by who acts next: grey while a machine waits, blue while
|
|
264
|
+
// Claude works, orange while it waits on the owner, green once it can go and purple once merged. In Progress shows its
|
|
265
|
+
// phase only as its progress bar, and a card In Review for an owner wait other than the merge has none. Ready reads
|
|
266
|
+
// only on a card that can start (domain.mjs ready), so a blocked one shows what blocks it instead.
|
|
267
|
+
const PHASE_LOOK = {
|
|
268
|
+
new: ['new', 'quiet'], grilling: ['grilling', 'info'], prototyping: ['prototyping', 'info'], staging: ['staging', 'notice'], ready: ['ready', 'success'],
|
|
269
|
+
draft: ['draft', 'quiet'], reviewers: ['pending review', 'notice'], merge: ['pending merge', 'notice'], stale: ['stale', 'quiet'],
|
|
270
|
+
merged: ['merged', 'merged'], closed: ['closed', 'quiet'],
|
|
271
|
+
};
|
|
272
|
+
function phaseWord(it) {
|
|
273
|
+
if (it.kind !== 'work' || it.lane === 'progress' || !it.phase || (it.phase === 'ready' && !it.ready)) return '';
|
|
274
|
+
const [word, tone] = PHASE_LOOK[it.phase];
|
|
275
|
+
return `<span class="card-phase ph-${tone}">${word}</span>`;
|
|
276
|
+
}
|
|
266
277
|
// A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed,
|
|
267
|
-
//
|
|
278
|
+
// bumped out by the card whose bump took its slot, or held while host recovers on a bumped ready card a busy host holds,
|
|
279
|
+
// both grey (docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md), stalled in red or a build's stale
|
|
280
|
+
// 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
|
|
268
282
|
// takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
|
|
269
283
|
// 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).
|
|
270
284
|
const SEP = '<span class="sep"> · </span>';
|
|
271
285
|
const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
|
|
272
286
|
function timeLine(it, unread, bars) {
|
|
273
287
|
const iso = it.sortAt;
|
|
274
|
-
const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it),
|
|
288
|
+
const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it),
|
|
289
|
+
it.bumpedOut && `<a class="card-bumped-out" href="#selectedCard=${encodeURIComponent(it.bumpedOut.key)}">bumped out by ${esc(it.bumpedOut.ref)}</a>`,
|
|
290
|
+
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);
|
|
275
292
|
const news = newWords(unread, 'message');
|
|
276
293
|
const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
|
|
277
294
|
const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
|
|
@@ -290,7 +307,7 @@
|
|
|
290
307
|
+ `<div class="dur-custom"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-hold-at="${esc(key)}" required aria-label="Custom hold until a date and time"></div></div></div>`;
|
|
291
308
|
}
|
|
292
309
|
// A New card's bump, edit and delete, and its line from its ask state (domain.mjs askItem;
|
|
293
|
-
// docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). top is whether it is
|
|
310
|
+
// docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). top is whether it is bumped already.
|
|
294
311
|
const askActions = (it, top) => `<div class="ask-actions">${bumpButton(it, top)}${it.ask.canEdit ? editButton(it.id) : ''}${withdrawButton(it.id)}</div>`;
|
|
295
312
|
// A card waiting on its owner step has the owner's Done where the bump sits: a check icon that confirms, then sends
|
|
296
313
|
// card-done (app-cards.js; docs/adr/2026-11-04-0158-a-card-whose-code-landed-stays-in-review-while-an-owner-step-remains.md).
|
|
@@ -298,15 +315,21 @@
|
|
|
298
315
|
const ownerDoneButton = (it) => `<button type="button" class="ghost icon owner-done" data-confirm="finish" data-key="${esc(it.key)}" aria-label="Done" title="${esc(`Done: ${it.ownerStep}`)}">${CHECK}</button>`;
|
|
299
316
|
const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
|
|
300
317
|
: it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
|
|
301
|
-
|
|
318
|
+
// docs/adr/2026-11-07-0115-the-orchestrator-sorts-new-tasks-itself.md: a New card reads a grey "waiting", its dot
|
|
319
|
+
// still, until the orchestrator picks it up (the watcher's working mark), then "thinking".
|
|
320
|
+
const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting</span>';
|
|
321
|
+
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(' · ');
|
|
302
322
|
// In a wave band (app-lanes.js), step is the number of the card's phase in its wave, shown inside its id chip, and actions replace
|
|
303
323
|
// its bump with the band's ⋯ menu.
|
|
304
324
|
function workLines(it, { unread = 0, top = false, step = 0, actions = null } = {}) {
|
|
305
325
|
const ticket = it.ticket ? ticketChip(it) : it.ticketSource === 'linear' ? linearChips(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : linearChips(it);
|
|
306
|
-
// A work card always shows its ticket;
|
|
326
|
+
// A work card always shows its ticket; an ask, which has none, shows New or Ask there (askChip).
|
|
307
327
|
const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
|
|
308
328
|
const ids = it.kind === 'ask' ? askChip(it) : chip;
|
|
309
329
|
const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
|
|
330
|
+
// A handled ask's asked time sits under its title, since its Ask badge holds the ticket's spot
|
|
331
|
+
// (docs/adr/2026-11-10-1200-an-ask-is-titled-in-place-and-wears-an-ask-badge.md).
|
|
332
|
+
const asked = it.kind === 'ask' && !it.isNew ? `<div class="line3 sub">asked ${esc(cardTime(it.ask.askedAt))}</div>` : '';
|
|
310
333
|
const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
|
|
311
334
|
: it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${escText(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
|
|
312
335
|
: it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
|
|
@@ -321,12 +344,12 @@
|
|
|
321
344
|
const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${escText(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
|
|
322
345
|
: `<div class="line3 sub check">${escText(`Checks: ${it.ownerCheck.words}`)}</div>`;
|
|
323
346
|
return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
|
|
324
|
-
${status}${check}${eta}
|
|
347
|
+
${asked}${status}${check}${eta}
|
|
325
348
|
${chips ? `<div class="line2 meta">${chips}</div>` : ''}
|
|
326
349
|
${timeLine(it, unread, progressBar(phaseStrip(it)))}`;
|
|
327
350
|
}
|
|
328
351
|
const WAVE_ICON = '<svg class="wave-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 9c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/><path d="M2 16c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/></svg>';
|
|
329
|
-
const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
|
|
352
|
+
const askChip = (it) => (it.isNew ? badge('notice', 'New') : badge('quiet', 'Ask'));
|
|
330
353
|
function planButton(items) {
|
|
331
354
|
const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blocked).map((i) => i.key);
|
|
332
355
|
return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}"${BoardUI.pending.has('todo:plan') ? BoardUI.BUSY : ''}>Plan waves</button>` : '';
|
package/board/public/ui-enums.js
CHANGED
|
@@ -3,9 +3,18 @@
|
|
|
3
3
|
// badge shows (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). Each value is its name,
|
|
4
4
|
// since cards carry it in the board's JSON. Needs nothing else: board/domain.mjs runs this file in a vm context (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
|
|
5
5
|
(function () {
|
|
6
|
-
const BUTTON = Object.freeze({ openQuestions: 'openQuestions', done: 'done', github: 'github', merge: 'merge' });
|
|
7
|
-
const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done' });
|
|
6
|
+
const BUTTON = Object.freeze({ openQuestions: 'openQuestions', done: 'done', github: 'github', merge: 'merge', reply: 'reply' });
|
|
7
|
+
const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done', stalled: 'stalled' });
|
|
8
8
|
const BUILDER_KIND = Object.freeze({ install: 'install' });
|
|
9
9
|
const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
|
|
10
|
-
|
|
10
|
+
// Each lane's phases, in order, which a card stores beside its lane (docs/adr/2026-11-11-1200-every-lane-has-phases.md).
|
|
11
|
+
// In Progress's are the five build steps its progress bar draws; a review's "reviewers" and "merge" read "pending review"
|
|
12
|
+
// and "pending merge" on the card (ui-cards.js PHASE_LOOK).
|
|
13
|
+
const PHASES = Object.freeze({
|
|
14
|
+
todo: Object.freeze(['new', 'grilling', 'prototyping', 'staging', 'ready']),
|
|
15
|
+
progress: Object.freeze(['reading', 'building', 'testing', 'checking', 'landing']),
|
|
16
|
+
review: Object.freeze(['draft', 'reviewers', 'merge', 'stale']),
|
|
17
|
+
done: Object.freeze(['merged', 'closed']),
|
|
18
|
+
});
|
|
19
|
+
Object.assign(globalThis.BoardUI ||= {}, { BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, PHASES });
|
|
11
20
|
})();
|
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
// The board header's time and the In Review header's GitHub line, with when a refresh asks for a check. Needs ui.js.
|
|
2
2
|
(function () {
|
|
3
3
|
const { since, esc } = globalThis.BoardUI;
|
|
4
|
-
// The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh). It reads that age
|
|
4
|
+
// The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh). It reads that age;
|
|
5
5
|
// the board's other states only move the span's colour (the arrow follows it, style.css) and its title. watcher is
|
|
6
6
|
// /api/board's checks.watcher (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md): stopped
|
|
7
|
-
// (nothing will wake it) and an offline link turn it red, paused usage turns it orange. A busy
|
|
8
|
-
// docs/adr/2026-11-06-
|
|
7
|
+
// (nothing will wake it) and an offline link turn it red, paused usage turns it orange. A busy host doesn't change it
|
|
8
|
+
// at all (docs/adr/2026-11-06-1305-a-busy-host-shows-only-on-the-hub-row-and-the-logo-dot.md).
|
|
9
9
|
// link is 'online', 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it, and the icon spins).
|
|
10
10
|
const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => {
|
|
11
11
|
const paused = pause?.state === 'paused';
|
|
12
|
-
const busy = pause?.machine?.state === 'busy';
|
|
13
12
|
const down = link === 'offline' || watcher === 'stopped';
|
|
14
13
|
const reason = link === 'offline' ? "Offline: can't reach the host"
|
|
15
14
|
: watcher === 'stopped' ? 'Claude not listening'
|
|
16
15
|
: paused ? `Paused ${BoardUI.pauseUntilWords(pause.resets_at)}`
|
|
17
|
-
: busy ? 'This Mac is busy: new work waits until it eases'
|
|
18
16
|
: link === 'reconnecting' ? 'Reconnecting…'
|
|
19
17
|
: watcher === 'waking' || (waking && watcher === 'asleep') ? 'Waking: the hub is resuming its session'
|
|
20
18
|
: watcher === 'asleep' ? 'Asleep: its session idled out. Refresh, or any tap on the page, wakes it' : '';
|
|
21
19
|
if (!at && !reason) return '';
|
|
22
20
|
const title = [reason, at && `Updated ${since(at)}: the last change from the host, or your last refresh`].filter(Boolean).join('. ');
|
|
23
|
-
return `<span class="fresh${down ? ' down' : paused
|
|
21
|
+
return `<span class="fresh${down ? ' down' : paused ? ' hold' : ''}" title="${esc(title)}">${at ? shortSince(at) : ''}</span>`;
|
|
24
22
|
};
|
|
25
23
|
// What the page shows, for telling whether a change signal brought anything new; generatedAt changes on every load.
|
|
26
24
|
const freshSig = (board, threads, openKeys) => JSON.stringify([{ ...board, generatedAt: null }, [...openKeys].sort().map((k) => [k, threads.get(k) ?? null])]);
|
package/board/public/ui-hub.js
CHANGED
|
@@ -34,14 +34,17 @@
|
|
|
34
34
|
// hold: hub/views.mjs hubView's hold, or null for no banner. Its End form posts snooze=off, which view-hub.js sends.
|
|
35
35
|
const holdBanner = (hold) => (hold ? `<div class="hold-banner"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${TILE_PATHS.moon}</svg><span>${esc(hold.text)}</span>${hold.end ? '<form method="post" action="/_hub/settings/notifications"><button class="btn quiet" name="snooze" value="off">End snooze</button></form>' : ''}</div>` : '');
|
|
36
36
|
|
|
37
|
+
// pause: shared/usage-gate.mjs usageGate's gate. Near the line (its near), the banner says agents start one at a time,
|
|
38
|
+
// with Keep going (docs/adr/2026-11-06-1249-the-pause-line-reads-every-headless-agents-usage.md).
|
|
37
39
|
function pauseBanner(pause) {
|
|
38
|
-
if (!pause || pause.state === 'normal') return '';
|
|
39
|
-
const going = pause.state === 'going';
|
|
40
|
-
const until = BoardUI.usageClock(pause.resets_at);
|
|
40
|
+
if (!pause || (pause.state === 'normal' && !pause.near)) return '';
|
|
41
|
+
const going = pause.state === 'going' || Boolean(pause.state === 'normal' && pause.near.going);
|
|
42
|
+
const until = BoardUI.usageClock(pause.state === 'normal' ? pause.near.resets_at : pause.resets_at);
|
|
41
43
|
const icon = going ? '<path d="M3.34 19a10 10 0 1 1 17.32 0"/><path d="m12 14 4-4"/>' : '<circle cx="12" cy="12" r="9"/><path d="M10 9v6"/><path d="M14 9v6"/>';
|
|
42
44
|
// Pause hub (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md): Resume ends it, which view-hub.js posts.
|
|
43
45
|
if (pause.manual) return `<div class="hold-banner ub-banner ub-paused" role="status"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg><span>${esc(pause.resets_at === 'forever' ? 'Work paused: you paused the hub until you resume.' : `Work paused: you paused the hub. Resumes at ${until}.`)}</span><form method="post" action="/_hub/settings/hub-pause"><button class="btn quiet" name="pause" value="off">Resume</button></form></div>`;
|
|
44
|
-
const text = going ? `
|
|
46
|
+
const text = pause.state === 'normal' ? going ? `Approaching usage limit: agents start in parallel until ${until}` : 'Approaching usage limit, reducing parallel agents'
|
|
47
|
+
: going ? `Past the buffer: spending usage until ${until}`
|
|
45
48
|
: `Work paused: Claude’s ${pause.window === 'five_hour' ? '5‑hour' : 'weekly'} usage is at ${Math.round(pause.used)}%. Resumes ${pause.window === 'five_hour' ? 'at ' : ''}${until}.`;
|
|
46
49
|
return `<div class="hold-banner ub-banner ub-${going ? 'going' : 'paused'}" role="status"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg><span>${esc(text)}</span><form method="post" action="/_hub/settings/keep-going"><button class="btn${going ? '' : ' quiet'}" name="value" value="${going ? 'off' : 'on'}">${going ? 'Stop' : 'Keep going'}</button></form></div>`;
|
|
47
50
|
}
|
package/board/public/ui-links.js
CHANGED
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
return anyLink(url, url) + esc(raw.slice(url.length));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
const cardLink = (ref, ctx) => `<a href="${esc(`${ctx.base || ''}#
|
|
71
|
+
const cardLink = (ref, ctx) => `<a href="${esc(`${ctx.base || ''}#selectedCard=${encodeURIComponent(record(ctx.refs, ref))}`)}">${esc(ref)}</a>`;
|
|
72
72
|
|
|
73
73
|
// The longest hyphen-prefix of a word that is a known ref or a Linear id, then the rest as text.
|
|
74
74
|
function wordLink(word, ctx) {
|
package/board/public/ui-order.js
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
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, sortAt, order and key.
|
|
3
|
+
// (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, bumpedOut, 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,
|
|
7
|
-
//
|
|
8
|
-
// (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md
|
|
6
|
+
const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3, asks: 4 };
|
|
7
|
+
// Every lane but Done lists bumped cards first, the earliest bump on top, a bumped New ask included
|
|
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), then in Todo the cards a bump bumped out,
|
|
10
|
+
// the earliest first, so the pull takes them next. Todo lists the rest oldest first by sortAt, a card with no
|
|
9
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
|
|
10
12
|
// top ready card (docs/adr/2026-11-05-2345-new-todo-cards-join-the-bottom.md,
|
|
11
13
|
// docs/adr/2026-11-06-0300-a-new-task-lands-at-the-bottom-of-todo-and-flashes-blue.md). Every other
|
|
12
14
|
// lane lists its cards newest first by sortAt, a card with no time last, the last added to its wave file on top.
|
|
15
|
+
// Present times first, the earliest on top.
|
|
16
|
+
const earliest = (x, y) => (x ? 0 : 1) - (y ? 0 : 1) || String(x || '').localeCompare(String(y || ''));
|
|
13
17
|
function laneOrder(a, b) {
|
|
14
|
-
const bumped = (i) => i.bumpedAt || '';
|
|
15
18
|
const at = (i) => String(i.sortAt || '');
|
|
16
19
|
const oldestFirst = a.lane === 'todo';
|
|
17
20
|
const timeless = (i) => (at(i) ? 0 : 1);
|
|
18
21
|
const age = oldestFirst ? timeless(a) - timeless(b) || at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
|
|
19
22
|
const added = a.lane !== 'todo' ? b.order - a.order : a.order - b.order;
|
|
20
|
-
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] ||
|
|
23
|
+
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || earliest(a.bumpedAt, b.bumpedAt) || earliest(a.bumpedOut?.at, b.bumpedOut?.at) || age || added || a.key.localeCompare(b.key);
|
|
21
24
|
}
|
|
22
25
|
Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
|
|
23
26
|
})();
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
const BULB = icon(18, '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>');
|
|
11
11
|
// The pages switch (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md): a bare ? and a bare check, drawn
|
|
12
12
|
// like the bulb and gear.
|
|
13
|
-
const QUESTION = icon(18, '<path d="
|
|
13
|
+
const QUESTION = icon(18, '<path d="M6.5 8.5a5.5 5.5 0 1 1 8.2 4.8c-1.6.9-2.7 1.8-2.7 3.2V17"/><path d="M12 21h.01"/>');
|
|
14
14
|
const CHECK = icon(18, '<path d="M20 6 9 17l-5-5"/>');
|
|
15
15
|
const id = (v) => (v ? ` id="${esc(v)}"` : '');
|
|
16
16
|
|
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
// The progress bar every bar on the board draws from: an In Progress card's five build phases and a
|
|
2
|
-
//
|
|
1
|
+
// The progress bar every bar on the board draws from: an In Progress card's five build phases (ui-cards.js), and a
|
|
2
|
+
// starting board's three start steps (ui-sidebar.js)
|
|
3
3
|
// (docs/adr/2026-11-06-0038-a-progress-bar-pulses-one-step-at-a-time.md). It needs nothing else, since hub/hub.mjs
|
|
4
4
|
// renders the shell's first sidebar with it in a vm context. style.css draws the bars.
|
|
5
5
|
(function () {
|
|
6
6
|
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
7
|
-
// {count, done, now, percent, dim,
|
|
7
|
+
// {count, done, now, percent, dim, label}, or null for no bar. done counts the steps reached, filled; now is the
|
|
8
8
|
// index of the one step still running, which pulses, so no bar can draw two. percent fills the step after the done
|
|
9
|
-
// ones that far. A dim bar draws its running step filled, without the pulse.
|
|
10
|
-
// Todo's grey; with none it is In Progress's orange.
|
|
9
|
+
// ones that far. A dim bar draws its running step filled, without the pulse.
|
|
11
10
|
function progressBar(bar) {
|
|
12
11
|
if (!bar) return '';
|
|
13
|
-
const { count, done, now, percent, dim,
|
|
12
|
+
const { count, done, now, percent, dim, label } = bar;
|
|
14
13
|
const step = (i) => (i === now ? `<i class="${dim ? 'done' : 'now'}"></i>` : i < done ? '<i class="done"></i>'
|
|
15
14
|
: i === done && percent !== undefined ? `<i class="fill" style="--p:${percent}%"></i>` : '<i class=""></i>');
|
|
16
|
-
return `<span class="phase-bars${dim ? ' dim' : ''}
|
|
15
|
+
return `<span class="phase-bars${dim ? ' dim' : ''}" role="img" aria-label="${esc(label)}" title="${esc(label)}">${Array.from({ length: count }, (_, i) => step(i)).join('')}</span>`;
|
|
17
16
|
}
|
|
18
17
|
// Every pulse on the page beats on one clock: each one that starts is set to the document timeline's origin, so a
|
|
19
18
|
// card's running step and its status dot blink together, and a redraw that draws a new copy never restarts the beat.
|
|
@@ -129,8 +129,8 @@
|
|
|
129
129
|
${answerHistory(q, links)}
|
|
130
130
|
</section>`;
|
|
131
131
|
}
|
|
132
|
-
// A card's
|
|
133
|
-
const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) :
|
|
132
|
+
// A pill reads its topic's title, a card's being its ref then its title, whichever tracker it comes from; an ask (item:ask:<id>) reads the time it appeared.
|
|
133
|
+
const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) : t.title);
|
|
134
134
|
// /api/board's ownerTopics, each card's topic named from its card, which the board sends in its place while the card
|
|
135
135
|
// is on the board (board/server.mjs pageBoard). cards: the board's items.
|
|
136
136
|
function cardTopics(topics, cards) {
|
|
@@ -174,5 +174,20 @@
|
|
|
174
174
|
},
|
|
175
175
|
};
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
// A prototype viewer's Round pill and its sheet, on the full page (prototype-switcher.js) and in the board's right pane
|
|
178
|
+
// (app-needs.js). rounds: the card's prototype questions, oldest round first, as /api/board's card prototypes.rounds
|
|
179
|
+
// lists them ({n, q, url, confirm, state}). The pill names the round qid is in, out of the newest round's number; a
|
|
180
|
+
// card with a single round gets none.
|
|
181
|
+
function roundPill(rounds, qid) {
|
|
182
|
+
const at = (rounds || []).find((r) => r.q === qid);
|
|
183
|
+
if (!at || rounds.length < 2) return '';
|
|
184
|
+
return `<button type="button" class="ps-chip" data-ps-top="rounds">Round ${at.n} of ${Math.max(...rounds.map((r) => r.n))} · earlier</button>`;
|
|
185
|
+
}
|
|
186
|
+
const ROUND_WORD = { open: 'open', requested: 'prototype requested' };
|
|
187
|
+
function roundSheet({ ref, rounds, qid }) {
|
|
188
|
+
const rows = [...rounds].reverse().map((r) => `<button type="button" data-ps-round="${esc(r.url)}"><span>Round ${r.n}${r.confirm ? ' confirm' : ''}${r.q === qid ? ' · showing' : ''}</span><span>${ROUND_WORD[r.state] || 'answered'}</span></button>`);
|
|
189
|
+
return `<div class="ps-sheet" role="dialog" aria-label="Rounds"><h3>Rounds on ${esc(ref)}</h3>${rows.join('')}</div>`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
Object.assign(globalThis.BoardUI, { isPrototype, getDraft, setDraft, choiceLabel, choiceOf, noteOf, isSent, option, answerHistory, questionCard, roundChip, roundPill, roundSheet, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
|
|
178
193
|
})();
|
|
@@ -196,14 +196,21 @@
|
|
|
196
196
|
// update: null | 'ready' | 'reloading'.
|
|
197
197
|
// now: the time a starting board's seconds count to. A shared person's data (data.shared; hub/share.mjs sharedSidebar)
|
|
198
198
|
// draws their boards alone: no Hub row, update or notification rows, and no New board.
|
|
199
|
+
// Whether the Hub row reads busy: the host is busy and no usage pause reads first.
|
|
200
|
+
const hubBusy = (data) => data?.pause?.machine?.state === 'busy' && data?.pause?.state !== 'paused';
|
|
201
|
+
// The logo's dot for the hub's state, like the blue update dot: 'down' (red) for a broken hub, 'busy' (orange), else ''.
|
|
202
|
+
const hubDot = (data) => (data?.hub?.tone === 'down' ? 'down' : hubBusy(data) ? 'busy' : '');
|
|
203
|
+
|
|
199
204
|
function sidebar(data, { here = '', device = null, switching = false, update = null, now = Date.now() } = {}) {
|
|
200
205
|
const sites = data?.sites || [];
|
|
201
206
|
const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
|
|
202
207
|
const paused = data?.pause?.state === 'paused';
|
|
203
|
-
// A busy
|
|
204
|
-
|
|
208
|
+
// A busy host holds every board's new work but shows only on the Hub row and the logo's dot, never on a board, and a pause reads before it
|
|
209
|
+
// (docs/adr/2026-11-06-1305-a-busy-host-shows-only-on-the-hub-row-and-the-logo-dot.md).
|
|
210
|
+
const busy = hubBusy(data);
|
|
205
211
|
// A board under Pause board carries its own pause (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md).
|
|
206
|
-
|
|
212
|
+
// A red fault on a board row keeps its word, and a down or degraded hub keeps the Hub row: red and orange beat the grey pause (AGENTS.md).
|
|
213
|
+
const holdWord = (s) => (paused || s.pause?.state === 'paused') && !/class="word t-down"/.test(s.sub || '') ? word('paused', 'paused') : '';
|
|
207
214
|
const siteRow = (s) => {
|
|
208
215
|
const sub = s.board && holdWord(s) ? holdWord(s) + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
|
|
209
216
|
return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub ? `<span class="sb-status">${sub}</span>` : '', ctx: contextWords(s.ctx), on: s.route === here, key: keys?.get(siteHref(s)) });
|
|
@@ -221,7 +228,7 @@
|
|
|
221
228
|
: device === 'install' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Add Claude to your Home Screen', sub: 'Notifications come only to the Home Screen app', cls: 'sb-turnon' })
|
|
222
229
|
: '';
|
|
223
230
|
const usage = sidebarUsage(data?.usage);
|
|
224
|
-
const hub = paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: '
|
|
231
|
+
const hub = data?.hub?.tone === 'down' || data?.hub?.tone === 'wait' ? data.hub : paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: 'the host is busy' } : data?.hub;
|
|
225
232
|
const n = data?.notifications || {};
|
|
226
233
|
const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
|
|
227
234
|
const notify = device === 'blocked' ? word('needs', 'Notifications blocked on this device')
|
|
@@ -249,5 +256,5 @@
|
|
|
249
256
|
+ `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
|
|
250
257
|
}
|
|
251
258
|
|
|
252
|
-
Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, START_STEPS, startSteps, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, pauseUntilWords, usageTone, usageBar, usageWords });
|
|
259
|
+
Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, START_STEPS, startSteps, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, pauseUntilWords, usageTone, usageBar, usageWords, hubDot });
|
|
253
260
|
})();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The board this device last held, saved in IndexedDB with its change number, which the board page paints live on open
|
|
2
2
|
// and catches up from (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md). Needs ui.js.
|
|
3
3
|
(function () {
|
|
4
|
-
const ACTIVE_LANES = ['review', '
|
|
4
|
+
const ACTIVE_LANES = ['review', 'progress', 'todo'];
|
|
5
5
|
|
|
6
6
|
// threads maps item keys to thread messages (app.js state.threadCache). → {build, savedAt, board, threads} with the
|
|
7
7
|
// threads of the cards on the board; build is the saving page's (app.html data-build).
|
|
@@ -3,9 +3,9 @@
|
|
|
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
|
-
// A link to a card on this board (#
|
|
6
|
+
// A link to a card on this board (#selectedCard=<key>) or on another board (/<route>/#selectedCard=<key>, an ask's move), or ''.
|
|
7
7
|
function cardLink(link) {
|
|
8
|
-
const key = /^(?:\/[\w-]+\/)?#item=([^&]+)/.exec(link)?.[1];
|
|
8
|
+
const key = /^(?:\/[\w-]+\/)?#(?:selectedCard|item)=([^&]+)/.exec(link)?.[1];
|
|
9
9
|
if (!key) return '';
|
|
10
10
|
let ask = key.startsWith('ask:');
|
|
11
11
|
try { ask = decodeURIComponent(key).startsWith('ask:'); } catch {}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
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>`
|
|
42
42
|
// 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
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">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${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>`
|
|
45
45
|
// docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md: a card's move between lanes.
|
|
46
46
|
: m.kind === 'move' ? `<div class="msg request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
47
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>`
|
|
@@ -52,22 +52,20 @@
|
|
|
52
52
|
${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
|
|
53
53
|
<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
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)}
|
|
55
|
-
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">
|
|
55
|
+
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">View transcript</a></div>` : ''}
|
|
56
56
|
${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
|
|
57
57
|
${m.eventId ? msgFooter(m) : ''}
|
|
58
58
|
</div>`);
|
|
59
59
|
|
|
60
60
|
// One agent run (domain.mjs threadOf gives its started line, status lines and finished line the same `run`) is one
|
|
61
|
-
// closed details line at its
|
|
62
|
-
//
|
|
61
|
+
// closed details line at its first line's place: the action, then its status, finished when its newest line is a finished
|
|
62
|
+
// line and running otherwise, and nothing after;
|
|
63
|
+
// expanding it shows the run's lines and, when the run has a log, a View transcript link.
|
|
63
64
|
function foldedRun(m, links, edits) {
|
|
64
|
-
const
|
|
65
|
-
const running = !m.folded.some((f) => f.finished);
|
|
66
|
-
const newest = statuses.at(-1);
|
|
67
|
-
const tail = running ? (newest ? `running · ${escText(newest.text)}` : 'running') : 'finished';
|
|
65
|
+
const tail = m.folded.at(-1).finished ? 'finished' : 'running';
|
|
68
66
|
const log = m.folded.find((f) => f.log)?.log;
|
|
69
|
-
const
|
|
70
|
-
return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${
|
|
67
|
+
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>`;
|
|
71
69
|
}
|
|
72
70
|
function foldStatus(lines) {
|
|
73
71
|
const runs = new Map();
|