@lovek4d/kickflip 0.1.0 → 0.3.1

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 (96) hide show
  1. package/CONTEXT.md +177 -78
  2. package/README.md +46 -5
  3. package/SKILL.md +58 -37
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +87 -32
  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/bin/agent.mjs +1 -1
  11. package/board/bin/board.mjs +241 -69
  12. package/board/bin/builder-stop-hook.mjs +4 -3
  13. package/board/domain.mjs +5 -3
  14. package/board/on-host.mjs +1 -0
  15. package/board/public/app-arch.js +75 -0
  16. package/board/public/app-asks.js +33 -14
  17. package/board/public/app-cards.js +242 -49
  18. package/board/public/app-core.js +39 -5
  19. package/board/public/app-header.js +24 -6
  20. package/board/public/app-lanes.js +76 -26
  21. package/board/public/app-needs.js +1 -0
  22. package/board/public/app-snapshot.js +45 -11
  23. package/board/public/app-threads.js +13 -11
  24. package/board/public/app.html +4 -1
  25. package/board/public/app.js +131 -20
  26. package/board/public/dialogs.js +114 -116
  27. package/board/public/hub-kit.js +19 -2
  28. package/board/public/hub.css +24 -18
  29. package/board/public/linear-key.js +141 -0
  30. package/board/public/router.js +6 -3
  31. package/board/public/rules-editor.js +1 -1
  32. package/board/public/shell.js +1 -1
  33. package/board/public/sidebar.css +31 -11
  34. package/board/public/sidebar.js +88 -16
  35. package/board/public/style.css +141 -46
  36. package/board/public/toasts.js +6 -4
  37. package/board/public/ui-cards.js +102 -37
  38. package/board/public/ui-header.js +17 -8
  39. package/board/public/ui-hub.js +30 -12
  40. package/board/public/ui-links.js +18 -10
  41. package/board/public/ui-order.js +18 -0
  42. package/board/public/ui-page-header.js +4 -2
  43. package/board/public/ui-questions.js +37 -16
  44. package/board/public/ui-sidebar.js +49 -11
  45. package/board/public/ui-snapshot.js +15 -9
  46. package/board/public/ui-threads.js +25 -16
  47. package/board/public/ui.js +23 -11
  48. package/board/public/view-hub-settings.js +46 -30
  49. package/board/public/view-hub.js +64 -13
  50. package/board/public/view-notes.js +7 -4
  51. package/board/public/view-polling.js +3 -3
  52. package/board/public/view-questions.js +42 -22
  53. package/board/public/view-settings.js +90 -35
  54. package/board/review.mjs +1 -0
  55. package/board/server.mjs +12 -7
  56. package/board/store.mjs +14 -6
  57. package/board/tickets.mjs +3 -3
  58. package/board/worktree.mjs +14 -13
  59. package/docs/hub-session.md +3 -3
  60. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  61. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  62. package/hub/awake.mjs +1 -0
  63. package/hub/board-repo.mjs +1 -1
  64. package/hub/boards.mjs +4 -4
  65. package/hub/close-board.mjs +1 -1
  66. package/hub/duration.mjs +1 -0
  67. package/hub/github.mjs +4 -5
  68. package/hub/home.mjs +1 -1
  69. package/hub/hub.mjs +12 -10
  70. package/hub/linear.mjs +2 -2
  71. package/hub/new-board.mjs +2 -2
  72. package/hub/pr-report.mjs +1 -0
  73. package/hub/push.mjs +2 -2
  74. package/hub/readiness.mjs +2 -0
  75. package/hub/repos.mjs +3 -2
  76. package/hub/restart-board.mjs +1 -0
  77. package/hub/routes.mjs +1 -1
  78. package/hub/schedule.mjs +1 -1
  79. package/hub/sessions.mjs +2 -2
  80. package/hub/shell.mjs +2 -1
  81. package/hub/slack.mjs +2 -0
  82. package/hub/stream.mjs +12 -12
  83. package/hub/sw.js +1 -1
  84. package/hub/trust.mjs +2 -0
  85. package/hub/try.mjs +4 -3
  86. package/hub/upgrade.mjs +1 -0
  87. package/hub/usage.mjs +3 -3
  88. package/hub/views.mjs +1 -1
  89. package/package.json +1 -1
  90. package/shared/colors.css +7 -5
  91. package/shared/fake-gh.mjs +54 -0
  92. package/shared/service.mjs +16 -16
  93. package/shared/skills.mjs +2 -0
  94. package/shared/usage-gate.mjs +1 -1
  95. package/starter/public/claude.js +3 -2
  96. package/bin/web-ts.mjs +0 -2
