@lovek4d/kickflip 0.4.0 → 0.5.3
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 +69 -48
- package/README.md +1 -1
- package/SKILL.md +22 -20
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/bin/board.mjs +108 -39
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -3
- package/board/headless.mjs +5 -3
- package/board/merge3.mjs +1 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -2
- package/board/public/app-asks.js +9 -92
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +70 -37
- package/board/public/app-core.js +24 -25
- package/board/public/app-header.js +2 -1
- package/board/public/app-lanes.js +55 -23
- package/board/public/app-needs.js +129 -4
- package/board/public/app-reader.js +174 -0
- package/board/public/app-snapshot.js +6 -5
- package/board/public/app-threads.js +83 -58
- package/board/public/app.html +2 -0
- package/board/public/app.js +66 -24
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +4 -2
- package/board/public/hub.css +1 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +100 -0
- package/board/public/prototype-kit.js +28 -3
- package/board/public/prototype-switcher.js +42 -107
- package/board/public/shell.js +7 -1
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +44 -5
- package/board/public/style.css +194 -35
- package/board/public/ui-cards.js +80 -44
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +2 -2
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-phase-bars.js +4 -4
- package/board/public/ui-questions.js +63 -47
- package/board/public/ui-sidebar.js +27 -15
- package/board/public/ui-threads.js +27 -14
- package/board/public/ui.js +76 -14
- package/board/public/view-frame.js +1 -0
- package/board/public/view-hub-settings.js +11 -10
- package/board/public/view-hub.js +17 -3
- package/board/public/view-questions.js +174 -202
- package/board/public/view-settings.js +208 -70
- package/board/server.mjs +19 -18
- package/board/store.mjs +20 -13
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +24 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +20 -0
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -14
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +2 -1
- package/shared/audit.mjs +4 -0
- package/shared/fake-gh.mjs +17 -4
- package/shared/owner-check.mjs +3 -0
- package/shared/service.mjs +4 -4
- package/shared/tailscale.mjs +1 -0
- package/shared/usage-gate.mjs +1 -1
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
// The answer banner at the top of the board view: asks Claude answered without making work, whose answer
|
|
2
|
-
//
|
|
1
|
+
// The answer banner at the top of the board view: asks Claude answered without making work, whose answer came after the
|
|
2
|
+
// card's read mark (domain.mjs answers; docs/adr/2026-10-29-1639-every-ask-is-a-card.md,
|
|
3
|
+
// docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md). Needs app-core.js.
|
|
3
4
|
BoardView.part((B) => {
|
|
4
|
-
const { $,
|
|
5
|
+
const { $, state, seen, markSeen, markRead, plural, svg } = B;
|
|
5
6
|
const { esc } = BoardUI;
|
|
6
|
-
const DISMISSED = 'dismissedAnswers';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
const dismissed = Object.assign(Object.create(null), store.get(DISMISSED, {}));
|
|
10
|
-
return (d.answers || []).filter((a) => seen[a.key] && a.at > seen[a.key] && !(dismissed[a.id] >= a.at));
|
|
11
|
-
}
|
|
8
|
+
const unseenAnswers = (d) => (d.answers || []).filter((a) => seen[a.key] && a.at > seen[a.key]);
|
|
12
9
|
|
|
13
10
|
function renderBanner(d) {
|
|
14
|
-
// A New card seen before its answer counts the answer as new; one first seen in Done starts from now (app-core.js markSeen).
|
|
15
|
-
for (const it of d.items) if (it.isNew) markSeen(it, true);
|
|
16
11
|
const shown = unseenAnswers(d);
|
|
17
12
|
const el = $('#answers');
|
|
18
13
|
el.hidden = !shown.length;
|
|
@@ -28,10 +23,9 @@ BoardView.part((B) => {
|
|
|
28
23
|
B.on($('#answers'), 'click', (ev) => {
|
|
29
24
|
const d = state.board;
|
|
30
25
|
if (!d) return;
|
|
26
|
+
// ✕ marks each answer shown read, on every device.
|
|
31
27
|
if (ev.target.closest('.answer-dismiss')) {
|
|
32
|
-
|
|
33
|
-
for (const a of unseenAnswers(d)) dismissed[a.id] = a.at;
|
|
34
|
-
store.set(DISMISSED, dismissed);
|
|
28
|
+
markRead(unseenAnswers(d).map((a) => [a.key, a.at]));
|
|
35
29
|
renderBanner(d);
|
|
36
30
|
return;
|
|
37
31
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// app-core.js and app-threads.js.
|
|
4
4
|
BoardView.part((B) => {
|
|
5
5
|
const { $, store, state, markSeen, seen, toast, links } = B;
|
|
6
|
-
const { esc, ago, REVIEW, reviewAge, unreadCount, workLines, BUILD_PHASES, phaseStrip, phaseMotion, heldUntilWords, holdPicker, linkText, mergeButton,
|
|
6
|
+
const { esc, ago, REVIEW, reviewAge, unreadCount, workLines, BUILD_PHASES, phaseStrip, phaseMotion, heldUntilWords, holdPicker, linkText, mergeButton, installButton, tryButton, tryWords, waitOn, prName, BUTTON, LINK_ARROW } = BoardUI;
|
|
7
7
|
|
|
8
8
|
const mainSheet = $('#mainLogSheet');
|
|
9
9
|
const mainRetry = $('#mainLogRetry');
|
|
@@ -116,20 +116,29 @@ BoardView.part((B) => {
|
|
|
116
116
|
}
|
|
117
117
|
ev.preventDefault();
|
|
118
118
|
const pull = state.board?.main?.pull;
|
|
119
|
-
if (!pull || pull.running ||
|
|
119
|
+
if (!pull || pull.running || BoardUI.pending.has('main:pull')) return;
|
|
120
120
|
if (pull.error && !matchMedia('(hover: hover)').matches) {
|
|
121
121
|
state.mainPull.open = !state.mainPull.open;
|
|
122
122
|
paintMain();
|
|
123
123
|
return;
|
|
124
124
|
}
|
|
125
|
-
state.mainPull = {
|
|
125
|
+
state.mainPull = { open: false };
|
|
126
|
+
// Spins until a board shows the pull running or changed (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
127
|
+
// A pull that brought no commits changes nothing a board shows, so its answer ends the spinner; one that did waits
|
|
128
|
+
// for Main to rebuild.
|
|
129
|
+
const mainFace = (main) => JSON.stringify([main?.pull, main?.sha, main?.state, main?.at]);
|
|
130
|
+
const before = mainFace(state.board.main);
|
|
131
|
+
BoardUI.pending.start('main:pull', (board) => mainFace(board.main) !== before, B.stillWaiting);
|
|
126
132
|
paintMain();
|
|
127
133
|
try {
|
|
128
134
|
const response = await fetch(B.site.url('api/try/main/pull'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
129
135
|
const out = await response.json();
|
|
130
136
|
if (!response.ok) throw new Error(out.error || 'The hub refused the pull.');
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
if (out.commits) BoardUI.pending.answered('main:pull'); else BoardUI.pending.drop('main:pull');
|
|
138
|
+
} catch (error) {
|
|
139
|
+
BoardUI.pending.drop('main:pull');
|
|
140
|
+
if (B.alive()) toast(error.message || 'Could not pull. Is the host awake?');
|
|
141
|
+
}
|
|
133
142
|
if (B.alive()) { paintMain(); B.reload(); }
|
|
134
143
|
});
|
|
135
144
|
|
|
@@ -216,18 +225,16 @@ BoardView.part((B) => {
|
|
|
216
225
|
}
|
|
217
226
|
}
|
|
218
227
|
if (it.branch) rows.push(['Branch', `<code>${esc(it.branch)}</code>`]);
|
|
219
|
-
|
|
220
|
-
if (it.status) rows.push(['Status', esc(it.status === 'building' ? 'implementing' : it.status)]);
|
|
228
|
+
rows.push(['Status', esc(BoardUI.statusWord(it))]);
|
|
221
229
|
// docs/adr/2026-11-03-0526-a-follow-up-is-a-work-card-with-a-parent.md: each link row is a row of labels that open their cards.
|
|
222
230
|
for (const [label, list] of [['Parent', it.parent ? [it.parent] : []], ['Children', it.children], ['Related to', it.related], ['Blocked by', it.blockedBy], ['Waits on it', it.waitsOnIt]]) {
|
|
223
231
|
if (list?.length) rows.push([label, list.map(cardLink).join(' ')]);
|
|
224
232
|
}
|
|
225
233
|
if (it.manualSteps.length) rows.push(['Manual steps', linkText(it.manualSteps.join('; '), ctx)]);
|
|
226
234
|
if (it.next) rows.push(['Next', linkText(it.next, ctx)]);
|
|
227
|
-
// docs/adr/2026-10-30-1626-notes-is-a-link-in-the-details-list.md
|
|
228
235
|
const external = [
|
|
229
|
-
it
|
|
230
|
-
|
|
236
|
+
// A vscode: link opens a folder on the machine whose browser follows it, so only the host's browser gets a live one; elsewhere it is muted, with a tooltip and a tap toast.
|
|
237
|
+
it.worktree && !state.board.onHost ? `<a aria-disabled="true" title="not on kickflip host" data-tip="not on kickflip host">Open in VS Code</a>` : '',
|
|
231
238
|
it.worktree && state.board.onHost ? `<a href="vscode://file${esc(encodeURI(it.worktree))}" title="Open the worktree in VS Code">Open in VS Code ${LINK_ARROW}</a>` : '',
|
|
232
239
|
installButton(it),
|
|
233
240
|
pr?.state === 'OPEN' ? `<a${classes(it, BUTTON.github)} href="${esc(pr.url)}" target="_blank" rel="noopener">${pr.isDraft ? 'Review draft on GitHub' : 'Review on GitHub'} ${LINK_ARROW}</a>` : '',
|
|
@@ -263,13 +270,13 @@ BoardView.part((B) => {
|
|
|
263
270
|
const CONFIRM = {
|
|
264
271
|
resume: (it, ref) => `Unhold ${ref}?${it.heldUntil ? ` It's held until ${heldUntilWords(it.heldUntil)}.` : ''} It can start again once nothing blocks it.`,
|
|
265
272
|
nudge: () => 'Nudge Claude to check this implementer?',
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
273
|
+
// docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md
|
|
274
|
+
merge: (it, ref) => (it.mergeReady ? `Merge ${ref}? The board rebases ${it.mergeReady.branch} onto ${it.mergeReady.base} as it is now, reruns the tests if moved, and lands it.`
|
|
275
|
+
: openPRs(it).length > 1
|
|
269
276
|
? `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join(', ')} in turn if they are still approved, green and clean.`
|
|
270
277
|
: `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join('')} if it is still approved, green and clean.`),
|
|
271
278
|
'card-build': (it, ref) => (it.blockedBy?.length
|
|
272
|
-
? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done? It
|
|
279
|
+
? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done? It is bumped to the top of Todo until then.`
|
|
273
280
|
: `${it.held ? `Unhold and implement ${ref}?` : `Implement ${ref}?`} Claude starts its implementer now with the notes as they stand.`),
|
|
274
281
|
finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
|
|
275
282
|
: it.ownerStep ? `Done with "${it.ownerStep}"? ${ref} moves to Done and its ticket closes.`
|
|
@@ -282,33 +289,37 @@ BoardView.part((B) => {
|
|
|
282
289
|
};
|
|
283
290
|
const actionRow = (kind, html) => (html.trim() ? `<div class="action-row ${kind}">${html}</div>` : '');
|
|
284
291
|
|
|
292
|
+
// The reader sheet opens it (app-reader.js); docs/adr/2026-11-05-0830-notes-is-a-button-on-the-hold-row.md.
|
|
293
|
+
const notesButton = (it) => (it.hasNotes ? `<a class="button" href="notes.html?item=${encodeURIComponent(it.key)}">Notes</a>` : '');
|
|
294
|
+
|
|
285
295
|
function actions(it) {
|
|
286
296
|
const key = esc(it.key);
|
|
287
297
|
const ask = (act, label, cls = '') => `<button${cls ? ` class="${cls}"` : ''} data-confirm="${act}" data-key="${key}">${label}</button>`;
|
|
288
298
|
const hold = it.kind !== 'work' || !it.holdable ? ''
|
|
289
|
-
: it.held ?
|
|
290
|
-
|
|
299
|
+
: it.held ? ask('resume', 'Unhold', 'unhold') : ask('hold', 'Hold');
|
|
300
|
+
// A Todo card's Implement is in its thread box (app-threads.js; docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md).
|
|
301
|
+
return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${mergeButton(it)}${hold}${actionButton(it)}${tryButton(it)}${notesButton(it)}`)}</div>`;
|
|
291
302
|
}
|
|
292
303
|
|
|
293
304
|
// board/domain.mjs actionButtonOf picks the button and resolvedBy its colour (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
|
|
294
305
|
const ACTION_BUTTONS = {
|
|
295
306
|
[BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Open questions</a>`,
|
|
296
307
|
[BUTTON.markDone]: (it) => `<button${classes(it, BUTTON.markDone)} data-confirm="finish" data-key="${esc(it.key)}">Mark as done</button>`,
|
|
297
|
-
// Grill and Prototype each ask for a planner; both wait while either is under way.
|
|
298
|
-
[BUTTON.grill]: (it) => ['grill', 'prototype'].map((act) => `<button data-confirm="${act}" data-key="${esc(it.key)}"${it.grilling || it.prototyping ? ' disabled' : ''}>${act === 'grill' ? 'Grill' : 'Prototype'}</button>`).join(''),
|
|
299
308
|
};
|
|
300
309
|
const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
|
|
301
310
|
|
|
302
311
|
// The latest bump among New cards takes no second bump (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md).
|
|
303
312
|
const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
|
|
304
313
|
// band {step, actions} draws the card in a wave band (app-lanes.js).
|
|
305
|
-
// Its open questions asked since this browser last opened it
|
|
314
|
+
// Its open questions asked since this browser last opened it are one more unread message: the thread's "N new
|
|
315
|
+
// questions" line (app-threads.js; docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
|
|
306
316
|
function card(it, band = {}) {
|
|
307
317
|
const unseen = (times) => (state.openItems.has(it.key) ? 0 : unreadCount(times, seen[it.key]));
|
|
318
|
+
const unread = unseen(it.othersAt) + Math.min(1, unseen(it.questions?.askedAt));
|
|
308
319
|
return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
|
|
309
320
|
<summary class="work">
|
|
310
321
|
<span class="status ${it.lane}"></span>
|
|
311
|
-
<div class="title-wrap">${workLines(it, { unread
|
|
322
|
+
<div class="title-wrap">${workLines(it, { unread, top: latestBump(it), ...band })}</div>
|
|
312
323
|
</summary>
|
|
313
324
|
${state.openItems.has(it.key) ? detail(it) : ''}
|
|
314
325
|
</details>`;
|
|
@@ -383,6 +394,7 @@ BoardView.part((B) => {
|
|
|
383
394
|
state.openItems.add(key);
|
|
384
395
|
const it = state.board.items.find((i) => i.key === key) || state.archived.cards.find((i) => i.key === key);
|
|
385
396
|
markSeen(it);
|
|
397
|
+
B.renderBanner(state.board);
|
|
386
398
|
const unread = el.querySelector('summary .card-unread');
|
|
387
399
|
const line = unread?.parentNode;
|
|
388
400
|
unread?.remove();
|
|
@@ -396,12 +408,6 @@ BoardView.part((B) => {
|
|
|
396
408
|
store.set('openItems', [...state.openItems]);
|
|
397
409
|
}, true);
|
|
398
410
|
|
|
399
|
-
function redrawActions(key) {
|
|
400
|
-
const it = state.board.items.find((i) => i.key === key);
|
|
401
|
-
const row = $(`.actions[data-actions="${CSS.escape(key)}"]`);
|
|
402
|
-
if (it && row) row.outerHTML = actions(it);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
411
|
B.on(B.root, 'click', async (ev) => {
|
|
406
412
|
const log = ev.target.closest('[data-try-log]');
|
|
407
413
|
if (log && state.openItems.has(log.dataset.tryLog)) {
|
|
@@ -410,6 +416,8 @@ BoardView.part((B) => {
|
|
|
410
416
|
card?.querySelector('.thread')?.scrollIntoView({ block: 'start' });
|
|
411
417
|
return;
|
|
412
418
|
}
|
|
419
|
+
const off = ev.target.closest('a[data-tip]');
|
|
420
|
+
if (off) { ev.preventDefault(); toast(off.dataset.tip); return; }
|
|
413
421
|
const button = ev.target.closest('button[data-try]');
|
|
414
422
|
if (!button) return;
|
|
415
423
|
ev.preventDefault();
|
|
@@ -417,13 +425,22 @@ BoardView.part((B) => {
|
|
|
417
425
|
const key = button.dataset.try;
|
|
418
426
|
const it = state.board.items.find((i) => i.key === key);
|
|
419
427
|
if (!it || !(await B.confirm(`${tryWords(it.try?.kind).go} ${it.ref}? The hub runs its build script.`)) || !B.alive()) return;
|
|
420
|
-
// Serve / Build
|
|
421
|
-
|
|
428
|
+
// Serve / Build spins until a board shows the card's build changed: running, or already done
|
|
429
|
+
// (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
430
|
+
const before = JSON.stringify(it.try);
|
|
431
|
+
const id = `${key}:try`;
|
|
432
|
+
BoardUI.pending.start(id, (board) => JSON.stringify(board.items.find((i) => i.key === key)?.try) !== before, B.stillWaiting);
|
|
433
|
+
B.render();
|
|
434
|
+
try {
|
|
422
435
|
const r = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key }) });
|
|
423
436
|
const out = await r.json();
|
|
424
437
|
if (!r.ok) throw new Error(out.error || 'The hub refused the build.');
|
|
425
|
-
|
|
426
|
-
|
|
438
|
+
BoardUI.pending.answered(id);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
BoardUI.pending.drop(id);
|
|
441
|
+
B.render();
|
|
442
|
+
toast(error.message || 'Could not start. Is the host awake?');
|
|
443
|
+
}
|
|
427
444
|
B.reload();
|
|
428
445
|
});
|
|
429
446
|
|
|
@@ -445,12 +462,13 @@ BoardView.part((B) => {
|
|
|
445
462
|
if (!b) return;
|
|
446
463
|
ev.preventDefault();
|
|
447
464
|
const { key, confirm: act } = b.dataset;
|
|
465
|
+
if (b.classList.contains('nudging')) return;
|
|
448
466
|
const it = state.board.items.find((i) => i.key === key);
|
|
449
467
|
if (it && act === 'hold') { openHold(it); return; }
|
|
450
468
|
if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
|
|
451
469
|
// Unhold, Merge (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md), Implement and Done wait while the
|
|
452
470
|
// server records them (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
|
|
453
|
-
const sent = {
|
|
471
|
+
const sent = { resume: ['card-hold', { key, hold: false }], merge: ['card-merge', { key }], 'card-build': ['card-build', { key }],
|
|
454
472
|
finish: it.kind === 'ask' ? ['ask-done', { id: it.id }] : ['card-done', { key }] }[act];
|
|
455
473
|
if (sent) {
|
|
456
474
|
// Done closes its card once the server answers, before the answer's board lands, or app-core.js steady would
|
|
@@ -461,16 +479,31 @@ BoardView.part((B) => {
|
|
|
461
479
|
state.openItems.delete(key);
|
|
462
480
|
store.set('openItems', [...state.openItems]);
|
|
463
481
|
} : null;
|
|
464
|
-
|
|
465
|
-
|
|
482
|
+
// docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md: Merge spins until a board shows the card left
|
|
483
|
+
// In Review, or no longer merging there, which is its refusal.
|
|
484
|
+
const pend = act === 'merge' ? `${key}:merge` : null;
|
|
485
|
+
if (pend) BoardUI.pending.start(pend, (board) => { const c = board.items.find((i) => i.key === key); return !c || c.lane !== 'review' || !c.merging; });
|
|
486
|
+
try {
|
|
487
|
+
const out = await waitOn(b, B.sentHere(key, () => B.send(...sent, close)));
|
|
488
|
+
if (pend) { BoardUI.pending.answered(pend, out.board.generatedAt); B.render(); }
|
|
489
|
+
} catch (e) {
|
|
490
|
+
if (pend) { BoardUI.pending.drop(pend); B.render(); }
|
|
491
|
+
B.failed(e);
|
|
492
|
+
}
|
|
466
493
|
if (act === 'finish') B.syncDone(key);
|
|
467
494
|
return;
|
|
468
495
|
}
|
|
469
|
-
//
|
|
470
|
-
|
|
496
|
+
// A stalled line's nudge calms it to orange with a ripple, not a spinner, until a board shows the card's status line
|
|
497
|
+
// changed, the builder no longer stalled or the card gone (style.css .nudging).
|
|
498
|
+
const id = `${key}:nudge`;
|
|
499
|
+
const { progress, at } = it.builder;
|
|
500
|
+
BoardUI.pending.start(id, (board) => { const c = board.items.find((i) => i.key === key); return !c?.builder?.stalled || c.builder.progress !== progress || c.builder.at !== at; }, B.stillWaiting);
|
|
501
|
+
B.render();
|
|
471
502
|
try {
|
|
472
|
-
await
|
|
473
|
-
|
|
503
|
+
const out = await B.sentHere(key, () => B.send('builder-nudge', { key }));
|
|
504
|
+
BoardUI.pending.answered(id, out.board.generatedAt); toast('Nudge sent');
|
|
505
|
+
} catch { BoardUI.pending.drop(id); toast('Could not send. Is the host awake?'); }
|
|
506
|
+
B.render();
|
|
474
507
|
});
|
|
475
508
|
|
|
476
509
|
Object.assign(B, { captureStrips, moveStrips, detail, card });
|
package/board/public/app-core.js
CHANGED
|
@@ -86,32 +86,31 @@ BoardView.part((B) => {
|
|
|
86
86
|
|
|
87
87
|
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
88
88
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
// Read marks live on the board, one set for every device: each card's readAt, the newest message the owner has seen
|
|
90
|
+
// on it (docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md). seen is card key → the later of the board's mark
|
|
91
|
+
// and this page's own, so a card read here reads at once, before the board comes back with it. seenBefore is an open
|
|
92
|
+
// card's mark from before it opened, where its thread's unread lines start (app-threads.js); a card closing drops it,
|
|
93
|
+
// and a board left and opened again in this page keeps it.
|
|
94
|
+
const seen = Object.create(null);
|
|
95
|
+
const seenBefore = new Map(B.place?.seenBefore);
|
|
96
|
+
B.keep(() => ({ seenBefore: [...seenBefore] }));
|
|
97
|
+
function takeMarks(items) {
|
|
98
|
+
for (const it of items) if (String(it.readAt || '') > (seen[it.key] || '')) seen[it.key] = it.readAt;
|
|
96
99
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (until <= Date.now()) return false;
|
|
104
|
-
if (until !== Infinity) ta.value = '';
|
|
105
|
-
return true;
|
|
100
|
+
// [[card key, time]] → each card read up to its time, here at once and on the board for every device.
|
|
101
|
+
function markRead(pairs) {
|
|
102
|
+
const marks = Object.fromEntries(pairs.filter(([key, at]) => at && String(at) > (seen[key] || '')));
|
|
103
|
+
if (!Object.keys(marks).length) return;
|
|
104
|
+
Object.assign(seen, marks);
|
|
105
|
+
fetch(B.site.url('api/read'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ marks }) }).catch(() => {});
|
|
106
106
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
quiet.set(draftKey, Date.now() + 1000);
|
|
107
|
+
// The newest message card it shows: its counted messages (domain.mjs othersAt), its questions' asks and its answer.
|
|
108
|
+
const newestOf = (it) => [...(it.othersAt || []), ...(it.questions?.askedAt || []), it.ask?.answerAt].filter(Boolean).map(String).sort().pop();
|
|
109
|
+
// Card it, open, reads up to the newest message it shows.
|
|
110
|
+
function markSeen(it) {
|
|
111
|
+
if (!it) return;
|
|
112
|
+
if (!seenBefore.has(it.key)) seenBefore.set(it.key, seen[it.key] || null);
|
|
113
|
+
markRead([[it.key, newestOf(it)]]);
|
|
115
114
|
}
|
|
116
115
|
|
|
117
116
|
// The ui-links.js linkContext for text shown on item `key`'s card or thread.
|
|
@@ -124,5 +123,5 @@ BoardView.part((B) => {
|
|
|
124
123
|
const confirm = (message, labels) => dialogs.confirm(message, { ...labels, signal: controller.signal });
|
|
125
124
|
const notice = dialogs.notice;
|
|
126
125
|
|
|
127
|
-
Object.assign(B, { steady, restorePlace, plural, seen,
|
|
126
|
+
Object.assign(B, { steady, restorePlace, plural, seen, seenBefore, takeMarks, markRead, markSeen, links, svg, confirm, notice });
|
|
128
127
|
});
|
|
@@ -77,7 +77,8 @@ BoardView.part((B) => {
|
|
|
77
77
|
} catch (e) { toast(`Couldn't wake this board's session: ${e.message}`); }
|
|
78
78
|
finally { state.waking = false; if (B.alive()) renderStatus(); }
|
|
79
79
|
}
|
|
80
|
-
|
|
80
|
+
// A shared person's tap never wakes the owner's session (shell.js .shared-view).
|
|
81
|
+
const wakeOnTap = () => { if (state.board?.checks?.watcher === 'asleep' && !state.waking && state.link === 'online' && !document.documentElement.classList.contains('shared-view')) wake(); };
|
|
81
82
|
B.on(B.root, 'click', wakeOnTap, true);
|
|
82
83
|
B.on(B.root, 'submit', wakeOnTap, true);
|
|
83
84
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The board view's lanes and the Auto Review lane leading them: each lane's header and cards,
|
|
1
|
+
// The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Done's and Asks' Show more,
|
|
2
2
|
// Done's finished-wave folds, and Todo's wave bands with their Implement, triage and card order.
|
|
3
3
|
// Needs app-core.js and app-cards.js.
|
|
4
4
|
BoardView.part((B) => {
|
|
@@ -25,7 +25,7 @@ BoardView.part((B) => {
|
|
|
25
25
|
const n = left + (older || 0);
|
|
26
26
|
const words = `${n} older card${n === 1 ? '' : 's'}`;
|
|
27
27
|
const fetches = older !== 0 && shown + STEP > cards.length;
|
|
28
|
-
return cards.slice(0, shown)
|
|
28
|
+
return waveGroups(laneId, cards.slice(0, shown))
|
|
29
29
|
+ (n > 0 ? `<button type="button" class="thread-more" data-lane-more="${laneId}"${fetches ? ' data-fetch' : ''}>Show more <span class="n">· ${words}</span></button>` : '');
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -97,7 +97,7 @@ BoardView.part((B) => {
|
|
|
97
97
|
if (lane.hideEmpty && !items.length) return '';
|
|
98
98
|
const expanded = laneState[lane.id] ?? lane.open;
|
|
99
99
|
const open = items.filter((i) => !i.fold);
|
|
100
|
-
const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) :
|
|
100
|
+
const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : waveGroups(lane.id, open)) + waveFolds(d, items);
|
|
101
101
|
return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
|
|
102
102
|
<summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
|
|
103
103
|
<div class="list">${rows || '<div class="empty">Nothing here.</div>'}
|
|
@@ -106,8 +106,36 @@ BoardView.part((B) => {
|
|
|
106
106
|
}).join('');
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// docs/adr/2026-11-05-0215-every-lane-groups-its-cards-by-wave.md
|
|
110
|
+
// Every lane but Todo groups its cards under a header per open wave (state.board.waveBands) that has cards there, in
|
|
111
|
+
// band order, then the cards in no wave under No wave. A lane with no card in a wave lists its cards plainly. A header
|
|
112
|
+
// counts its wave's cards in Todo, In Progress (Stale included), In Review and Done as x/y/k/z, from every lane, with
|
|
113
|
+
// the lane's own number bold in its colour and a tooltip that spells the counts out.
|
|
114
|
+
// A tap on the counts opens the tooltip (countsShown, the header's lane/wave); a second tap or a tap elsewhere closes it.
|
|
115
|
+
let countsShown = null;
|
|
116
|
+
const COUNT_LANES = [['todo', 'in Todo'], ['progress', 'in progress'], ['review', 'in review'], ['done', 'done']];
|
|
117
|
+
function waveCounts(band, laneId) {
|
|
118
|
+
const lanes = state.board.items.reduce((m, i) => m.set(i.key, i.lane === 'stale' ? 'progress' : i.lane), new Map());
|
|
119
|
+
const n = COUNT_LANES.map(([id]) => band.keys.filter((k) => lanes.get(k) === id).length);
|
|
120
|
+
const here = laneId === 'stale' ? 'progress' : laneId;
|
|
121
|
+
const id = `${laneId}/${band.slug}`;
|
|
122
|
+
const spelled = COUNT_LANES.map(([, word], at) => `${n[at]} ${word}`);
|
|
123
|
+
const tip = `<span class="wb-tip" role="tooltip">${spelled.map((t) => `<div>${t}</div>`).join('')}</span>`;
|
|
124
|
+
return `<button type="button" class="wb-counts${countsShown === id ? ' open' : ''}" data-counts="${esc(id)}" aria-label="${spelled.join(', ')}">${COUNT_LANES.map(([lane], at) => `<span${lane === here ? ` class="wv-cur wv-${lane}"` : ''}>${n[at]}</span>`).join('<span aria-hidden="true">/</span>')}${tip}</button>`;
|
|
125
|
+
}
|
|
126
|
+
function waveGroups(laneId, cards) {
|
|
127
|
+
const bands = state.board.waveBands || [];
|
|
128
|
+
const inBand = (b) => cards.filter((i) => b.keys.includes(i.key));
|
|
129
|
+
const grouped = bands.filter((b) => inBand(b).length);
|
|
130
|
+
if (!grouped.length) return cards.map((it) => B.card(it)).join('');
|
|
131
|
+
const loose = cards.filter((i) => !bands.some((b) => b.keys.includes(i.key)));
|
|
132
|
+
const group = (cls, data, name, n, counts, list) => `<section class="${cls}"${data}><div class="wb-head wb-loosehead">${counts ? BoardUI.WAVE_ICON : ''}<span class="wb-label"><span class="lane-name">${name}</span> <span class="count">${n}</span></span>${counts}</div>${list.map((it) => B.card(it)).join('')}</section>`;
|
|
133
|
+
return grouped.map((b) => group('wb-lane', ` data-wave="${esc(b.slug)}"`, esc(b.title), inBand(b).length, waveCounts(b, laneId), inBand(b))).join('')
|
|
134
|
+
+ (loose.length ? group('wb-loose', '', 'No wave', loose.length, '', loose) : '');
|
|
135
|
+
}
|
|
136
|
+
|
|
109
137
|
// docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md
|
|
110
|
-
// Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under
|
|
138
|
+
// Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under No wave. A band's open state lives
|
|
111
139
|
// in storage once the owner taps its head; until then only the first band whose next card is ready (a wave file's:
|
|
112
140
|
// any Todo card) starts open. A band's name shows its goal as a tooltip, or a popover on a long-press. menu is the
|
|
113
141
|
// card whose ⋯ menu is open, goalShown the band whose goal popover is open.
|
|
@@ -125,33 +153,25 @@ BoardView.part((B) => {
|
|
|
125
153
|
const LANE_WORDS = { progress: 'in progress', review: 'in review', stale: 'stale' };
|
|
126
154
|
const DOTS = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>';
|
|
127
155
|
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
const head = queued.length ? `<div class="wb-loose lane-queue"><div class="wb-head wb-loosehead"><span class="lane-name">Queued</span><span class="count">${queued.length}</span></div>${queued.map((it) => B.card(it)).join('')}</div>` : '';
|
|
134
|
-
return head + bandRows(d, shown, all.filter((i) => !i.queued));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function bandRows(d, shown, todo) {
|
|
138
|
-
const bands = d.waveBands || [];
|
|
139
|
-
const byKey = new Map(shown.filter((i) => !i.queued).map((i) => [i.key, i]));
|
|
140
|
-
const banded = new Set(bands.flatMap((b) => b.keys));
|
|
156
|
+
// Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
|
|
157
|
+
function todoRows(d, shown, todo) {
|
|
158
|
+
const byKey = new Map(shown.map((i) => [i.key, i]));
|
|
159
|
+
const bands = (d.waveBands || []).filter((b) => b.keys.some((k) => byKey.get(k)?.lane === 'todo'));
|
|
160
|
+
const banded = new Set((d.waveBands || []).flatMap((b) => b.keys));
|
|
141
161
|
const loose = todo.filter((i) => !banded.has(i.key));
|
|
142
162
|
const first = bands.find((b) => (b.next ? b.next.ready : b.keys.some((k) => byKey.get(k)?.ready)))?.slug;
|
|
143
163
|
if (!bands.length) return loose.map((it) => B.card(it)).join('');
|
|
144
164
|
return bands.map((b) => bandHtml(b, b.keys.map((k) => byKey.get(k)).filter(Boolean), bandOpen(b, first))).join('')
|
|
145
|
-
+ (loose.length ? `<div class="wb-loose"><div class="wb-head wb-loosehead"><span class="lane-name">
|
|
165
|
+
+ (loose.length ? `<div class="wb-loose"><div class="wb-head wb-loosehead"><span class="lane-name">No wave</span><span class="count">${loose.length}</span></div>${loose.map((it) => B.card(it)).join('')}</div>` : '');
|
|
146
166
|
}
|
|
147
167
|
|
|
148
168
|
function bandHtml(band, cards, open) {
|
|
149
169
|
const todo = cards.filter((i) => i.lane === 'todo');
|
|
150
170
|
const done = cards.filter((i) => i.lane === 'done');
|
|
151
|
-
const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${
|
|
171
|
+
const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${LANE_WORDS[i.lane]}`);
|
|
152
172
|
const progress = [`${band.merged} of ${band.total} merged`, ...now].join(' · ');
|
|
153
173
|
const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${esc(band.title)}</span>`;
|
|
154
|
-
const notes = band.kind === 'lineup' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes
|
|
174
|
+
const notes = band.kind === 'lineup' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
|
|
155
175
|
const next = band.next;
|
|
156
176
|
const go = !next ? '' : next.ready
|
|
157
177
|
? `<button type="button" class="wv-link" data-confirm="card-build" data-key="${esc(next.key)}">Implement</button>`
|
|
@@ -159,7 +179,7 @@ BoardView.part((B) => {
|
|
|
159
179
|
const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
|
|
160
180
|
const head = `<div class="wb-head" data-band-toggle="${esc(band.slug)}" role="button" tabindex="0" aria-expanded="${open}">${BoardUI.WAVE_ICON}`
|
|
161
181
|
+ `<span class="wb-label">${name} <span class="count">${todo.length}</span> <span class="count">· ${progress}</span></span>`
|
|
162
|
-
+ `<span class="wb-end"><span class="wb-links">${notes}${triageLink(band)}</span>${go}</span>${pop}</div>`;
|
|
182
|
+
+ waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}${triageLink(band)}</span>${go}</span>${pop}</div>`;
|
|
163
183
|
if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
|
|
164
184
|
const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#item=${encodeURIComponent(i.key)}">${esc(i.ref)}</a>`).join(', ')}</div>` : '';
|
|
165
185
|
const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
|
|
@@ -220,8 +240,14 @@ BoardView.part((B) => {
|
|
|
220
240
|
const pb = ev.target.closest('button[data-plan]');
|
|
221
241
|
if (pb) {
|
|
222
242
|
if (!(await B.confirm('Claude triages these Todo cards into one or more waves and asks you about them in Needs you. Nothing is implemented until you answer. Start?'))) return;
|
|
223
|
-
|
|
224
|
-
|
|
243
|
+
// Spins until a board shows a grill running on one of the cards (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
244
|
+
const keys = pb.dataset.plan.split(' ');
|
|
245
|
+
BoardUI.pending.start('todo:plan', (board) => board.items.some((i) => keys.includes(i.key) && i.grilling), B.stillWaiting);
|
|
246
|
+
try {
|
|
247
|
+
const out = await waitOn(pb, B.send('plan-waves', { keys }));
|
|
248
|
+
BoardUI.pending.answered('todo:plan', out.board.generatedAt);
|
|
249
|
+
B.render();
|
|
250
|
+
} catch { BoardUI.pending.drop('todo:plan'); B.render(); B.notice('Could not send. Is the host awake?'); }
|
|
225
251
|
return;
|
|
226
252
|
}
|
|
227
253
|
const tb = ev.target.closest('button[data-triage]');
|
|
@@ -264,6 +290,12 @@ BoardView.part((B) => {
|
|
|
264
290
|
for (const type of ['pointerup', 'pointercancel']) B.on(B.root, type, endPress);
|
|
265
291
|
B.on(B.root, 'contextmenu', (ev) => { if (ev.target.closest?.('[data-goal]')) ev.preventDefault(); });
|
|
266
292
|
B.on(B.root, 'click', async (ev) => {
|
|
293
|
+
const counts = ev.target.closest?.('[data-counts]');
|
|
294
|
+
if (counts || countsShown) {
|
|
295
|
+
countsShown = counts && counts.dataset.counts !== countsShown ? counts.dataset.counts : null;
|
|
296
|
+
B.render();
|
|
297
|
+
if (counts) return;
|
|
298
|
+
}
|
|
267
299
|
if (pressed) { pressed = false; ev.preventDefault(); return; }
|
|
268
300
|
if (goalShown) { goalShown = null; B.render(); if (ev.target.closest('.wb-head')) return; }
|
|
269
301
|
const dots = ev.target.closest('button[data-band-menu]');
|