@lovek4d/kickflip 0.0.0-stage → 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 +513 -0
- package/LICENSE +57 -0
- package/README.md +64 -2
- package/SKILL.md +142 -0
- package/bin/doctor.mjs +4 -0
- package/bin/install.mjs +3 -0
- package/bin/kickflip.mjs +180 -0
- package/bin/statusline.mjs +5 -0
- package/bin/wait-events.mjs +3 -0
- package/board/arch-review.mjs +5 -0
- package/board/bin/agent.mjs +2 -0
- package/board/bin/board.mjs +379 -0
- package/board/bin/builder-stop-hook.mjs +6 -0
- package/board/build-script.mjs +1 -0
- package/board/domain.mjs +5 -0
- package/board/on-host.mjs +1 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +188 -0
- package/board/public/app-banner.js +48 -0
- package/board/public/app-cards.js +393 -0
- package/board/public/app-core.js +130 -0
- package/board/public/app-header.js +126 -0
- package/board/public/app-lanes.js +142 -0
- package/board/public/app-needs.js +31 -0
- package/board/public/app-snapshot.js +131 -0
- package/board/public/app-threads.js +167 -0
- package/board/public/app.html +86 -0
- package/board/public/app.js +438 -0
- package/board/public/attach.js +84 -0
- package/board/public/dialogs.js +358 -0
- package/board/public/hub-kit.js +168 -0
- package/board/public/hub.css +170 -0
- package/board/public/kickflip.png +0 -0
- package/board/public/linear-key.js +141 -0
- package/board/public/prompts.js +64 -0
- package/board/public/prototype-kit.js +193 -0
- package/board/public/prototype-switcher.js +226 -0
- package/board/public/prototype.html +55 -0
- package/board/public/router.js +226 -0
- package/board/public/rules-editor.js +127 -0
- package/board/public/shell.js +298 -0
- package/board/public/sidebar.css +219 -0
- package/board/public/sidebar.js +359 -0
- package/board/public/style.css +665 -0
- package/board/public/toasts.js +181 -0
- package/board/public/ui-cards.js +218 -0
- package/board/public/ui-enums.js +10 -0
- package/board/public/ui-header.js +93 -0
- package/board/public/ui-hub.js +112 -0
- package/board/public/ui-links.js +150 -0
- package/board/public/ui-markdown.js +80 -0
- package/board/public/ui-order.js +18 -0
- package/board/public/ui-page-header.js +65 -0
- package/board/public/ui-prompts.js +39 -0
- package/board/public/ui-questions.js +151 -0
- package/board/public/ui-sidebar.js +215 -0
- package/board/public/ui-snapshot.js +62 -0
- package/board/public/ui-threads.js +90 -0
- package/board/public/ui.js +247 -0
- package/board/public/view-frame.js +93 -0
- package/board/public/view-hub-settings.js +282 -0
- package/board/public/view-hub.js +150 -0
- package/board/public/view-notes.js +32 -0
- package/board/public/view-polling.js +87 -0
- package/board/public/view-questions.js +411 -0
- package/board/public/view-settings.js +383 -0
- package/board/review.mjs +1 -0
- package/board/server.mjs +17 -0
- package/board/store.mjs +15 -0
- package/board/tickets.mjs +3 -0
- package/board/worktree.mjs +15 -0
- package/docs/hub-session.md +11 -0
- package/hooks-plugin/.claude-plugin/plugin.json +5 -0
- package/hooks-plugin/hooks/codex-share-auto.mjs +23 -0
- package/hooks-plugin/hooks/hooks.json +54 -0
- package/hooks-plugin/hooks/usage-gate.mjs +18 -0
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -0
- package/hub/boards.mjs +4 -0
- package/hub/close-board.mjs +1 -0
- package/hub/duration.mjs +1 -0
- package/hub/files.mjs +1 -0
- package/hub/github.mjs +4 -0
- package/hub/home.mjs +1 -0
- package/hub/hub.mjs +19 -0
- package/hub/icon-180.png +0 -0
- package/hub/icon-192.png +0 -0
- package/hub/icon-512.png +0 -0
- package/hub/icons.mjs +1 -0
- package/hub/linear.mjs +2 -0
- package/hub/live.mjs +2 -0
- package/hub/locks.mjs +1 -0
- package/hub/new-board.mjs +2 -0
- package/hub/permission-hook.mjs +3 -0
- package/hub/pr-report.mjs +1 -0
- package/hub/presence.mjs +1 -0
- package/hub/prompts.mjs +2 -0
- package/hub/push.mjs +2 -0
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -0
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -0
- package/hub/rules.mjs +1 -0
- package/hub/schedule.mjs +1 -0
- package/hub/sessions.mjs +2 -0
- package/hub/shell.mjs +2 -0
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +19 -0
- package/hub/sw.js +144 -0
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +4 -0
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -0
- package/hub/views.mjs +1 -0
- package/package.json +26 -4
- package/shared/codex.mjs +1 -0
- package/shared/colors.css +15 -0
- package/shared/fake-gh.mjs +54 -0
- package/shared/repos.mjs +1 -0
- package/shared/service.mjs +33 -0
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -0
- package/starter/cross-site.mjs +1 -0
- package/starter/public/claude.js +68 -0
- package/starter/public/index.html +49 -0
- package/starter/server.mjs +4 -0
|
@@ -0,0 +1,393 @@
|
|
|
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
|
|
3
|
+
// app-core.js and app-threads.js.
|
|
4
|
+
BoardView.part((B) => {
|
|
5
|
+
const { $, store, state, markSeen, seen, toast, links } = B;
|
|
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); });
|
|
164
|
+
|
|
165
|
+
function detail(it) {
|
|
166
|
+
if (it.kind === 'ask') {
|
|
167
|
+
const button = actionButton(it);
|
|
168
|
+
return `<div class="detail">${button ? `<div class="actions" data-actions="${esc(it.key)}">${actionRow('workflow', button)}</div>` : ''}${B.thread(it.key)}</div>`;
|
|
169
|
+
}
|
|
170
|
+
const pr = it.pr;
|
|
171
|
+
const ctx = links(it.key);
|
|
172
|
+
const rows = [];
|
|
173
|
+
rows.push(['Wave', esc(it.waveTitle)]);
|
|
174
|
+
if (pr) {
|
|
175
|
+
const ci = pr.checks ? `${pr.checks.state}${pr.checks.fail ? ` (${pr.checks.fail} failing)` : pr.checks.pending ? ` (${pr.checks.pending} running)` : ''}` : 'unknown';
|
|
176
|
+
if (pr.state === 'OPEN') {
|
|
177
|
+
rows.push(['CI', esc(ci)]);
|
|
178
|
+
rows.push(['Merge', esc([pr.mergeStateStatus, pr.reviewDecision].filter(Boolean).join(' · ').toLowerCase() || 'unknown')]);
|
|
179
|
+
rows.push(['Review', (it.reviewers || []).map((r) => `${esc(r)} <span class="muted">${REVIEW[BoardUI.record(it.review, r)?.state || 'unsent'].label}${reviewAge(BoardUI.record(it.review, r)) ? `,${reviewAge(BoardUI.record(it.review, r))}` : ''}</span>`).join(' · ') || '—']);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (it.branch) rows.push(['Branch', `<code>${esc(it.branch)}</code>`]);
|
|
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
|
+
}
|
|
189
|
+
if (it.manualSteps.length) rows.push(['Manual steps', linkText(it.manualSteps.join('; '), ctx)]);
|
|
190
|
+
if (it.next) rows.push(['Next', linkText(it.next, ctx)]);
|
|
191
|
+
// docs/adr/0062-notes-is-a-link-in-the-details-list.md
|
|
192
|
+
const external = [
|
|
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>` : '',
|
|
196
|
+
installButton(it),
|
|
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) : '',
|
|
199
|
+
].filter(Boolean);
|
|
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>` : '';
|
|
201
|
+
|
|
202
|
+
return `<div class="detail">
|
|
203
|
+
<dl class="grid">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')}${externalRow}</dl>
|
|
204
|
+
${actions(it)}
|
|
205
|
+
${B.thread(it.key)}
|
|
206
|
+
</div>`;
|
|
207
|
+
}
|
|
208
|
+
|
|
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
|
+
|
|
226
|
+
const sendingMerge = new Set();
|
|
227
|
+
const sendingBuild = new Set();
|
|
228
|
+
const CONFIRM = {
|
|
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?',
|
|
231
|
+
grill: (it, ref) => `Ask Claude for questions about ${ref}?`,
|
|
232
|
+
merge: (it, ref) => `Merge ${ref}? Claude squash-merges #${it.pr?.number} if it is still approved, green and clean.`,
|
|
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.`),
|
|
236
|
+
finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
|
|
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'}.` : ''}`),
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const classes = (it, button, base = '') => {
|
|
241
|
+
const names = [base, it.resolvedBy === button && 'resolves'].filter(Boolean).join(' ');
|
|
242
|
+
return names ? ` class="${names}"` : '';
|
|
243
|
+
};
|
|
244
|
+
const actionRow = (kind, html) => (html.trim() ? `<div class="action-row ${kind}">${html}</div>` : '');
|
|
245
|
+
|
|
246
|
+
function actions(it) {
|
|
247
|
+
const key = esc(it.key);
|
|
248
|
+
const ask = (act, label, cls = '') => `<button${cls ? ` class="${cls}"` : ''} data-confirm="${act}" data-key="${key}">${label}</button>`;
|
|
249
|
+
const hold = it.kind !== 'work' || !it.holdable ? ''
|
|
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>`;
|
|
252
|
+
}
|
|
253
|
+
|
|
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).
|
|
255
|
+
const ACTION_BUTTONS = {
|
|
256
|
+
[BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Open questions</a>`,
|
|
257
|
+
[BUTTON.markDone]: (it) => `<button${classes(it, BUTTON.markDone)} data-confirm="finish" data-key="${esc(it.key)}">Mark as done</button>`,
|
|
258
|
+
[BUTTON.grill]: (it) => (it.grilling ? '<button disabled>Writing questions…</button>' : `<button data-confirm="grill" data-key="${esc(it.key)}">Grill</button>`),
|
|
259
|
+
};
|
|
260
|
+
const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
|
|
261
|
+
|
|
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);
|
|
264
|
+
function card(it) {
|
|
265
|
+
return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
|
|
266
|
+
<summary class="work">
|
|
267
|
+
<span class="status ${it.lane}"></span>
|
|
268
|
+
<div class="title-wrap">${workLines(it, { unread: state.openItems.has(it.key) ? 0 : unreadCount(it.othersAt, seen[it.key]), top: latestBump(it) })}</div>
|
|
269
|
+
</summary>
|
|
270
|
+
${state.openItems.has(it.key) ? detail(it) : ''}
|
|
271
|
+
</details>`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
B.on(B.root, 'toggle', (ev) => {
|
|
275
|
+
const el = ev.target;
|
|
276
|
+
if (!el.matches?.('details.item')) return;
|
|
277
|
+
const key = el.dataset.key;
|
|
278
|
+
if (el.open) {
|
|
279
|
+
state.openItems.add(key);
|
|
280
|
+
const it = state.board.items.find((i) => i.key === key);
|
|
281
|
+
markSeen(it);
|
|
282
|
+
const unread = el.querySelector('summary .card-unread');
|
|
283
|
+
const line = unread?.parentNode;
|
|
284
|
+
unread?.remove();
|
|
285
|
+
if (line && !line.children.length) line.remove();
|
|
286
|
+
if (it && !el.querySelector('.detail')) { el.insertAdjacentHTML('beforeend', detail(it)); }
|
|
287
|
+
if (state.threadCache.has(key)) B.renderThread(key, state.threadCache.get(key));
|
|
288
|
+
B.loadThread(key, { maxAge: 10000 });
|
|
289
|
+
} else {
|
|
290
|
+
state.openItems.delete(key);
|
|
291
|
+
}
|
|
292
|
+
store.set('openItems', [...state.openItems]);
|
|
293
|
+
}, true);
|
|
294
|
+
|
|
295
|
+
function redrawActions(key) {
|
|
296
|
+
const it = state.board.items.find((i) => i.key === key);
|
|
297
|
+
const row = $(`.actions[data-actions="${CSS.escape(key)}"]`);
|
|
298
|
+
if (it && row) row.outerHTML = actions(it);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
B.on(B.root, 'click', async (ev) => {
|
|
302
|
+
const log = ev.target.closest('[data-try-log]');
|
|
303
|
+
if (log && state.openItems.has(log.dataset.tryLog)) {
|
|
304
|
+
ev.preventDefault();
|
|
305
|
+
const card = log.closest('details.item');
|
|
306
|
+
card?.querySelector('.thread')?.scrollIntoView({ block: 'start' });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const button = ev.target.closest('button[data-try]');
|
|
310
|
+
if (!button) return;
|
|
311
|
+
ev.preventDefault();
|
|
312
|
+
if (button.dataset.tip) { toast(button.dataset.tip); return; }
|
|
313
|
+
const key = button.dataset.try;
|
|
314
|
+
const it = state.board.items.find((i) => i.key === key);
|
|
315
|
+
if (!it || !(await B.confirm(`${tryWords(it.try?.kind).go} ${it.ref}? The hub runs its build script.`)) || !B.alive()) return;
|
|
316
|
+
button.disabled = true;
|
|
317
|
+
try {
|
|
318
|
+
const r = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key }) });
|
|
319
|
+
const out = await r.json();
|
|
320
|
+
if (!r.ok) throw new Error(out.error || 'The hub refused the build.');
|
|
321
|
+
} catch (error) { toast(error.message || 'Could not start. Is the host awake?'); }
|
|
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); }
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
B.on(B.root, 'click', async (ev) => {
|
|
342
|
+
const b = ev.target.closest('button[data-confirm]');
|
|
343
|
+
if (!b) return;
|
|
344
|
+
ev.preventDefault();
|
|
345
|
+
const { key, confirm: act } = b.dataset;
|
|
346
|
+
const it = state.board.items.find((i) => i.key === key);
|
|
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?'); }
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// docs/adr/0052-auto-merge-is-a-board-setting-off-by-default.md
|
|
355
|
+
if (act === 'merge') {
|
|
356
|
+
sendingMerge.add(key);
|
|
357
|
+
redrawActions(key);
|
|
358
|
+
try { await B.sentHere(key, () => B.send('card-merge', { key })); }
|
|
359
|
+
catch { toast('Could not send. Is the host awake?'); }
|
|
360
|
+
sendingMerge.delete(key);
|
|
361
|
+
redrawActions(key);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
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.
|
|
375
|
+
if (act === 'finish') {
|
|
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 }))); }
|
|
379
|
+
catch { toast('Could not close. Is the host awake?'); }
|
|
380
|
+
B.syncDone(key);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const row = $(`.actions[data-actions="${CSS.escape(key)}"]`);
|
|
384
|
+
row?.querySelectorAll('button').forEach((x) => { x.disabled = true; });
|
|
385
|
+
b.disabled = true;
|
|
386
|
+
try {
|
|
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?'); }
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
Object.assign(B, { detail, card });
|
|
393
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The board view's shared helpers, which the other parts take from B. Register it first, after app.js.
|
|
2
|
+
BoardView.part((B) => {
|
|
3
|
+
const { $, store, state } = B;
|
|
4
|
+
|
|
5
|
+
const focusedField = () => {
|
|
6
|
+
const a = document.activeElement;
|
|
7
|
+
return a && B.root.contains(a) && a.matches('textarea, input') ? a : null;
|
|
8
|
+
};
|
|
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
|
+
const form = field.closest('form[data-reply], form[data-edit-form]');
|
|
13
|
+
return !form ? null : form.dataset.reply != null ? `form[data-reply="${CSS.escape(form.dataset.reply)}"] textarea`
|
|
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);
|
|
19
|
+
return () => (field.isConnected ? field : again && $(again));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Runs paint, a repaint through ui.js setHTML, then gives the field being typed in its focus and cursor back when it
|
|
23
|
+
// lost them, which setHTML avoids for a node it moves. A card that moved into a closed lane or fold opens it, since a
|
|
24
|
+
// field there can't keep focus. Refocusing doesn't scroll: steady does.
|
|
25
|
+
function keepFocus(paint) {
|
|
26
|
+
const field = focusedField();
|
|
27
|
+
const find = field && finder(field);
|
|
28
|
+
const sel = field ? [field.selectionStart, field.selectionEnd, field.selectionDirection] : null;
|
|
29
|
+
paint();
|
|
30
|
+
const el = find?.();
|
|
31
|
+
if (!el) return;
|
|
32
|
+
for (let d = el.closest('details:not([open])'); d; d = d.closest('details:not([open])')) d.open = true;
|
|
33
|
+
if (document.activeElement === el) return;
|
|
34
|
+
el.focus({ preventScroll: true });
|
|
35
|
+
if (sel[0] != null) el.setSelectionRange(...sel);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Runs paint through keepFocus and keeps the anchor where it was on screen, scrolling the page by however far it
|
|
39
|
+
// moved, in the same task so nothing visibly jumps (docs/adr/0063-an-open-card-moves-at-once-and-the-page-follows-it.md).
|
|
40
|
+
// The anchor is the field being typed in, else the title row of the open card on screen, else there is none. A field
|
|
41
|
+
// in a dialog doesn't move with the page, so the card anchors then.
|
|
42
|
+
function anchorOf() {
|
|
43
|
+
const field = focusedField();
|
|
44
|
+
if (field && !field.closest('dialog')) return finder(field);
|
|
45
|
+
const card = [...B.$$('details.item[open]')].find((d) => { const r = d.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight; });
|
|
46
|
+
if (!card) return null;
|
|
47
|
+
const title = `details.item[data-key="${CSS.escape(card.dataset.key)}"] > summary`;
|
|
48
|
+
return () => $(title);
|
|
49
|
+
}
|
|
50
|
+
function steady(paint) {
|
|
51
|
+
const find = anchorOf();
|
|
52
|
+
const before = find?.()?.getBoundingClientRect().top;
|
|
53
|
+
keepFocus(paint);
|
|
54
|
+
const after = before == null ? null : find()?.getBoundingClientRect().top;
|
|
55
|
+
if (after != null && after !== before) scrollBy(0, after - before);
|
|
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
|
+
|
|
87
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
88
|
+
|
|
89
|
+
// A card this browser has never seen starts from now, so a first load badges nothing.
|
|
90
|
+
const seen = Object.assign(Object.create(null), store.get('seenThreads', {}));
|
|
91
|
+
const latestOf = (it) => [new Date().toISOString(), ...(it?.othersAt || [])].sort().pop();
|
|
92
|
+
function markSeen(it, onlyNew = false) {
|
|
93
|
+
if (!it || (onlyNew && seen[it.key])) return;
|
|
94
|
+
seen[it.key] = latestOf(it);
|
|
95
|
+
store.set('seenThreads', seen);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// iOS commits a pending predictive-text word after Send clears the box, which would refill it and resave the draft.
|
|
99
|
+
// So Send blurs first, and input is dropped while the send runs and for a moment after it succeeds.
|
|
100
|
+
const quiet = new Map();
|
|
101
|
+
function quieted(draftKey, ta) {
|
|
102
|
+
const until = quiet.get(draftKey) || 0;
|
|
103
|
+
if (until <= Date.now()) return false;
|
|
104
|
+
if (until !== Infinity) ta.value = '';
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
async function sendDraft(draftKey, ta, current, send) {
|
|
108
|
+
ta.blur();
|
|
109
|
+
quiet.set(draftKey, Infinity);
|
|
110
|
+
try { await send(); } catch (e) { quiet.delete(draftKey); store.set(draftKey, (current() || ta).value); throw e; }
|
|
111
|
+
store.set(draftKey, '');
|
|
112
|
+
const now = current();
|
|
113
|
+
if (now) now.value = '';
|
|
114
|
+
quiet.set(draftKey, Date.now() + 1000);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A deleted ask is hidden on the server, so it leaves every device (docs/adr/0008-dismissals-hide-on-the-server.md).
|
|
118
|
+
const hide = (id, hidden) => fetch(B.site.url('api/hide'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, hidden }) });
|
|
119
|
+
// The ui-links.js linkContext for text shown on item `key`'s card or thread.
|
|
120
|
+
const links = (key) => BoardUI.linkContext(state.board?.items || [], key);
|
|
121
|
+
const svg = (body, size = 20) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
122
|
+
|
|
123
|
+
const dialogs = globalThis.BoardDialogs;
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
B.atUnmount(() => controller.abort());
|
|
126
|
+
const confirm = (message, labels) => dialogs.confirm(message, { ...labels, signal: controller.signal });
|
|
127
|
+
const notice = dialogs.notice;
|
|
128
|
+
|
|
129
|
+
Object.assign(B, { steady, restorePlace, plural, seen, markSeen, quieted, sendDraft, hide, links, svg, confirm, notice });
|
|
130
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
// 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.
|
|
6
|
+
BoardView.part((B) => {
|
|
7
|
+
const { $, state, toast } = B;
|
|
8
|
+
const { statusLine, freshSig, nextFresh, githubLine, remoteLine, linksList, setupCard, checkLanded, githubRefresh, headerControls } = BoardUI;
|
|
9
|
+
// A request no check answers stops showing after this.
|
|
10
|
+
const GITHUB_GIVE_UP = 5 * 60000;
|
|
11
|
+
|
|
12
|
+
function renderHeader(d) {
|
|
13
|
+
if (state.githubPending && (checkLanded(d.checks, state.githubPending) || Date.now() - state.githubPending > GITHUB_GIVE_UP)) state.githubPending = null;
|
|
14
|
+
renderStatus();
|
|
15
|
+
$('#setup').hidden = !d.setup;
|
|
16
|
+
$('#setup').innerHTML = setupCard(d.setup);
|
|
17
|
+
if ($('#linksSheet').open) $('#linksList').innerHTML = linksList(d.links);
|
|
18
|
+
}
|
|
19
|
+
const renderStatus = () => { $('#status').innerHTML = statusLine(state.fresh?.at, { link: state.link, watcher: state.board?.checks?.watcher, pause: B.pause?.(), waking: state.waking }); };
|
|
20
|
+
// A board load and its open threads landed; via is ui-header.js nextFresh's.
|
|
21
|
+
function landed(via) {
|
|
22
|
+
state.fresh = nextFresh(state.fresh, { via, at: new Date().toISOString(), sig: freshSig(state.board, state.threadCache, state.openItems) });
|
|
23
|
+
renderStatus();
|
|
24
|
+
}
|
|
25
|
+
B.every(15000, () => { if (!document.hidden) renderStatus(); });
|
|
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>`);
|
|
28
|
+
|
|
29
|
+
// The Hub links sheet: the GitHub mark opens it, from inside the lane's summary, so the tap must not fold the lane.
|
|
30
|
+
const linksSheet = $('#linksSheet');
|
|
31
|
+
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
32
|
+
function closeLinks() {
|
|
33
|
+
if (!linksSheet.open || linksSheet.classList.contains('closing')) return;
|
|
34
|
+
if (reduceMotion.matches) { linksSheet.close(); return; }
|
|
35
|
+
linksSheet.classList.add('closing');
|
|
36
|
+
B.later(180, () => { linksSheet.classList.remove('closing'); linksSheet.close(); });
|
|
37
|
+
}
|
|
38
|
+
B.on(B.root, 'click', (ev) => {
|
|
39
|
+
if (!ev.target.closest('[data-hub-links]')) return;
|
|
40
|
+
ev.preventDefault();
|
|
41
|
+
if (!state.board || linksSheet.open) return;
|
|
42
|
+
$('#linksList').innerHTML = linksList(state.board.links);
|
|
43
|
+
linksSheet.classList.remove('closing');
|
|
44
|
+
linksSheet.showModal();
|
|
45
|
+
});
|
|
46
|
+
B.on($('#linksClose'), 'click', closeLinks);
|
|
47
|
+
B.on(linksSheet, 'click', (ev) => { if (ev.target === linksSheet) closeLinks(); });
|
|
48
|
+
B.on(linksSheet, 'cancel', (ev) => { ev.preventDefault(); closeLinks(); });
|
|
49
|
+
function askGitHub() {
|
|
50
|
+
state.githubPending = Date.now();
|
|
51
|
+
B.render();
|
|
52
|
+
B.send('check-github', {}).catch(() => { state.githubPending = null; B.render(); toast('Could not ask Claude to check GitHub'); });
|
|
53
|
+
}
|
|
54
|
+
B.on(B.root, 'click', (ev) => {
|
|
55
|
+
const b = ev.target.closest('[data-check-github]');
|
|
56
|
+
if (!b) return;
|
|
57
|
+
ev.preventDefault();
|
|
58
|
+
if (state.link === 'online' && state.board && !state.githubPending) askGitHub();
|
|
59
|
+
});
|
|
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
|
+
|
|
77
|
+
state.link = 'online';
|
|
78
|
+
const controls = { wired: false, boardSettled: false, reloading: false };
|
|
79
|
+
function applyControls() {
|
|
80
|
+
const on = headerControls({ link: state.link, ...controls });
|
|
81
|
+
$('#refresh').disabled = !on.refresh;
|
|
82
|
+
$('#refresh').classList.toggle('spinning', on.spin);
|
|
83
|
+
const gear = $('#settingsLink');
|
|
84
|
+
if (on.settings) { gear.removeAttribute('aria-disabled'); gear.removeAttribute('tabindex'); } else { gear.setAttribute('aria-disabled', 'true'); gear.tabIndex = -1; }
|
|
85
|
+
}
|
|
86
|
+
const headerWired = () => { controls.wired = true; applyControls(); };
|
|
87
|
+
// style.css dims the offline view from .view-board.offline.
|
|
88
|
+
function setLink(link) {
|
|
89
|
+
const was = state.link;
|
|
90
|
+
state.link = link;
|
|
91
|
+
if (link !== 'reconnecting') controls.boardSettled = true;
|
|
92
|
+
const off = link !== 'online';
|
|
93
|
+
B.root.classList.toggle('offline', off);
|
|
94
|
+
if (link === 'offline' && was === 'online') toast('Offline. Use refresh in the header to retry.');
|
|
95
|
+
applyControls();
|
|
96
|
+
renderStatus();
|
|
97
|
+
}
|
|
98
|
+
const linkUp = () => setLink('online');
|
|
99
|
+
const linkDown = () => { if (state.link !== 'reconnecting') setLink('offline'); };
|
|
100
|
+
B.on($('.nav'), 'click', (ev) => {
|
|
101
|
+
if (ev.target.closest('a[aria-disabled="true"]')) { ev.preventDefault(); ev.stopImmediatePropagation(); }
|
|
102
|
+
}, true);
|
|
103
|
+
// A load can return in milliseconds, so the spin and "Reconnecting…" hold long enough to read.
|
|
104
|
+
const RELOAD_MIN = 700;
|
|
105
|
+
async function reloading(load) {
|
|
106
|
+
controls.reloading = true;
|
|
107
|
+
applyControls();
|
|
108
|
+
try { await Promise.all([load(), new Promise((r) => setTimeout(r, RELOAD_MIN))]); }
|
|
109
|
+
finally { controls.reloading = false; applyControls(); }
|
|
110
|
+
}
|
|
111
|
+
async function reconnect() {
|
|
112
|
+
setLink('reconnecting');
|
|
113
|
+
await reloading(() => B.loadBoard({ via: 'refresh' }));
|
|
114
|
+
if (state.link === 'reconnecting') setLink('offline');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The header refresh reloads the board and its open threads; only the GitHub ↻ asks Claude to check GitHub.
|
|
118
|
+
// Offline, it retries the board load instead.
|
|
119
|
+
B.on($('#refresh'), 'click', () => {
|
|
120
|
+
if (state.link === 'offline') { reconnect(); return; }
|
|
121
|
+
reloading(() => B.loadBoard({ via: 'refresh' }));
|
|
122
|
+
});
|
|
123
|
+
B.every(5000, () => { if (state.githubPending && !document.hidden) B.loadBoard(); });
|
|
124
|
+
|
|
125
|
+
Object.assign(B, { renderHeader, renderStatus, landed, githubHeader, linkUp, linkDown, headerWired });
|
|
126
|
+
});
|