@@ -1,8 +1,166 @@
1
- // Work cards on the board view: a card in a lane, its expanded detail, its action row with each button's confirmation, and opening it. Needs
1
+ // Work cards on the board view: a card in a lane, its expanded detail, its action row with each button's confirmation or
2
+ // Hold's sheet, and opening it. Needs
2
3
  // app-core.js and app-threads.js.
3
4
  BoardView.part((B) => {
4
5
  const { $, store, state, markSeen, seen, toast, links } = B;
5
- const { esc, REVIEW, reviewAge, unreadCount, workLines, linkText, mergeButton, installButton, tryButton, tryWords, BUTTON, LINK_ARROW } = BoardUI;
6
+ const { esc, ago, REVIEW, reviewAge, unreadCount, workLines, heldUntilWords, holdPicker, linkText, mergeButton, buildButton, installButton, tryButton, tryWords, BUTTON, LINK_ARROW } = BoardUI;
7
+
8
+ const mainSheet = $('#mainLogSheet');
9
+ const mainRetry = $('#mainLogRetry');
10
+ const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
11
+ let postingMain = false;
12
+ let followMainAfter = null;
13
+ let mainState = null;
14
+ let logRequest = 0;
15
+ function mainReason(text = '') {
16
+ $('#mainLogReason').textContent = text;
17
+ $('#mainLogReason').hidden = !text;
18
+ }
19
+ function closeMainLog() {
20
+ if (!mainSheet.open || mainSheet.classList.contains('closing')) return;
21
+ logRequest++;
22
+ if (reduceMotion.matches) { mainSheet.close(); return; }
23
+ mainSheet.classList.add('closing');
24
+ B.later(180, () => { mainSheet.classList.remove('closing'); mainSheet.close(); });
25
+ }
26
+ async function loadMainLog() {
27
+ const request = ++logRequest;
28
+ const pre = $('#mainLogLines');
29
+ try {
30
+ const response = await fetch(B.site.url('api/try/main/log'), { cache: 'no-store' });
31
+ const out = await response.json();
32
+ if (!response.ok) throw new Error(out.error || 'Could not read main’s log.');
33
+ if (!B.alive() || request !== logRequest || !mainSheet.open) return;
34
+ pre.innerHTML = out.lines.length ? out.lines.map((line) => esc(line)).join('\n') : 'No log was written.';
35
+ pre.scrollTop = pre.scrollHeight;
36
+ } catch (error) {
37
+ if (B.alive() && request === logRequest && mainSheet.open) mainReason(error.message || 'Could not read main’s log.');
38
+ }
39
+ }
40
+ function renderMainLog(main, load = null) {
41
+ if (!mainSheet.open || mainSheet.classList.contains('closing') || !main) return;
42
+ const words = tryWords(main.kind);
43
+ $('#mainLogTitle').textContent = `Main’s ${words.failed.toLowerCase()}`;
44
+ $('#mainLogSource').innerHTML = `<code>.kickflip/build</code> stopped at main@<code>${esc(main.sha)}</code> · last lines of <code>data/try/main.log</code>`;
45
+ // Only a load begun after the POST resolved can settle this retry.
46
+ const fresh = !postingMain && load !== null && (followMainAfter === null || load >= followMainAfter);
47
+ if (fresh) {
48
+ const followed = followMainAfter !== null || mainState === 'running';
49
+ if (main.state === 'ready' && followed) {
50
+ followMainAfter = null;
51
+ closeMainLog();
52
+ toast(`Main is up again at ${main.sha}.`);
53
+ } else if (main.state === 'failed' && followed) {
54
+ followMainAfter = null;
55
+ mainReason();
56
+ loadMainLog();
57
+ }
58
+ mainState = main.state;
59
+ }
60
+ const busy = postingMain || followMainAfter !== null || main.state === 'running';
61
+ mainRetry.disabled = busy;
62
+ mainRetry.setAttribute('aria-disabled', String(busy));
63
+ mainRetry.classList.toggle('main-log-going', busy);
64
+ mainRetry.innerHTML = busy ? `<span class="try-spin" aria-hidden="true"></span>${esc(words.going)}` : 'Retry';
65
+ }
66
+ B.renderMainLog = renderMainLog;
67
+ B.on(B.root, 'click', (ev) => {
68
+ if (!ev.target.closest('[data-main-log]') || !state.board?.main || mainSheet.open) return;
69
+ ev.preventDefault();
70
+ mainReason();
71
+ if (!postingMain) followMainAfter = null;
72
+ mainState = state.board.main.state;
73
+ mainSheet.classList.remove('closing');
74
+ mainSheet.showModal();
75
+ $('#mainLogLines').textContent = 'Loading…';
76
+ renderMainLog(state.board.main);
77
+ loadMainLog();
78
+ });
79
+ B.on($('#mainLogClose'), 'click', closeMainLog);
80
+ B.on(mainSheet, 'click', (ev) => { if (ev.target === mainSheet) closeMainLog(); });
81
+ B.on(mainSheet, 'cancel', (ev) => { ev.preventDefault(); closeMainLog(); });
82
+ B.on(mainRetry, 'click', async () => {
83
+ if (mainRetry.disabled) return;
84
+ postingMain = true;
85
+ mainReason();
86
+ renderMainLog(state.board.main);
87
+ try {
88
+ const response = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: 'main' }) });
89
+ const out = await response.json();
90
+ if (!response.ok) throw new Error(out.error || 'The hub refused the build.');
91
+ if (!B.alive()) return;
92
+ followMainAfter = B.boardLoad + 1;
93
+ } catch (error) {
94
+ if (B.alive()) mainReason(error.message || 'Could not start. Is the host awake?');
95
+ } finally { postingMain = false; }
96
+ if (B.alive()) {
97
+ renderMainLog(state.board.main);
98
+ B.reload();
99
+ }
100
+ });
101
+
102
+ // Hold's sheet: the shared duration picker in place of a confirm, since a chip is already a deliberate tap
103
+ // (docs/adr/0118-a-card-can-be-held-until-a-time.md). A chip or Custom sends card-hold {key, hold: true, for}.
104
+ const holdSheet = $('#holdSheet');
105
+ let holdPicking = null;
106
+ const localInput = (d) => {
107
+ const pad = (n) => String(n).padStart(2, '0');
108
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
109
+ };
110
+ function closeHold() {
111
+ if (!holdSheet.open || holdSheet.classList.contains('closing')) return;
112
+ holdPicking = null;
113
+ if (reduceMotion.matches) { holdSheet.close(); return; }
114
+ holdSheet.classList.add('closing');
115
+ B.later(180, () => { holdSheet.classList.remove('closing'); holdSheet.close(); });
116
+ }
117
+ function openHold(it) {
118
+ $('#holdTitle').textContent = `Hold ${it.ref}`;
119
+ $('#holdLede').textContent = `${it.lane === 'todo' ? 'It won’t start' : 'Claude stops work on it'} until then. Forever holds it until you unhold it.`;
120
+ $('#holdPicker').innerHTML = holdPicker(it.key, state.board.holdChoices || []);
121
+ const input = $('#holdPicker [data-hold-at]');
122
+ const now = Date.now();
123
+ input.min = localInput(new Date(now + 60_000));
124
+ input.max = localInput(new Date(now + 30 * 86_400_000));
125
+ input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
126
+ holdSheet.classList.remove('closing');
127
+ holdSheet.showModal();
128
+ $('#holdTitle').focus({ preventScroll: true });
129
+ }
130
+ async function hold(key, choice) {
131
+ closeHold();
132
+ try { await B.sentHere(key, () => B.send('card-hold', { key, hold: true, for: choice })); }
133
+ catch { toast('Could not hold. Is the host awake?'); }
134
+ }
135
+ B.on($('#holdClose'), 'click', closeHold);
136
+ B.on(holdSheet, 'click', (ev) => {
137
+ if (ev.target === holdSheet) { closeHold(); return; }
138
+ const chip = ev.target.closest('[data-hold-for]');
139
+ if (chip) { hold(chip.dataset.holdFor, chip.value); return; }
140
+ const input = ev.target.closest('[data-hold-at]');
141
+ if (!input) return;
142
+ holdPicking = input;
143
+ try { input.showPicker(); } catch { /* iOS opens its wheel on the tap itself. */ }
144
+ });
145
+ B.on(holdSheet, 'cancel', (ev) => { ev.preventDefault(); closeHold(); });
146
+ // A chip's pointerdown comes before Custom's focusout; without this reset Custom sends first and the chip's is lost.
147
+ B.on(holdSheet, 'pointerdown', (ev) => {
148
+ if (!ev.target.closest('[data-hold-for]')) return;
149
+ const input = $('#holdPicker [data-hold-at]');
150
+ holdPicking = null;
151
+ if (input) input.value = input.defaultValue;
152
+ });
153
+ // Closing an opened picker accepts its suggested time; an unchanged field reached by Tab sends nothing.
154
+ function holdCustom(input) {
155
+ if (!input.value || (input !== holdPicking && input.value === input.defaultValue) || !input.checkValidity()) return;
156
+ const at = new Date(input.value);
157
+ if (Number.isNaN(at.getTime())) return;
158
+ holdPicking = null;
159
+ input.defaultValue = input.value;
160
+ hold(input.dataset.holdAt, at.toISOString());
161
+ }
162
+ B.on(holdSheet, 'change', (ev) => { if (ev.target.matches?.('[data-hold-at]') && document.activeElement !== ev.target) holdCustom(ev.target); });
163
+ B.on(holdSheet, 'focusout', (ev) => { if (ev.target.matches?.('[data-hold-at]')) holdCustom(ev.target); });
6
164
 
