@lovek4d/kickflip 0.1.0 → 0.4.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.
Files changed (120) hide show
  1. package/CONTEXT.md +239 -104
  2. package/README.md +54 -4
  3. package/SKILL.md +63 -42
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +103 -40
  7. package/bin/statusline.mjs +4 -3
  8. package/bin/wait-events.mjs +2 -2
  9. package/board/arch-review.mjs +5 -0
  10. package/board/auto-review.mjs +2 -0
  11. package/board/bin/agent.mjs +1 -1
  12. package/board/bin/board.mjs +371 -82
  13. package/board/bin/builder-stop-hook.mjs +4 -3
  14. package/board/domain.mjs +8 -3
  15. package/board/finish.mjs +3 -0
  16. package/board/headless.mjs +3 -0
  17. package/board/on-host.mjs +1 -0
  18. package/board/permissions.mjs +2 -0
  19. package/board/public/app-arch.js +75 -0
  20. package/board/public/app-asks.js +66 -28
  21. package/board/public/app-banner.js +2 -2
  22. package/board/public/app-cards.js +354 -77
  23. package/board/public/app-core.js +40 -8
  24. package/board/public/app-header.js +33 -8
  25. package/board/public/app-lanes.js +238 -34
  26. package/board/public/app-needs.js +2 -0
  27. package/board/public/app-snapshot.js +45 -11
  28. package/board/public/app-threads.js +20 -21
  29. package/board/public/app.html +6 -2
  30. package/board/public/app.js +114 -33
  31. package/board/public/attach.js +39 -3
  32. package/board/public/dialogs.js +161 -118
  33. package/board/public/hub-kit.js +27 -7
  34. package/board/public/hub.css +26 -33
  35. package/board/public/linear-key.js +143 -0
  36. package/board/public/log.html +48 -0
  37. package/board/public/prompts.js +9 -8
  38. package/board/public/prototype-kit.js +6 -6
  39. package/board/public/prototype-switcher.js +13 -5
  40. package/board/public/prototype.html +1 -0
  41. package/board/public/router.js +14 -10
  42. package/board/public/rules-editor.js +4 -5
  43. package/board/public/shell.js +21 -7
  44. package/board/public/sidebar.css +51 -11
  45. package/board/public/sidebar.js +124 -19
  46. package/board/public/style.css +244 -56
  47. package/board/public/toasts.js +5 -3
  48. package/board/public/ui-cards.js +236 -66
  49. package/board/public/ui-enums.js +5 -4
  50. package/board/public/ui-header.js +23 -14
  51. package/board/public/ui-hub.js +34 -16
  52. package/board/public/ui-links.js +18 -10
  53. package/board/public/ui-markdown.js +1 -1
  54. package/board/public/ui-order.js +21 -0
  55. package/board/public/ui-page-header.js +13 -8
  56. package/board/public/ui-patch.js +53 -0
  57. package/board/public/ui-phase-bars.js +17 -0
  58. package/board/public/ui-prompts.js +8 -8
  59. package/board/public/ui-questions.js +35 -11
  60. package/board/public/ui-sidebar.js +83 -21
  61. package/board/public/ui-snapshot.js +30 -13
  62. package/board/public/ui-threads.js +38 -23
  63. package/board/public/ui.js +96 -16
  64. package/board/public/view-frame.js +1 -1
  65. package/board/public/view-hub-settings.js +67 -44
  66. package/board/public/view-hub.js +105 -13
  67. package/board/public/view-notes.js +8 -5
  68. package/board/public/view-questions.js +123 -52
  69. package/board/public/view-settings.js +235 -62
  70. package/board/review.mjs +1 -0
  71. package/board/server.mjs +18 -7
  72. package/board/store.mjs +15 -6
  73. package/board/tickets.mjs +3 -3
  74. package/board/worktree.mjs +20 -13
  75. package/docs/hub-session.md +4 -4
  76. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  77. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  78. package/hub/awake.mjs +1 -0
  79. package/hub/board-repo.mjs +1 -1
  80. package/hub/boards.mjs +4 -4
  81. package/hub/close-board.mjs +1 -1
  82. package/hub/duration.mjs +1 -0
  83. package/hub/github.mjs +3 -4
  84. package/hub/home.mjs +1 -1
  85. package/hub/hub.mjs +12 -10
  86. package/hub/incoming.mjs +2 -0
  87. package/hub/linear.mjs +2 -2
  88. package/hub/live.mjs +2 -2
  89. package/hub/new-board.mjs +2 -2
  90. package/hub/permission-denied-hook.mjs +2 -0
  91. package/hub/pr-report.mjs +3 -0
  92. package/hub/presence.mjs +1 -1
  93. package/hub/prompts.mjs +1 -1
  94. package/hub/push.mjs +2 -2
  95. package/hub/readiness.mjs +2 -0
  96. package/hub/repos.mjs +3 -2
  97. package/hub/restart-board.mjs +1 -0
  98. package/hub/routes.mjs +1 -1
  99. package/hub/schedule.mjs +1 -1
  100. package/hub/sessions.mjs +2 -2
  101. package/hub/shell.mjs +2 -1
  102. package/hub/slack.mjs +2 -0
  103. package/hub/stream.mjs +12 -12
  104. package/hub/sw.js +2 -2
  105. package/hub/trust.mjs +2 -0
  106. package/hub/try.mjs +8 -3
  107. package/hub/upgrade.mjs +1 -0
  108. package/hub/usage.mjs +3 -3
  109. package/hub/views.mjs +1 -1
  110. package/package.json +5 -1
  111. package/shared/card-prs.mjs +1 -0
  112. package/shared/colors.css +9 -6
  113. package/shared/fake-gh.mjs +105 -0
  114. package/shared/repos.mjs +1 -1
  115. package/shared/service.mjs +16 -16
  116. package/shared/skills.mjs +2 -0
  117. package/shared/usage-gate.mjs +1 -1
  118. package/starter/public/claude.js +3 -2
  119. package/bin/web-ts.mjs +0 -2
  120. package/board/public/view-polling.js +0 -87
