@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.
- package/CONTEXT.md +177 -78
- package/README.md +46 -5
- package/SKILL.md +58 -37
- package/bin/doctor.mjs +4 -3
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +87 -32
- package/bin/statusline.mjs +4 -3
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +5 -0
- package/board/bin/agent.mjs +1 -1
- package/board/bin/board.mjs +241 -69
- package/board/bin/builder-stop-hook.mjs +4 -3
- package/board/domain.mjs +5 -3
- package/board/on-host.mjs +1 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +33 -14
- package/board/public/app-cards.js +242 -49
- package/board/public/app-core.js +39 -5
- package/board/public/app-header.js +24 -6
- package/board/public/app-lanes.js +76 -26
- package/board/public/app-needs.js +1 -0
- package/board/public/app-snapshot.js +45 -11
- package/board/public/app-threads.js +13 -11
- package/board/public/app.html +4 -1
- package/board/public/app.js +131 -20
- package/board/public/dialogs.js +114 -116
- package/board/public/hub-kit.js +19 -2
- package/board/public/hub.css +24 -18
- package/board/public/linear-key.js +141 -0
- package/board/public/router.js +6 -3
- package/board/public/rules-editor.js +1 -1
- package/board/public/shell.js +1 -1
- package/board/public/sidebar.css +31 -11
- package/board/public/sidebar.js +88 -16
- package/board/public/style.css +141 -46
- package/board/public/toasts.js +6 -4
- package/board/public/ui-cards.js +102 -37
- package/board/public/ui-header.js +17 -8
- package/board/public/ui-hub.js +30 -12
- package/board/public/ui-links.js +18 -10
- package/board/public/ui-order.js +18 -0
- package/board/public/ui-page-header.js +4 -2
- package/board/public/ui-questions.js +37 -16
- package/board/public/ui-sidebar.js +49 -11
- package/board/public/ui-snapshot.js +15 -9
- package/board/public/ui-threads.js +25 -16
- package/board/public/ui.js +23 -11
- package/board/public/view-hub-settings.js +46 -30
- package/board/public/view-hub.js +64 -13
- package/board/public/view-notes.js +7 -4
- package/board/public/view-polling.js +3 -3
- package/board/public/view-questions.js +42 -22
- package/board/public/view-settings.js +90 -35
- package/board/review.mjs +1 -0
- package/board/server.mjs +12 -7
- package/board/store.mjs +14 -6
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +14 -13
- package/docs/hub-session.md +3 -3
- package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/duration.mjs +1 -0
- package/hub/github.mjs +4 -5
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -10
- package/hub/linear.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/pr-report.mjs +1 -0
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -2
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/shell.mjs +2 -1
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +12 -12
- package/hub/sw.js +1 -1
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +4 -3
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/colors.css +7 -5
- package/shared/fake-gh.mjs +54 -0
- package/shared/service.mjs +16 -16
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -1
- package/starter/public/claude.js +3 -2
- 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
|
|
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
|
-
|
|
26
|
-
if (it.
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
227
|
+
const sendingBuild = new Set();
|
|
50
228
|
const CONFIRM = {
|
|
51
|
-
|
|
52
|
-
|
|
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) =>
|
|
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
|
-
:
|
|
74
|
-
|
|
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
|
|
88
|
-
|
|
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 .
|
|
109
|
-
const
|
|
282
|
+
const unread = el.querySelector('summary .card-unread');
|
|
283
|
+
const line = unread?.parentNode;
|
|
110
284
|
unread?.remove();
|
|
111
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
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
|
-
|
|
361
|
+
redrawActions(key);
|
|
175
362
|
return;
|
|
176
363
|
}
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
try { await (it.kind === 'ask' ? B.
|
|
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 === '
|
|
193
|
-
else
|
|
194
|
-
|
|
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 });
|
package/board/public/app-core.js
CHANGED
|
@@ -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
|
-
// →
|
|
10
|
-
//
|
|
11
|
-
function
|
|
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
|
-
|
|
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
|
-
//
|
|
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">${
|
|
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.
|
|
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() {
|