7
165
  function detail(it) {
8
166
  if (it.kind === 'ask') {
@@ -22,17 +180,22 @@ BoardView.part((B) => {
22
180
  }
23
181
  }
24
182
  if (it.branch) rows.push(['Branch', `<code>${esc(it.branch)}</code>`]);
25
- if (it.status) rows.push(['Status', esc(it.status)]);
26
- if (it.deps.length) rows.push(['Waits on', esc(it.deps.join(', '))]);
183
+ // building is an implementer at work, since Build means the package (docs/adr/0166-a-todo-cards-button-reads-implement.md).
184
+ if (it.status) rows.push(['Status', esc(it.status === 'building' ? 'implementing' : it.status)]);
185
+ // docs/adr/0156-a-follow-up-is-a-work-card-with-a-parent.md: each link row is a row of labels that open their cards.
186
+ 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]]) {
187
+ if (list?.length) rows.push([label, list.map(cardLink).join(' ')]);
188
+ }
27
189
  if (it.manualSteps.length) rows.push(['Manual steps', linkText(it.manualSteps.join('; '), ctx)]);
28
- if (it.slack) rows.push(['Slack', `<a href="${esc(it.slack)}" target="_blank" rel="noopener">thread</a>`]);
29
- if (it.from) rows.push(['Filed from', esc(it.from)]);
30
190
  if (it.next) rows.push(['Next', linkText(it.next, ctx)]);