@@ -1,10 +1,11 @@
1
1
  // The board view's header and GitHub status: the time since the last change signal or refresh, refresh, the In Review
2
- // header's GitHub line (docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md);
3
- // the GitHub mark's Hub links sheet and a new board's setup checklist (docs/adr/0040-one-board-registry-for-every-hub-feature.md).
4
- // Needs app-core.js.
2
+ // header's GitHub line (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md);
3
+ // the GitHub mark's Hub links sheet and a new board's setup checklist (docs/adr/2026-10-29-1639-one-board-registry-for-every-hub-feature.md);
4
+ // and the wake: any tap on an asleep board's page, refresh included, asks the hub to resume its session
5
+ // (docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md). Needs app-core.js.
5
6
  BoardView.part((B) => {
6
7
  const { $, state, toast } = B;
7
- const { statusLine, freshSig, nextFresh, githubLine, linksList, setupCard, checkLanded, githubRefresh, headerControls } = BoardUI;
8
+ const { statusLine, freshSig, nextFresh, githubLine, remoteLine, linksList, setupCard, checkLanded, githubRefresh, headerControls } = BoardUI;
8
9
  // A request no check answers stops showing after this.
9
10
  const GITHUB_GIVE_UP = 5 * 60000;
10
11
 
@@ -15,14 +16,22 @@ BoardView.part((B) => {
15
16
  $('#setup').innerHTML = setupCard(d.setup);
16
17
  if ($('#linksSheet').open) $('#linksList').innerHTML = linksList(d.links);
17
18
  }
18
- const renderStatus = () => { $('#status').innerHTML = statusLine(state.fresh?.at, { link: state.link, watcher: state.board?.checks?.watcher, pause: B.pause?.() }); };
19
+ // The status line, and the moon right of the title while the owner reads away (hub/presence.mjs ownerTracker).
20
+ const renderStatus = () => {
21
+ $('#status').innerHTML = statusLine(state.fresh?.at, { link: state.link, watcher: state.board?.checks?.watcher, pause: B.pause?.(), waking: state.waking });
22
+ const moon = $('.away-moon');
23
+ const away = Boolean(B.away?.());
24
+ moon.classList.toggle('on', away);
25
+ moon.setAttribute('aria-hidden', String(!away));
26
+ };
19
27
  // A board load and its open threads landed; via is ui-header.js nextFresh's.
20
28
  function landed(via) {
21
29
  state.fresh = nextFresh(state.fresh, { via, at: new Date().toISOString(), sig: freshSig(state.board, state.threadCache, state.openItems) });
22
30
  renderStatus();
23
31
  }
24
32
  B.every(15000, () => { if (!document.hidden) renderStatus(); });
25
- const githubHeader = (d) => `<span class="lane-github">${githubLine(d.checks, { pending: Boolean(state.githubPending), links: d.links })}${githubRefresh(d.checks, state.githubPending)}</span>`;
33
+ const githubHeader = (d) => (d.remote ? `<span class="lane-github">${remoteLine(d.remote, { links: d.links })}</span>`
34
+ : `<span class="lane-github">${githubLine(d.checks, { pending: Boolean(state.githubPending), links: d.links })}${githubRefresh(d.checks, state.githubPending)}</span>`);
26
35
 
27
36
  // The Hub links sheet: the GitHub mark opens it, from inside the lane's summary, so the tap must not fold the lane.
28
37
  const linksSheet = $('#linksSheet');
@@ -47,7 +56,7 @@ BoardView.part((B) => {
47
56
  function askGitHub() {
48
57
  state.githubPending = Date.now();
49
58
  B.render();
50
- B.site.send('check-github', {}).catch(() => { state.githubPending = null; B.render(); toast('Could not ask Claude to check GitHub'); });
59
+ B.send('check-github', {}).catch(() => { state.githubPending = null; B.render(); toast('Could not ask Claude to check GitHub'); });
51
60
  }
52
61
  B.on(B.root, 'click', (ev) => {
53
62
  const b = ev.target.closest('[data-check-github]');
@@ -56,6 +65,22 @@ BoardView.part((B) => {
56
65
  if (state.link === 'online' && state.board && !state.githubPending) askGitHub();
57
66
  });
58
67
 
68
+ // The wake: the header reads waking… from the tap until the board's reload says the hub took it up.
69
+ state.waking = false;
70
+ async function wake() {
71
+ state.waking = true;
72
+ renderStatus();
73
+ try {
74
+ const r = await fetch('/_hub/wake', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ route: B.route }) });
75
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `the hub answered ${r.status}`);
76
+ await B.loadBoard();
77
+ } catch (e) { toast(`Couldn't wake this board's session: ${e.message}`); }
78
+ finally { state.waking = false; if (B.alive()) renderStatus(); }
79
+ }
80
+ const wakeOnTap = () => { if (state.board?.checks?.watcher === 'asleep' && !state.waking && state.link === 'online') wake(); };
81
+ B.on(B.root, 'click', wakeOnTap, true);
82
+ B.on(B.root, 'submit', wakeOnTap, true);
83
+
59
84
  state.link = 'online';
60
85
  const controls = { wired: false, boardSettled: false, reloading: false };
61
86
  function applyControls() {
@@ -82,7 +107,7 @@ BoardView.part((B) => {
82
107
  B.on($('.nav'), 'click', (ev) => {
83
108
  if (ev.target.closest('a[aria-disabled="true"]')) { ev.preventDefault(); ev.stopImmediatePropagation(); }
84
109
  }, true);
85
- // A load can return in milliseconds, so the spin and "Reconnecting…" hold long enough to read.
110
+ // A load can return in milliseconds, so the spin and "reconnecting…" hold long enough to read.
86
111
  const RELOAD_MIN = 700;
87
112
  async function reloading(load) {
88
113
  controls.reloading = true;
@@ -1,18 +1,59 @@
1
- // The board view's lanes and wave filter: each lane's header and cards, the "N older" and finished-wave folds, and the wave bars.
1
+ // The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Todo's Queued section, Done's and Asks' Show more,
2
+ // Done's finished-wave folds, and Todo's wave bands with their Implement, triage and card order.
2
3
  // Needs app-core.js and app-cards.js.
3
4
  BoardView.part((B) => {
4
5
  const { $, store, state } = B;
5
- const { esc } = BoardUI;
6
+ const { esc, waitOn } = BoardUI;
6
7
  const LANES = [
7
8
  { id: 'review', name: 'In Review', open: true },
8
9
  { id: 'stale', name: 'Stale', open: true, hideEmpty: true },
9
10
  { id: 'progress', name: 'In Progress', open: true },
10
11
  { id: 'todo', name: 'Todo', open: true },
11
- { id: 'done', name: 'Done', open: false },
12
+ { id: 'done', name: 'Done', open: false, capped: true },
13
+ { id: 'asks', name: 'Asks', open: false, capped: true },
12
14
  ];
15
+ // docs/adr/2026-11-03-0259-answered-asks-have-their-own-section-below-done.md
16
+ // A capped lane shows STEP cards, and each Show more tap adds STEP more, kept in memory per lane. A card the owner has
17
+ // open stays shown past the cap. Show more says how many are left (docs/adr/2026-10-29-1639-lists-never-cut-silently.md).
18
+ // older is how many cards wait past `cards` in the archive; the tap that would show past `cards` fetches them first.
19
+ const STEP = 10;
20
+ const moreTaps = new Map();
21
+ function cappedRows(laneId, cards, older = 0) {
22
+ const lastOpen = cards.findLastIndex((it) => state.openItems.has(it.key));
23
+ const shown = Math.max(STEP * (1 + (moreTaps.get(laneId) || 0)), lastOpen + 1);
24
+ const left = Math.max(0, cards.length - shown);
25
+ const n = left + (older || 0);
26
+ const words = `${n} older card${n === 1 ? '' : 's'}`;
27
+ const fetches = older !== 0 && shown + STEP > cards.length;
28
+ return cards.slice(0, shown).map((it) => B.card(it)).join('')
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
+ }
31
+
32
+ // docs/adr/2026-11-04-0307-the-board-keeps-the-newest-100-done-cards.md
33
+ // Done shows the board's Done cards, then those paged in from the archive (GET /api/done), 100 a page, newest first.
34
+ // The board says how many the archive holds (doneArchived), so Done's count is the true total before any page is fetched.
35
+ const { archived } = state;
36
+ const olderThanBoard = (d) => Math.max(0, d.doneArchived - archived.cards.filter((c) => !d.items.some((i) => i.key === c.key)).length);
37
+ function doneRows(d, open) {
38
+ const onBoard = new Set(d.items.map((i) => i.key));
39
+ const cards = [...open, ...archived.cards.filter((c) => !onBoard.has(c.key))].sort((a, b) => String(b.sortAt || '').localeCompare(String(a.sortAt || '')));
40
+ return cappedRows('done', cards, olderThanBoard(d));
41
+ }
42
+ function doneCount(d, items) {
43
+ const onBoard = new Set(d.items.map((i) => i.key));
44
+ const older = olderThanBoard(d);
45
+ return `${items.length + archived.cards.filter((c) => !onBoard.has(c.key)).length + older}`;
46
+ }
47
+ async function fetchOlder() {
48
+ const last = archived.cards.at(-1);
49
+ const r = await fetch(B.site.url(`api/done${last ? `?before=${encodeURIComponent(`${last.sortAt}|${last.key}`)}` : ''}`), { cache: 'no-store' });
50
+ if (!r.ok) throw new Error(`done ${r.status}`);
51
+ const page = await r.json();
52
+ archived.cards.push(...page.cards);
53
+ }
13
54
 
14
55
  // A fold's open state lives in storage, not in setHTML's summary-text match, because its row's count changes.
15
- // Fold ids are 'older', 'asks' or wave:<slug> (board/domain.mjs), so any id maps to its own storage key.
56
+ // Fold ids are wave:<slug> (board/domain.mjs waveFold), so any id maps to its own storage key.
16
57
  const FOLDS = new Proxy({}, { get: (_, id) => ({ store: `foldOpen:${String(id)}` }) });
17
58
  // A fold's header reads like a lane's: its name, then its count, then an optional note.
18
59
  function fold(id, list, name, note = '') {
@@ -33,39 +74,116 @@ BoardView.part((B) => {
33
74
  }).join('');
34
75
  }
35
76
 
36
- // shown is the board's items after the wave filter and pending deletes.
77
+ // docs/adr/2026-11-03-1731-each-board-reviews-incoming-prs-that-touch-its-work.md: the Auto Review lane leads the lanes. The PRs
78
+ // the owner has open are kept in memory, as cards are.
79
+ const openReviews = new Set();
80
+ const reviewsHtml = (d) => BoardUI.reviewLane(d.reviews, {
81
+ open: { lane: store.get('lanes', {}).reviews, earlier: store.get(FOLDS['reviews-earlier'].store, false), cards: openReviews } });
82
+ // Opens PR number's review and its lane or fold, for a Needs you line or a push's #review=<number>.
83
+ function openReview(number) {
84
+ openReviews.add(number);
85
+ const s = store.get('lanes', {}); s.reviews = true; store.set('lanes', s);
86
+ if (state.board?.reviews?.earlier.some((c) => c.number === number)) store.set(FOLDS['reviews-earlier'].store, true);
87
+ B.render();
88
+ $(`[data-review="${number}"]`)?.scrollIntoView({ block: 'start' });
89
+ }
90
+
91
+ // shown is the board's items less pending deletes.
37
92
  function lanesHtml(d, shown) {
38
93
  const laneState = store.get('lanes', {});
39
- return LANES.map((lane) => {
94
+ return reviewsHtml(d) + LANES.map((lane) => {
95
+ // Items arrive in each lane's fixed order (domain.mjs laneOrder).
40
96
  const items = shown.filter((i) => i.lane === lane.id);
41
97
  if (lane.hideEmpty && !items.length) return '';
42
- const open = laneState[lane.id] ?? lane.open;
43
- const older = items.filter((i) => i.fold === 'older');
44
- const asks = items.filter((i) => i.fold === 'asks');
45
- const rows = items.filter((i) => !i.fold).map((it) => B.card(it)).join('') + fold('asks', asks, 'Answered')
46
- + waveFolds(d, items) + fold('older', older, 'Older');
47
- return `<details class="lane" data-lane="${lane.id}" ${open ? 'open' : ''}>
48
- <summary><span class="lane-name">${lane.name}</span><span class="count">${items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
98
+ const expanded = laneState[lane.id] ?? lane.open;
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) : open.map((it) => B.card(it)).join('')) + waveFolds(d, items);
101
+ return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
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>
49
103
  <div class="list">${rows || '<div class="empty">Nothing here.</div>'}
50
104
  ${lane.id === 'todo' ? BoardUI.planButton(items) : ''}</div>
51
105
  </details>`;
52
106
  }).join('');
53
107
  }
54
108
 
55
- function renderWaves(d) {
56
- if (d.waves.some((w) => w.finished && w.slug === state.waveFilter)) { state.waveFilter = null; store.set('wave', null); }
57
- const rows = d.waves.map((w) => {
58
- const items = d.items.filter((i) => i.wave === w.slug);
59
- const merged = items.filter((i) => i.pr?.state === 'MERGED' || (!i.pr && i.lane === 'done')).length;
60
- const drafts = items.filter((i) => i.pr?.state === 'OPEN' && i.pr.isDraft).length;
61
- const review = items.filter((i) => i.pr?.state === 'OPEN' && !i.pr.isDraft).length;
62
- return { w, total: items.length, merged, drafts, review };
63
- }).filter((r) => r.total && !r.w.finished);
64
- $('#waves').innerHTML = rows.map(({ w, total, merged, drafts, review }) => `<button class="wave ${w.slug === state.waveFilter ? 'on' : ''}" data-wave="${esc(w.slug)}">
65
- <span class="wave-name">${esc(w.title)}</span>
66
- <span class="muted small">${merged} of ${total} ${w.internal ? 'done' : 'merged'}${review ? ` · ${review} in review` : ''}${drafts ? ` · ${drafts} draft${drafts === 1 ? '' : 's'}` : ''}</span>
67
- <span class="bar"><span style="width:${Math.round((merged / total) * 100)}%"></span></span>
68
- </button>`).join('');
109
+ // 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 Not in a wave. A band's open state lives
111
+ // in storage once the owner taps its head; until then only the first band whose next card is ready (a wave file's:
112
+ // 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
+ // card whose ⋯ menu is open, goalShown the band whose goal popover is open.
114
+ let menu = null;
115
+ let goalShown = null;
116
+ const triaging = new Set();
117
+ const triageTopic = (r) => `${r.kind}:${r.slug}`;
118
+ function triageLink(r) {
119
+ if (r.triage.running || triaging.has(triageTopic(r))) return '<span class="wv-busy" role="status"><span class="try-spin" aria-hidden="true"></span>triaging…</span>';
120
+ if (r.triage.questions) return `<span class="muted">Triage: ${B.plural(r.triage.questions, 'open question')}</span>`;
121
+ return `<button type="button" class="wv-link" data-triage="${esc(triageTopic(r))}">Triage</button>`;
122
+ }
123
+ const bandStore = (slug) => FOLDS[`band:${slug}`].store;
124
+ const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
125
+ const LANE_WORDS = { progress: 'in progress', review: 'in review', stale: 'stale' };
126
+ 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
+
128
+ // docs/adr/2026-11-04-0150-queued-sits-at-the-top-of-todo.md
129
+ // Todo's Queued section, which holds the cards waiting for an implementer slot, the next to build on top, as they
130
+ // arrive (domain.mjs laneOrder), then its wave bands and loose cards.
131
+ function todoRows(d, shown, all) {
132
+ const queued = all.filter((i) => i.queued);
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));
141
+ const loose = todo.filter((i) => !banded.has(i.key));
142
+ const first = bands.find((b) => (b.next ? b.next.ready : b.keys.some((k) => byKey.get(k)?.ready)))?.slug;
143
+ if (!bands.length) return loose.map((it) => B.card(it)).join('');
144
+ 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">Not in a wave</span><span class="count">${loose.length}</span></div>${loose.map((it) => B.card(it)).join('')}</div>` : '');
146
+ }
147
+
148
+ function bandHtml(band, cards, open) {
149
+ const todo = cards.filter((i) => i.lane === 'todo');
150
+ const done = cards.filter((i) => i.lane === 'done');
151
+ const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${i.queued ? 'queued' : LANE_WORDS[i.lane]}`);
152
+ const progress = [`${band.merged} of ${band.total} merged`, ...now].join(' · ');
153
+ 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 ${BoardUI.LINK_ARROW}</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
155
+ const next = band.next;
156
+ const go = !next ? '' : next.ready
157
+ ? `<button type="button" class="wv-link" data-confirm="card-build" data-key="${esc(next.key)}">Implement</button>`
158
+ : `<span class="wb-next">next: ${esc(next.ref)} ${esc(next.word)}</span>`;
159
+ const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
160
+ const head = `<div class="wb-head" data-band-toggle="${esc(band.slug)}" role="button" tabindex="0" aria-expanded="${open}">${BoardUI.WAVE_ICON}`
161
+ + `<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>`;
163
+ if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
164
+ 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
+ const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
166
+ const actions = (it, at) => (band.kind !== 'lineup' ? null : cardMenu(band.slug, it.key, at, todo.length));
167
+ return `<section class="wb" data-band="${esc(band.slug)}" data-open>${head}<div class="wb-body">${doneRow}${todo.map((it, at) => B.card(it, { step: step(it), actions: actions(it, at) })).join('')}</div></section>`;
168
+ }
169
+
170
+ // A lineup card's ⋯ and, while open, its menu: Move up and Move down among the band's Todo cards. A move waits for
171
+ // the server (board/server.mjs lineup-move): the tapped move waits and the other is disabled meanwhile.
172
+ function cardMenu(slug, key, at, count) {
173
+ const shown = menu === key;
174
+ const item = (by, label) => {
175
+ const off = by < 0 ? at === 0 : at === count - 1;
176
+ return `<button type="button" role="menuitem" data-band-move="${by}" data-band="${esc(slug)}" data-key="${esc(key)}"${off ? ' disabled' : ''}>${label}</button>`;
177
+ };
178
+ return `<div class="ask-actions"><button type="button" class="ghost icon wb-dots" data-band-menu="${esc(key)}" aria-label="Card menu" aria-haspopup="menu" aria-expanded="${shown}">${DOTS}</button>`
179
+ + (shown ? `<div class="wb-menu" role="menu">${item(-1, '↑ Move up')}${item(1, '↓ Move down')}</div>` : '') + '</div>';
180
+ }
181
+
182
+ // Opens the band holding card key or named slug, for a #item= link or a Needs you line.
183
+ function openBand({ key, slug }) {
184
+ const band = (state.board?.waveBands || []).find((b) => b.slug === slug || b.keys.includes(key));
185
+ if (band) store.set(bandStore(band.slug), true);
186
+ return band;
69
187
  }
70
188
 
71
189
  B.on(B.root, 'toggle', (ev) => {
@@ -73,20 +191,106 @@ BoardView.part((B) => {
73
191
  if (el.matches?.('details.lane')) {
74
192
  const s = store.get('lanes', {}); s[el.dataset.lane] = el.open; store.set('lanes', s);
75
193
  } else if (el.matches?.('details.fold')) store.set(FOLDS[el.dataset.fold].store, el.open);
194
+ else if (el.matches?.('details.rv-card, details.rv-line')) {
195
+ if (el.open) openReviews.add(Number(el.dataset.review)); else openReviews.delete(Number(el.dataset.review));
196
+ }
76
197
  }, true);
77
198
 
78
199
  B.on(B.root, 'click', async (ev) => {
200
+ const answer = ev.target.closest('button[data-review-answer]');
201
+ if (answer) {
202
+ // The tapped answer waits, and the review's other answer is disabled, until the server records it.
203
+ const pr = Number(answer.dataset.review);
204
+ try { await waitOn(answer, B.send('review-answer', { pr, answer: answer.dataset.reviewAnswer }), { group: answer.parentNode.querySelectorAll('button') }); }
205
+ catch { B.notice('Could not send. Is the host awake?'); }
206
+ return;
207
+ }
208
+ const review = ev.target.closest('a[data-review-open]');
209
+ if (review) { ev.preventDefault(); openReview(Number(review.dataset.reviewOpen)); return; }
210
+ const more = ev.target.closest('[data-lane-more]');
211
+ if (more) {
212
+ if (more.hasAttribute('data-fetch')) {
213
+ try { await waitOn(more, fetchOlder()); } catch { B.toast('Could not load older cards. Is the host awake?'); return; }
214
+ if (!B.alive()) return;
215
+ }
216
+ moreTaps.set(more.dataset.laneMore, (moreTaps.get(more.dataset.laneMore) || 0) + 1);
217
+ B.render();
218
+ return;
219
+ }
79
220
  const pb = ev.target.closest('button[data-plan]');
80
221
  if (pb) {
81
- if (!(await B.confirm('Claude groups these follow-ups into a small wave and asks you about the plan in Needs you. Nothing builds until you answer. Start?'))) return;
82
- pb.disabled = true;
83
- try { await B.site.send('plan-followups', { keys: pb.dataset.plan.split(' ') }); pb.textContent = 'Planning… its questions appear in Needs you'; }
84
- catch { B.notice('Could not send. Is the host awake?'); pb.disabled = false; }
222
+ 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
+ try { await waitOn(pb, B.send('plan-waves', { keys: pb.dataset.plan.split(' ') })); pb.textContent = 'Planning… its questions appear in Needs you'; pb.disabled = true; }
224
+ catch { B.notice('Could not send. Is the host awake?'); }
225
+ return;
226
+ }
227
+ const tb = ev.target.closest('button[data-triage]');
228
+ if (tb) {
229
+ const topic = tb.dataset.triage;
230
+ const row = state.board.waveBands.find((r) => triageTopic(r) === topic);
231
+ const asks = row.kind === 'lineup'
232
+ ? `Claude reads the code for ${row.title}'s unstarted cards and asks you in Needs you about splitting, merging, adding or closing any. Start?`
233
+ : `Claude checks ${row.title}'s unfinished cards, fixes what it can and asks you in Needs you about any that need you. Start?`;
234
+ if (!(await B.confirm(asks))) return;
235
+ triaging.add(topic);
236
+ B.render();
237
+ try { await B.send('wave-triage', { [row.kind]: row.slug }); }
238
+ catch { B.notice('Could not send. Is the host awake?'); }
239
+ finally { triaging.delete(topic); B.render(); }
240
+ return;
241
+ }
242
+ const show = ev.target.closest('a[data-band-show]');
243
+ if (show) {
244
+ ev.preventDefault();
245
+ if (!openBand({ slug: show.dataset.bandShow })) return;
246
+ const s = store.get('lanes', {}); s.todo = true; store.set('lanes', s);
247
+ B.render();
248
+ $(`.wb[data-band="${CSS.escape(show.dataset.bandShow)}"]`)?.scrollIntoView({ block: 'start' });
249
+ }
250
+ });
251
+
252
+ // A band's ⋯ menu and its moves, its head's fold and its name's goal popover. A long-press on the name (touch only;
253
+ // a mouse has the tooltip) opens the popover, and the click that ends the press neither folds the band nor closes it.
254
+ let press = null;
255
+ let pressed = false;
256
+ const endPress = () => { press?.(); press = null; };
257
+ B.on(B.root, 'pointerdown', (ev) => {
258
+ pressed = false;
259
+ const name = ev.target.closest?.('[data-goal]');
260
+ if (!name || ev.pointerType === 'mouse') return;
261
+ endPress();
262
+ press = B.later(450, () => { press = null; pressed = true; goalShown = name.dataset.goal; B.render(); });
263
+ });
264
+ for (const type of ['pointerup', 'pointercancel']) B.on(B.root, type, endPress);
265
+ B.on(B.root, 'contextmenu', (ev) => { if (ev.target.closest?.('[data-goal]')) ev.preventDefault(); });
266
+ B.on(B.root, 'click', async (ev) => {
267
+ if (pressed) { pressed = false; ev.preventDefault(); return; }
268
+ if (goalShown) { goalShown = null; B.render(); if (ev.target.closest('.wb-head')) return; }
269
+ const dots = ev.target.closest('button[data-band-menu]');
270
+ if (dots) { ev.preventDefault(); menu = menu === dots.dataset.bandMenu ? null : dots.dataset.bandMenu; B.render(); return; }
271
+ const mv = ev.target.closest('button[data-band-move]');
272
+ if (mv) {
273
+ ev.preventDefault();
274
+ if (mv.disabled) return;
275
+ const { band, key } = mv.dataset;
276
+ try { await waitOn(mv, B.send('lineup-move', { lineup: band, key, by: Number(mv.dataset.bandMove) }), { group: mv.parentNode.querySelectorAll('button') }); }
277
+ catch { B.toast('Could not move it. Is the host awake?'); }
278
+ menu = null;
279
+ B.render();
85
280
  return;
86
281
  }
87
- const wb = ev.target.closest('button[data-wave]');
88
- if (wb) { state.waveFilter = wb.dataset.wave !== state.waveFilter ? wb.dataset.wave : null; store.set('wave', state.waveFilter); B.render(); }
282
+ if (menu && !ev.target.closest('.wb-menu')) { menu = null; B.render(); if (ev.target.closest('summary')) { ev.preventDefault(); return; } }
283
+ const head = ev.target.closest('[data-band-toggle]');
284
+ if (head && !ev.target.closest('a, button')) {
285
+ const slug = head.dataset.bandToggle;
286
+ store.set(bandStore(slug), head.getAttribute('aria-expanded') !== 'true');
287
+ B.render();
288
+ }
289
+ });
290
+ B.on(B.root, 'keydown', (ev) => {
291
+ const head = ev.target.closest?.('[data-band-toggle]');
292
+ if (head && ev.target === head && (ev.key === 'Enter' || ev.key === ' ')) { ev.preventDefault(); head.click(); }
89
293
  });
90
294
 
91
- Object.assign(B, { FOLDS, lanesHtml, renderWaves });
295
+ Object.assign(B, { FOLDS, lanesHtml, openReview, openBand });
92
296
  });
@@ -23,6 +23,8 @@ BoardView.part((B) => {
23
23
  w.innerHTML = needs.length ? `<h2 style="margin:0 0 6px">Needs you · ${needs.length}</h2>
24
24
  <ul>${needs.map((line) => (line.prompt ? promptItem(line.prompt, prompts.state(line.prompt))
25
25
  : line.key ? `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
26
+ : line.review ? `<li><a href="${esc(line.url)}" data-review-open="${line.review}">${esc(line.title)}</a> <span class="muted small">· review held for you</span></li>`
27
+ : line.band ? `<li><a href="#" data-band-show="${esc(line.band)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
26
28
  : `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
27
29
  }
28
30
 
@@ -1,9 +1,10 @@
1
1
  // The last visit's board on the board view: reading and saving it, holding the controls that send until the fresh
2
- // board lands, and flashing the lines the fresh board changed (docs/adr/0028-the-board-opens-on-the-last-visit.md).
2
+ // board lands, flashing the lines the fresh board changed (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md), and
3
+ // the sign on a card that changed lanes or was updated (docs/adr/2026-11-02-2354-an-updated-card-takes-a-brief-tint.md).
3
4
  // Needs app-core.js and ui-snapshot.js; app.js paints it.
4
5
  BoardView.part((B) => {
5
6
  const { $$, state } = B;
6
- const { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, SNAPSHOT_MAX } = BoardUI;
7
+ const { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, updateSign, SNAPSHOT_MAX } = BoardUI;
7
8
  // The hub serves every site from one origin, so each board keeps its own key.
8
9
  const KEY = 'boardSnapshot:' + B.site.url('');
9
10
  // Every board's snapshots together stay within three at ui-snapshot.js's cap, the boards saved least recently
@@ -11,11 +12,12 @@ BoardView.part((B) => {
11
12
  // otherwise fail to save.
12
13
  const BUDGET = 3 * SNAPSHOT_MAX;
13
14
  const ORDER = 'boardSnapshots';
15
+ const BUILD = document.documentElement.dataset.build;
14
16
 
15
17
  function readSnapshot() {
16
18
  let raw = null;
17
19
  try { raw = localStorage.getItem(KEY); } catch {}
18
- return raw ? unpackSnapshot(raw) : null;
20
+ return raw ? unpackSnapshot(raw, BUILD) : null;
19
21
  }
20
22
  // Makes room for this board's raw snapshot.
21
23
  function keepRecent(raw) {
@@ -38,6 +40,7 @@ BoardView.part((B) => {
38
40
  saveTimer = null;
39
41
  if (state.view !== 'fresh' || !state.board) return;
40
42
  const raw = packSnapshot(state.board, state.threadCache, {
43
+ build: BUILD,
41
44
  savedAt: state.board.generatedAt || new Date().toISOString(),
42
45
  keep: [...state.openItems, ...preloadKeys(state.board.items, [])],
43
46
  });
@@ -66,13 +69,9 @@ BoardView.part((B) => {
66
69
  }, true);
67
70
  }
68
71
 
69
- // The lines a fresh board can change: each card line, lane count, Needs you line and thread message.
72
+ // The lines a fresh board can change: each lane count, Needs you line and thread message. A card shows its own sign below.
70
73
  function lines() {
71
74
  const out = new Map();
72
- for (const wrap of $$('details.item[data-key] > summary .title-wrap')) {
73
- const key = wrap.closest('[data-key]').dataset.key;
74
- [...wrap.children].forEach((el, i) => out.set(`${key}|${i}`, el));
75
- }
76
75
  for (const count of $$('.lane[data-lane] > summary .count')) out.set(`lane:${count.closest('.lane').dataset.lane}`, count);
77
76
  $$('#waiting li').forEach((li, i) => out.set(`needs:${i}`, li));
78
77
  for (const msgs of $$('[data-thread] > .msgs')) [...msgs.children].forEach((m, i) => out.set(`thread:${msgs.parentElement.dataset.thread}|${i}`, m));
@@ -86,12 +85,47 @@ BoardView.part((B) => {
86
85
  const els = lines();
87
86
  const after = texts(els);
88
87
  for (const id of changedLines(before, after)) {
89
- const el = els.get(id);
90
- (el.closest('details.item') || el).classList.add('flash');
88
+ els.get(id).classList.add('flash');
91
89
  before[id] = after[id];
92
90
  }
93
91
  }
94
92
  const endFlash = () => { before = null; };
95
93
 
96
- Object.assign(B, { readSnapshot, saveSnapshotSoon, setView, holdForFlash, flashChanged, endFlash });
94
+ // Since the page last drew it, a last visit's board included, a card that changed lanes, or a work card that takes
95
+ // the place of a drawn New task (its fromAsk), flashes once; any other card whose updateSign changed takes a brief
96
+ // tint on its title row. Neither shows for a change the owner sent from this page: sentHere(key, send) holds the
97
+ // card's next change unsigned until the first board load started after send settles.
98
+ let drawn = new Map();
99
+ const ownChanges = new Map();
100
+ async function sentHere(key, send) {
101
+ ownChanges.set(key, Infinity);
102
+ try { return await send(); } finally { ownChanges.set(key, B.boardLoad || 0); }
103
+ }
104
+ // Each sign's style.css animation length. A lane repaint resets a card's classes, so a sign still running is put back
105
+ // as far into its animation as it was.
106
+ const SIGNS = { flash: 1600, tint: 2400 };
107
+ const running = new Map();
108
+ function signCards(load = null) {
109
+ const now = Date.now();
110
+ for (const it of state.board.items) {
111
+ const was = drawn.get(it.key);
112
+ const moved = was ? was.lane !== it.lane : Boolean(it.fromAsk) && drawn.has(`ask:${it.fromAsk}`);
113
+ const updated = Boolean(was) && was.sign !== updateSign(it);
114
+ const own = ownChanges.get(it.key);
115
+ if (own !== undefined) { if (moved || updated || (load !== null && load > own)) ownChanges.delete(it.key); continue; }
116
+ if ((moved || updated) && state.view === 'fresh') running.set(it.key, { sign: moved ? 'flash' : 'tint', at: now });
117
+ }
118
+ drawn = new Map(state.board.items.map((i) => [i.key, { lane: i.lane, sign: updateSign(i) }]));
119
+ for (const [key, { sign, at }] of running) {
120
+ const card = now - at < SIGNS[sign] && B.root.querySelector(`details.item[data-key="${CSS.escape(key)}"]`);
121
+ if (!card) { running.delete(key); continue; }
122
+ // Re-adding the class after a layout read restarts the animation, from the delay's point.
123
+ card.classList.remove('flash', 'tint');
124
+ card.style.setProperty('--sign-delay', `-${now - at}ms`);
125
+ void card.offsetWidth;
126
+ card.classList.add(sign);
127
+ }
128
+ }
129
+
130
+ Object.assign(B, { readSnapshot, saveSnapshotSoon, setView, holdForFlash, flashChanged, endFlash, sentHere, signCards });
97
131
  });
@@ -1,16 +1,15 @@
1
1
  // A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
2
2
  BoardView.part((B) => {
3
3
  const { $, store, state, toast, quieted, sendDraft, links } = B;
4
- const { esc, setHTML, threadMessages, attachButton, protoButton, protoNotice, doneButton } = BoardUI;
4
+ const { esc, setHTML, threadMessages, attachButton, dropZone, protoButton, protoNotice, doneButton, waitOn } = BoardUI;
5
5
 
6
6
  const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
7
7
 
8
8
  // The board, not the thread, decides the Done button, so it follows each board load even when the thread is unchanged.
9
- const sendingDone = new Set();
10
9
  function syncDone(el, key) {
11
10
  const send = el.querySelector('form button[type=submit]');
12
11
  if (!send) return;
13
- const html = doneButton(state.board?.items.find((i) => i.key === key), sendingDone.has(key));
12
+ const html = doneButton(state.board?.items.find((i) => i.key === key));
14
13
  const current = el.querySelector('form [data-confirm="finish"]');
15
14
  if (current?.outerHTML === html) return;
16
15
  current?.remove();
@@ -18,10 +17,13 @@ BoardView.part((B) => {
18
17
  }
19
18
 
20
19
  // Each open card's ui-threads.js threadMessages view. Opening a card marks it seen (app-core.js markSeen) before its
21
- // thread draws, so its unread messages start from its seen mark as the page loaded or as the card last closed.
22
- const seenBefore = new Map(Object.entries(B.seen));
23
- const moreTaps = new Map();
24
- const firstDrawn = new Map();
20
+ // thread draws, so its unread messages start from its seen mark as the page loaded or as the card last closed. A
21
+ // board left and opened again in this page keeps them, so its open threads show what they showed.
22
+ const kept = B.place?.threads;
23
+ const seenBefore = new Map(kept?.seenBefore || Object.entries(B.seen));
24
+ const moreTaps = new Map(kept?.moreTaps);
25
+ const firstDrawn = new Map(kept?.firstDrawn);
26
+ B.keep(() => ({ threads: { seenBefore: [...seenBefore], moreTaps: [...moreTaps], firstDrawn: [...firstDrawn] } }));
25
27
  const newestTs = (msgs) => msgs.reduce((top, m) => [String(m.ts), m.reply?.at ? String(m.reply.at) : ''].reduce((a, b) => (b > a ? b : a), top), '');
26
28
  const viewOf = (key) => ({ seenAt: seenBefore.get(key) || null, more: moreTaps.get(key) || 0, upto: firstDrawn.get(key) || null });
27
29
  B.on(B.root, 'toggle', (ev) => {
@@ -52,13 +54,14 @@ BoardView.part((B) => {
52
54
  // The reply form is built once per card, so a refresh never drops what is being typed.
53
55
  if (!el.querySelector('.msgs')) {
54
56
  const draft = store.get('draft:' + key, '');
55
- el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}"><textarea rows="2" placeholder="Reply in this thread…">${esc(draft)}</textarea>
57
+ el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="Reply in this thread…">${esc(draft)}</textarea>`)}
56
58
  <div class="pending-files" data-files></div>
57
59
  <div class="row">${attachButton()}${protoButton(key)}<button class="chip quick" type="button" data-quick="What's left?">What's left?</button><button class="primary" type="submit">Send</button></div></form>`;
58
60
  BoardAttach.paint('item:' + key, B.site.root);
59
61
  }
60
62
  syncDone(el, key);
61
63
  B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(msgs, links(key), edits, view)));
64
+ for (const id of edits.keys()) BoardAttach.paint('edit:' + id, B.site.root);
62
65
  B.flashChanged?.(el);
63
66
  // A thread's request is answered by a later thread line, so only a pending one changes the eye.
64
67
  const req = msgs.filter((m) => m.kind === 'prototype-request').pop();
@@ -103,10 +106,8 @@ BoardView.part((B) => {
103
106
  const b = ev.target.closest('form[data-reply] button[data-quick]');
104
107
  if (!b) return;
105
108
  const key = b.closest('form').dataset.reply;
106
- b.disabled = true;
107
- try { await B.site.send('item-message', { key, text: b.dataset.quick }); loadThread(key, { force: true }); }
109
+ try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick }))); loadThread(key, { force: true }); }
108
110
  catch { toast('Could not send'); }
109
- b.disabled = false;
110
111
  });
111
112
  B.on(B.root, 'click', async (ev) => {
112
113
  const b = ev.target.closest('form[data-reply] button[data-proto]');
@@ -119,17 +120,17 @@ BoardView.part((B) => {
119
120
  const n = protoNotice(pending, latest);
120
121
  if (!n.bump) { B.notice(n.text); return; }
121
122
  if (!(await B.confirm(n.text))) return;
122
- try { await B.site.send('ask-bump', { id: pending }); toast('Bumped to the top'); } catch { toast('Could not bump'); }
123
+ await B.bumpAsk(pending, b);
123
124
  return;
124
125
  }
125
126
  if (!(await B.confirm('Ask Claude for prototypes of this item?'))) return;
126
- b.disabled = true;
127
127
  try {
128
- await B.site.send('prototype-request', { key });
128
+ await waitOn(b, B.sentHere(key, () => B.send('prototype-request', { key })));
129
+ b.disabled = true;
129
130
  b.title = b.ariaLabel = 'Prototypes asked for';
130
131
  toast('Asked Claude for prototypes');
131
132
  loadThread(key, { force: true });
132
- } catch { b.disabled = false; toast('Could not send'); }
133
+ } catch { toast('Could not send'); }
133
134
  });
134
135
  B.on(B.root, 'submit', async (ev) => {
135
136
  const f = ev.target.closest('form[data-reply]');
@@ -142,14 +143,12 @@ BoardView.part((B) => {
142
143
  const files = BoardAttach.files(id, B.site.root);
143
144
  if ((!text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
144
145
  const key = f.dataset.reply;
145
- const btn = f.querySelector('button[type=submit]'); btn.disabled = true;
146
146
  try {
147
- await sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
148
- () => B.site.send('item-message', { key, text, ...(files.length ? { files } : {}) }));
147
+ await waitOn(f.querySelector('button[type=submit]'), sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
148
+ () => B.sentHere(key, () => B.send('item-message', { key, text, ...(files.length ? { files } : {}) }))));
149
149
  BoardAttach.clear(id, B.site.root);
150
- btn.disabled = false;
151
150
  loadThread(f.dataset.reply, { force: true });
152
- } catch { B.notice('Could not send. Is the host awake?'); btn.disabled = false; }
151
+ } catch { B.notice('Could not send. Is the host awake?'); }
153
152
  });
154
153
  // Send waits while a composer's files upload, the ask's included (attach.js attach-change).
155
154
  B.on(B.root, 'attach-change', (ev) => {
@@ -158,7 +157,7 @@ BoardView.part((B) => {
158
157
  });
159
158
 
160
159
  Object.assign(B, {
161
- thread, renderThread, loadThread, sendingDone,
160
+ thread, renderThread, loadThread,
162
161
  repaintThread: (key) => { if (state.threadCache.has(key)) renderThread(key, state.threadCache.get(key)); },
163
162
  syncDone: (key) => { const el = $(`[data-thread="${CSS.escape(key)}"]`); if (el) syncDone(el, key); },
164
163
  });