31
191
  // docs/adr/0062-notes-is-a-link-in-the-details-list.md
32
192
  const external = [
33
193
  it.hasNotes ? `<a href="notes.html?item=${encodeURIComponent(it.key)}">Notes ${LINK_ARROW}</a>` : '',
194
+ // A vscode: link opens a folder on the machine whose browser follows it, so only the host's browser gets one.
195
+ 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>` : '',
34
196
  installButton(it),
35
197
  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>` : '',
198
+ it.pushed ? pushedLink(it) : '',
36
199
  ].filter(Boolean);
37
200
  const externalRow = external.length ? `<dt class="external-links-label">Links</dt><dd class="external-links">${external.map((html, i) => `<span class="external-link">${i ? '<span class="external-separator" aria-hidden="true"> · </span>' : ''}${html}</span>`).join('')}</dd>` : '';
38
201
 
@@ -43,20 +206,35 @@ BoardView.part((B) => {
43
206
  </div>`;
44
207
  }
45
208
 
46
- // docs/adr/0023-a-card-can-be-held-and-every-card-button-confirms.md
47
- const sendingHold = new Map();
209
+ const cardLink = (l) => `<a class="card-link" href="#item=${encodeURIComponent(l.key)}">${esc(l.ref)}</a>`;
210
+ // A card whose branch is pushed to a plain git remote has no PR to link, so its line copies the branch name
211
+ // (docs/adr/0127-a-board-can-work-on-any-git-remote.md).
212
+ const pushedLink = (it) => `<a href="#" data-copy-branch="${esc(it.pushed.branch)}" title="Copy the branch name">${esc(pushedWords(it))}</a>`;
213
+ const pushedWords = (it) => [`Pushed ${it.ref}${it.pushed.host ? ` to ${it.pushed.host}` : ''}`, it.pushed.at && ago(it.pushed.at)].filter(Boolean).join(' · ');
214
+ B.on(B.root, 'click', (ev) => {
215
+ const link = ev.target.closest('[data-copy-branch]');
216
+ if (!link) return;
217
+ ev.preventDefault();
218
+ navigator.clipboard?.writeText(link.dataset.copyBranch).catch(() => {});
219
+ link.textContent = 'Copied the branch name';
220
+ B.later(1600, () => {
221
+ const it = state.board?.items.find((i) => i.pushed?.branch === link.dataset.copyBranch);
222
+ if (link.isConnected && it) link.textContent = pushedWords(it);
223
+ });
224
+ });
225
+
48
226
  const sendingMerge = new Set();
49
- const refFor = (it) => (it.kind !== 'work' || it.internal ? it.ref : it.linear?.[0]?.id || (it.pr ? `#${it.pr.number}` : it.ref));
227
+ const sendingBuild = new Set();
50
228
  const CONFIRM = {
51
- hold: (it, ref) => `Hold ${ref}? ${it.lane === 'todo' ? "It won't start" : 'Claude stops work on it'} until you resume it.`,
52
- resume: (it, ref) => `Resume ${ref}? It can start again once nothing blocks it.`,
53
- bump: (it, ref) => `Move ${ref} to the top of its lane?`,
54
- nudge: () => 'Nudge Claude to check this builder?',
229
+ resume: (it, ref) => `Unhold ${ref}?${it.heldUntil ? ` It's held until ${heldUntilWords(it.heldUntil)}.` : ''} It can start again once nothing blocks it.`,
230
+ nudge: () => 'Nudge Claude to check this implementer?',
55
231
  grill: (it, ref) => `Ask Claude for questions about ${ref}?`,
56
232
  merge: (it, ref) => `Merge ${ref}? Claude squash-merges #${it.pr?.number} if it is still approved, green and clean.`,
57
- build: (it, ref) => `Build ${ref}? Claude replies with a short plan, then builds it as a draft.`,
233
+ 'card-build': (it, ref) => (it.blockedBy?.length
234
+ ? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done? It waits in In Progress until then.`
235
+ : `${it.held ? `Unhold and implement ${ref}?` : `Implement ${ref}?`} Claude starts its implementer now with the notes as they stand.`),
58
236
  finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
59
- : `Close ${ref}? It moves to Done and Claude stops work on it.${it.pr?.state === 'OPEN' ? ' Its PR stays open on GitHub.' : ''}`),
237
+ : `Close ${ref}? It moves to Done and Claude stops work on it.${it.pr?.state === 'OPEN' ? ' Its PR stays open on GitHub.' : it.pushed ? ` Its branch stays on ${it.pushed.host || 'the remote'}.` : ''}`),
60
238
  };
61
239
 
62
240
  const classes = (it, button, base = '') => {
@@ -67,13 +245,10 @@ BoardView.part((B) => {
67
245
 
68
246
  function actions(it) {
69
247
  const key = esc(it.key);
70
- const ask = (act, label) => `<button data-confirm="${act}" data-key="${key}">${label}</button>`;
71
- const holding = sendingHold.has(it.key) ? sendingHold.get(it.key) : it.holdPending;
248
+ const ask = (act, label, cls = '') => `<button${cls ? ` class="${cls}"` : ''} data-confirm="${act}" data-key="${key}">${label}</button>`;
72
249
  const hold = it.kind !== 'work' || !it.holdable ? ''
73
- : holding != null ? `<button disabled>${holding ? 'Holding…' : 'Resuming…'}</button>`
74
- : it.held ? ask('resume', 'Resume') : ask('hold', 'Hold');
75
- const build = it.kind === 'followup' && it.lane !== 'done' ? `<button class="build" data-confirm="build" data-key="${key}">Build</button>` : '';
76
- return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${build}${mergeButton(it, sendingMerge.has(it.key))}${hold}${actionButton(it)}${tryButton(it)}`)}</div>`;
250
+ : it.held ? (it.buildable ? '' : ask('resume', 'Unhold', 'unhold')) : ask('hold', 'Hold');
251
+ return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${mergeButton(it, sendingMerge.has(it.key))}${hold}${actionButton(it)}${tryButton(it)}${buildButton(it, sendingBuild.has(it.key))}`)}</div>`;
77
252
  }
78
253
 
79
254
  // board/domain.mjs actionButtonOf picks the button and resolvedBy its colour (docs/adr/0059-an-unstarted-card-waits-on-its-questions-in-todo.md).
@@ -84,9 +259,8 @@ BoardView.part((B) => {
84
259
  };
85
260
  const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
86
261
 
87
- // The latest bump takes no second bump: among New cards for a New card (docs/adr/0014-a-pending-ask-can-be-deleted-or-bumped.md),
88
- // in its lane for any other card (docs/adr/0019-a-card-that-hasnt-started-can-be-bumped.md).
89
- const latestBump = (it) => Boolean(it.bumpedAt) && !state.board.items.some((i) => (it.isNew ? i.isNew : i.lane === it.lane) && (i.bumpedAt || '') > it.bumpedAt);
262
+ // The latest bump among New cards takes no second bump (docs/adr/0014-a-pending-ask-can-be-deleted-or-bumped.md).
263
+ const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
90
264
  function card(it) {
91
265
  return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
92
266
  <summary class="work">
@@ -105,10 +279,10 @@ BoardView.part((B) => {
105
279
  state.openItems.add(key);
106
280
  const it = state.board.items.find((i) => i.key === key);
107
281
  markSeen(it);
108
- const unread = el.querySelector('summary .chip.unread');
109
- const row = unread?.parentNode;
282
+ const unread = el.querySelector('summary .card-unread');
283
+ const line = unread?.parentNode;
110
284
  unread?.remove();
111
- if (row && !row.children.length) row.remove();
285
+ if (line && !line.children.length) line.remove();
112
286
  if (it && !el.querySelector('.detail')) { el.insertAdjacentHTML('beforeend', detail(it)); }
113
287
  if (state.threadCache.has(key)) B.renderThread(key, state.threadCache.get(key));
114
288
  B.loadThread(key, { maxAge: 10000 });
@@ -145,7 +319,23 @@ BoardView.part((B) => {
145
319
  const out = await r.json();
146
320
  if (!r.ok) throw new Error(out.error || 'The hub refused the build.');
147
321
  } catch (error) { toast(error.message || 'Could not start. Is the host awake?'); }
148
- if (B.alive()) B.loadBoard();
322
+ B.reload();
323
+ });
324
+
325
+ // A work card's bump icon bumps it, or unbumps a bumped one, with no confirm
326
+ // (docs/adr/0145-build-on-a-blocked-card-queues-it.md), and the card moves at once
327
+ // (docs/adr/0151-every-owner-tap-the-server-can-apply-draws-at-once.md).
328
+ B.on(B.root, 'click', async (ev) => {
329
+ const b = ev.target.closest('button[data-card-bump]');
330
+ if (!b) return;
331
+ ev.preventDefault();
332
+ const key = b.dataset.cardBump;
333
+ const unbump = b.hasAttribute('data-bumped');
334
+ const icon = `button[data-card-bump="${CSS.escape(key)}"]`;
335
+ const sending = B.sentHere(key, () => B.send(unbump ? 'card-unbump' : 'card-bump', { key }, B.drawItem(key, (it) => ({ ...it, bumpedAt: unbump ? null : new Date().toISOString() }))));
336
+ B.fadeBump(icon, !unbump);
337
+ try { await sending; toast(unbump ? 'Unbumped' : 'Moved to the top'); }
338
+ catch { B.fadeBump(icon, unbump); toast(B.FAILED); }
149
339
  });
150
340
 
151
341
  B.on(B.root, 'click', async (ev) => {
@@ -154,34 +344,39 @@ BoardView.part((B) => {
154
344
  ev.preventDefault();
155
345
  const { key, confirm: act } = b.dataset;
156
346
  const it = state.board.items.find((i) => i.key === key);
157
- if (!it || !(await B.confirm(CONFIRM[act](it, refFor(it))))) return;
158
- if (act === 'hold' || act === 'resume') {
159
- sendingHold.set(key, act === 'hold');
160
- redrawActions(key);
161
- try { await B.site.send('card-hold', { key, hold: act === 'hold' }); }
162
- catch { toast(`Could not ${act}. Is the host awake?`); }
163
- sendingHold.delete(key);
164
- B.loadBoard();
347
+ if (it && act === 'hold') { openHold(it); return; }
348
+ if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
349
+ if (act === 'resume') {
350
+ try { await B.sentHere(key, () => B.send('card-hold', { key, hold: false })); }
351
+ catch { toast('Could not resume. Is the host awake?'); }
165
352
  return;
166
353
  }
167
354
  // docs/adr/0052-auto-merge-is-a-board-setting-off-by-default.md
168
355
  if (act === 'merge') {
169
356
  sendingMerge.add(key);
170
357
  redrawActions(key);
171
- try { await B.site.send('card-merge', { key }); }
358
+ try { await B.sentHere(key, () => B.send('card-merge', { key })); }
172
359
  catch { toast('Could not send. Is the host awake?'); }
173
360
  sendingMerge.delete(key);
174
- B.loadBoard();
361
+ redrawActions(key);
175
362
  return;
176
363
  }
177
- // docs/adr/0035-an-ask-can-wait-in-progress-until-the-owner-marks-it-done.md
364
+ if (act === 'card-build') {
365
+ sendingBuild.add(key);
366
+ redrawActions(key);
367
+ try { await B.sentHere(key, () => B.send('card-build', { key })); }
368
+ catch { toast('Could not send. Is the host awake?'); }
369
+ sendingBuild.delete(key);
370
+ redrawActions(key);
371
+ return;
372
+ }
373
+ // docs/adr/0106-the-board-applies-the-owners-hold-resume-and-done-at-once.md
374
+ // The card closes first, or app-core.js steady would anchor it and scroll the page after it into Done.
178
375
  if (act === 'finish') {
179
- B.sendingDone.add(key);
180
- B.syncDone(key);
181
- try { await (it.kind === 'ask' ? B.site.send('ask-done', { id: it.id }) : B.site.send('card-done', { key })); }
376
+ const card = $(`details.item[data-key="${CSS.escape(key)}"]`);
377
+ if (card) card.open = false;
378
+ try { await B.sentHere(key, () => (it.kind === 'ask' ? B.send('ask-done', { id: it.id }) : B.send('card-done', { key }))); }
182
379
  catch { toast('Could not close. Is the host awake?'); }
183
- B.sendingDone.delete(key);
184
- await B.loadBoard();
185
380
  B.syncDone(key);
186
381
  return;
187
382
  }
@@ -189,11 +384,9 @@ BoardView.part((B) => {
189
384
  row?.querySelectorAll('button').forEach((x) => { x.disabled = true; });
190
385
  b.disabled = true;
191
386
  try {
192
- if (act === 'bump') { await B.site.send('card-bump', { key }); toast('Moved to the top'); }
193
- else if (act === 'nudge') { await B.site.send('builder-nudge', { key }); b.textContent = 'Sent'; toast('Nudge sent'); }
194
- else { await B.site.send(act === 'grill' ? 'grill-more' : 'build-followup', { key }); B.loadThread(key, { force: true }); }
195
- } catch { toast('Could not send. Is the host awake?'); }
196
- B.loadBoard();
387
+ if (act === 'nudge') { await B.sentHere(key, () => B.send('builder-nudge', { key })); b.textContent = 'Sent'; toast('Nudge sent'); }
388
+ else { await B.sentHere(key, () => B.send('grill-more', { key })); B.loadThread(key, { force: true }); }
389
+ } catch { redrawActions(key); toast('Could not send. Is the host awake?'); }
197
390
  });
198
391
 
199
392
  Object.assign(B, { detail, card });
@@ -6,12 +6,16 @@ BoardView.part((B) => {
6
6
  const a = document.activeElement;
7
7
  return a && B.root.contains(a) && a.matches('textarea, input') ? a : null;
8
8
  };
9
- // → a function finding field after a repaint: the same node, else the box drawn anew in its place (an edit box from
10
- // state.edits, a reply box from its draft), found by its form.
11
- function finder(field) {
9
+ // → the selector of field's box as drawn anew (an edit box from state.edits, a reply box from its draft), by its form;
10
+ // null for any other field.
11
+ function boxOf(field) {
12
12
  const form = field.closest('form[data-reply], form[data-edit-form]');
13
- const again = !form ? null : form.dataset.reply != null ? `form[data-reply="${CSS.escape(form.dataset.reply)}"] textarea`
13
+ return !form ? null : form.dataset.reply != null ? `form[data-reply="${CSS.escape(form.dataset.reply)}"] textarea`
14
14
  : `form[data-edit-form="${CSS.escape(form.dataset.editForm)}"] textarea`;
15
+ }
16
+ // → a function finding field after a repaint: the same node, else the box drawn anew in its place.
17
+ function finder(field) {
18
+ const again = boxOf(field);
15
19
  return () => (field.isConnected ? field : again && $(again));
16
20
  }
17
21
 
@@ -50,6 +54,36 @@ BoardView.part((B) => {
50
54
  const after = before == null ? null : find()?.getBoundingClientRect().top;
51
55
  if (after != null && after !== before) scrollBy(0, after - before);
52
56
  }
57
+ // Where the owner left the board, kept for its next mount in this page: the page's scroll and the box they were typing
58
+ // in, as it unmounts or at their last press outside the board, such as on a sidebar row, which takes the focus first.
59
+ // The header's logo, which opens the sidebar, counts as outside.
60
+ let held = null;
61
+ B.on(document, 'pointerdown', (e) => {
62
+ const outside = !B.root.contains(e.target) || e.target.closest?.('.top a.hub');
63
+ held = outside ? focusedField() || held : null;
64
+ }, true);
65
+ // A board left before its first render keeps the place it hadn't returned to yet.
66
+ let place = B.place;
67
+ B.keep(() => {
68
+ if (place) return { y: place.y, box: place.box };
69
+ const field = focusedField() || (held?.isConnected ? held : null);
70
+ const selector = field && boxOf(field);
71
+ return { y: scrollY, box: selector ? { selector, selection: [field.selectionStart, field.selectionEnd, field.selectionDirection], top: field.scrollTop } : null };
72
+ });
73
+ // Only the first call restores, and none when the URL names a card (linked). A phone's keyboard would open for the
74
+ // focus, so only a mouse or trackpad gets the box back (sidebar.js DESKTOP).
75
+ function restorePlace({ linked = false } = {}) {
76
+ const p = place;
77
+ place = null;
78
+ if (!p || linked) return;
79
+ scrollTo(0, p.y);
80
+ const box = p.box && $(p.box.selector);
81
+ if (!box || box.closest('details:not([open])') || !matchMedia('(hover: hover) and (pointer: fine)').matches) return;
82
+ box.focus({ preventScroll: true });
83
+ box.setSelectionRange(...p.box.selection);
84
+ box.scrollTop = p.box.top;
85
+ }
86
+
53
87
  const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
54
88
 
55
89
  // A card this browser has never seen starts from now, so a first load badges nothing.
@@ -92,5 +126,5 @@ BoardView.part((B) => {
92
126
  const confirm = (message, labels) => dialogs.confirm(message, { ...labels, signal: controller.signal });
93
127
  const notice = dialogs.notice;
94
128
 
95
- Object.assign(B, { steady, plural, seen, markSeen, quieted, sendDraft, hide, links, svg, confirm, notice });
129
+ Object.assign(B, { steady, restorePlace, plural, seen, markSeen, quieted, sendDraft, hide, links, svg, confirm, notice });
96
130
  });
@@ -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
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.
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
+ // and the wake: any tap on an asleep board's page, refresh included, asks the hub to resume its session
5
+ // (docs/adr/0147-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,15 @@ 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
+ const renderStatus = () => { $('#status').innerHTML = statusLine(state.fresh?.at, { link: state.link, watcher: state.board?.checks?.watcher, pause: B.pause?.(), waking: state.waking }); };
19
20
  // A board load and its open threads landed; via is ui-header.js nextFresh's.
20
21
  function landed(via) {
21
22
  state.fresh = nextFresh(state.fresh, { via, at: new Date().toISOString(), sig: freshSig(state.board, state.threadCache, state.openItems) });
22
23
  renderStatus();
23
24
  }
24
25
  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>`;
26
+ const githubHeader = (d) => (d.remote ? `<span class="lane-github">${remoteLine(d.remote, { links: d.links })}</span>`
27
+ : `<span class="lane-github">${githubLine(d.checks, { pending: Boolean(state.githubPending), links: d.links })}${githubRefresh(d.checks, state.githubPending)}</span>`);
26
28
 
27
29
  // The Hub links sheet: the GitHub mark opens it, from inside the lane's summary, so the tap must not fold the lane.
28
30
  const linksSheet = $('#linksSheet');
@@ -47,7 +49,7 @@ BoardView.part((B) => {
47
49
  function askGitHub() {
48
50
  state.githubPending = Date.now();
49
51
  B.render();
50
- B.site.send('check-github', {}).catch(() => { state.githubPending = null; B.render(); toast('Could not ask Claude to check GitHub'); });
52
+ B.send('check-github', {}).catch(() => { state.githubPending = null; B.render(); toast('Could not ask Claude to check GitHub'); });
51
53
  }
52
54
  B.on(B.root, 'click', (ev) => {
53
55
  const b = ev.target.closest('[data-check-github]');
@@ -56,6 +58,22 @@ BoardView.part((B) => {
56
58
  if (state.link === 'online' && state.board && !state.githubPending) askGitHub();
57
59
  });
58
60
 
61
+ // The wake: the header reads waking… from the tap until the board's reload says the hub took it up.
62
+ state.waking = false;
63
+ async function wake() {
64
+ state.waking = true;
65
+ renderStatus();
66
+ try {
67
+ const r = await fetch('/_hub/wake', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ route: B.route }) });
68
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `the hub answered ${r.status}`);
69
+ await B.loadBoard();
70
+ } catch (e) { toast(`Couldn't wake this board's session: ${e.message}`); }
71
+ finally { state.waking = false; if (B.alive()) renderStatus(); }
72
+ }
73
+ const wakeOnTap = () => { if (state.board?.checks?.watcher === 'asleep' && !state.waking && state.link === 'online') wake(); };
74
+ B.on(B.root, 'click', wakeOnTap, true);
75
+ B.on(B.root, 'submit', wakeOnTap, true);
76
+
59
77
  state.link = 'online';
60
78
  const controls = { wired: false, boardSettled: false, reloading: false };
61
79
  function applyControls() {