@lovek4d/kickflip 0.4.0 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +69 -48
- package/README.md +1 -1
- package/SKILL.md +22 -20
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/bin/board.mjs +108 -39
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -3
- package/board/headless.mjs +5 -3
- package/board/merge3.mjs +1 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -2
- package/board/public/app-asks.js +9 -92
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +70 -37
- package/board/public/app-core.js +24 -25
- package/board/public/app-header.js +2 -1
- package/board/public/app-lanes.js +55 -23
- package/board/public/app-needs.js +129 -4
- package/board/public/app-reader.js +174 -0
- package/board/public/app-snapshot.js +6 -5
- package/board/public/app-threads.js +83 -58
- package/board/public/app.html +2 -0
- package/board/public/app.js +66 -24
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +4 -2
- package/board/public/hub.css +1 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +100 -0
- package/board/public/prototype-kit.js +28 -3
- package/board/public/prototype-switcher.js +42 -107
- package/board/public/shell.js +7 -1
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +44 -5
- package/board/public/style.css +194 -35
- package/board/public/ui-cards.js +80 -44
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +2 -2
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-phase-bars.js +4 -4
- package/board/public/ui-questions.js +63 -47
- package/board/public/ui-sidebar.js +27 -15
- package/board/public/ui-threads.js +27 -14
- package/board/public/ui.js +76 -14
- package/board/public/view-frame.js +1 -0
- package/board/public/view-hub-settings.js +11 -10
- package/board/public/view-hub.js +17 -3
- package/board/public/view-questions.js +174 -202
- package/board/public/view-settings.js +208 -70
- package/board/server.mjs +19 -18
- package/board/store.mjs +20 -13
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +24 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +20 -0
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -14
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +2 -1
- package/shared/audit.mjs +4 -0
- package/shared/fake-gh.mjs +17 -4
- package/shared/owner-check.mjs +3 -0
- package/shared/service.mjs +4 -4
- package/shared/tailscale.mjs +1 -0
- package/shared/usage-gate.mjs +1 -1
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
pulse: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
|
15
15
|
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
|
16
16
|
plus: '<path d="M12 5v14M5 12h14"/>',
|
|
17
|
+
// A starting board's step icons, one per START_STEPS key. No open icon set (Lucide, Tabler) has a skateboard, so landing's
|
|
18
|
+
// deck with upturned ends over two wheels is drawn here.
|
|
19
|
+
cloning: '<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 20h14"/>',
|
|
20
|
+
starting: '<path d="m5 7 5 5-5 5M12 18h7"/>',
|
|
21
|
+
landing: '<path d="M2.5 9c0 1.7 1.2 3 3 3h13c1.8 0 3-1.3 3-3"/><circle cx="7.5" cy="16.5" r="2"/><circle cx="16.5" cy="16.5" r="2"/>',
|
|
17
22
|
moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
|
|
18
23
|
up: '<circle cx="12" cy="12" r="9"/><path d="m8 12 4-4 4 4"/><path d="M12 16V8"/>',
|
|
19
24
|
};
|
|
@@ -64,7 +69,7 @@
|
|
|
64
69
|
const sites = data?.sites || [];
|
|
65
70
|
const site = (rank) => (s) => ({ href: siteHref(s), title: String(s.title ?? s.route), board: rank === 0, rank, joined: Date.parse(s.created) });
|
|
66
71
|
const rows = [...sites.filter((s) => s.board).map(site(0)), ...sites.filter((s) => !s.board).map(site(1)),
|
|
67
|
-
...UTILITY.map((f, i) => ({ href: f.href, title: f.title, board: false, rank: 2, joined: i }))];
|
|
72
|
+
...(data?.shared ? [] : UTILITY).map((f, i) => ({ href: f.href, title: f.title, board: false, rank: 2, joined: i }))];
|
|
68
73
|
rows.filter((r) => r.board).slice(0, 9).forEach((r, i) => { r.num = String(i + 1); });
|
|
69
74
|
const when = (r) => (Number.isFinite(r.joined) ? r.joined : Infinity);
|
|
70
75
|
const taken = new Set();
|
|
@@ -149,11 +154,12 @@
|
|
|
149
154
|
+ `<dl class="fs-facts">${fact('Repo', s.repo && esc(repoLabel(s.repo, home)))}${fact('Branch', s.branch && `<code>${esc(s.branch)}</code>`)}`
|
|
150
155
|
+ `${fact('Tickets', s.tickets && esc(ticketsLabel(s.tickets)))}${fact('Asked', s.askedAt && esc(askedWhen(s.askedAt, now)))}</dl>`
|
|
151
156
|
+ '<p class="fs-refused" role="alert" hidden></p>'
|
|
152
|
-
+ `<div class="sheet-actions fs-actions"><button type="button" data-failed-dismiss>Dismiss</button>${s.branch ? '<button type="button" class="primary" data-failed-retry>Retry</button>' : ''}</div></div>`;
|
|
157
|
+
+ `<div class="sheet-actions fs-actions"><button type="button" data-failed-dismiss>Dismiss</button>${s.branch && !s.joined ? '<button type="button" class="primary" data-failed-retry>Retry</button>' : ''}</div></div>`;
|
|
153
158
|
}
|
|
154
159
|
|
|
155
160
|
// A starting board's three steps (docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md):
|
|
156
|
-
// s.step is cloning, starting (the orchestrator's session) or landing (the
|
|
161
|
+
// s.step is cloning, starting (the orchestrator's session, until it joins the hub) or landing (the joined board, until it
|
|
162
|
+
// listens), since s.stepAt.
|
|
157
163
|
// Cloning fills its bar to git's percent; the later steps run the bars' pulse, never a fill.
|
|
158
164
|
const START_STEPS = { cloning: 'cloning', starting: 'starting orchestrator', landing: 'landing it' };
|
|
159
165
|
const STEP_KEYS = Object.keys(START_STEPS);
|
|
@@ -170,25 +176,28 @@
|
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
// The New version row. update: the app's reload, null | 'ready' | 'reloading', which comes first. upgrade:
|
|
173
|
-
// /_hub/sidebar.json's npm update, null | {state: available |
|
|
174
|
-
// (hub/upgrade.mjs; docs/adr/2026-11-
|
|
175
|
-
//
|
|
179
|
+
// /_hub/sidebar.json's npm update, null | {state: available | updating | updated | restarting | failed, version, error}
|
|
180
|
+
// (hub/upgrade.mjs; docs/adr/2026-11-05-2200-the-hub-pulls-a-new-version-itself-and-offers-restart.md). The hub pulls a new
|
|
181
|
+
// version itself, so the row reads Updated to v<version> with Restart; restarting is the sidebar's own state while the hub
|
|
182
|
+
// restarts. A reload, a pull and a restart wait (ui.js BUSY): the pill keeps its blue with a spinner in place of its word.
|
|
176
183
|
function updateRow(update, upgrade) {
|
|
177
184
|
const button = (attr, busy, title, sub, go) => `<button type="button" class="sb-row sb-update-row" ${attr}${busy ? globalThis.BoardUI.BUSY : ''}>${tile('up', 'var(--accent)')}`
|
|
178
185
|
+ `<span class="sb-text"><span class="sb-title">${title}</span>${sub ? `<span class="sb-sub">${esc(sub)}</span>` : ''}</span><span class="sb-go">${go}</span></button>`;
|
|
179
186
|
if (update) return button('data-app-reload', update === 'reloading', 'New version', '', 'Reload');
|
|
180
187
|
if (!upgrade) return '';
|
|
181
|
-
if (upgrade.state === '
|
|
182
|
-
if (upgrade.state === '
|
|
183
|
-
if (upgrade.state === '
|
|
184
|
-
return button('data-app-upgrade', false, 'Update
|
|
188
|
+
if (upgrade.state === 'updated') return button('data-app-restart', false, `Updated to v${esc(upgrade.version)}`, upgrade.error, 'Restart');
|
|
189
|
+
if (upgrade.state === 'restarting') return button('data-app-restart', true, `Restarting to v${esc(upgrade.version)}`, '', 'Restart');
|
|
190
|
+
if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Updating', '', 'Update');
|
|
191
|
+
if (upgrade.state === 'failed') return button('data-app-upgrade', false, 'Update failed', upgrade.error, 'Update');
|
|
192
|
+
return button('data-app-upgrade', false, 'Update available', '', 'Update');
|
|
185
193
|
}
|
|
186
194
|
|
|
187
195
|
// data: /_hub/sidebar.json. here: a site's route or a hub page's path without its leading slash. device: this device's push
|
|
188
196
|
// subscription as sidebar.js finds it, 'on' | 'off' | 'blocked' | 'install' (iOS outside the Home Screen app), or
|
|
189
197
|
// null while unknown or unsupported. switching: switch mode shows, each row with its key and the hint on the Boards label.
|
|
190
198
|
// update: null | 'ready' | 'reloading'.
|
|
191
|
-
// now: the time a starting board's seconds count to.
|
|
199
|
+
// now: the time a starting board's seconds count to. A shared person's data (data.shared; hub/share.mjs sharedSidebar)
|
|
200
|
+
// draws their boards alone: no Hub row, update or notification rows, and no New board.
|
|
192
201
|
function sidebar(data, { here = '', device = null, switching = false, update = null, now = Date.now() } = {}) {
|
|
193
202
|
const sites = data?.sites || [];
|
|
194
203
|
const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
|
|
@@ -203,7 +212,7 @@
|
|
|
203
212
|
const pending = (data?.starting || []).map((s) => (s.failed
|
|
204
213
|
? `<button type="button" class="sb-row sb-starting sb-failed" data-starting="${esc(s.route)}" data-failed-start="${esc(s.route)}" aria-haspopup="dialog">${plus}`
|
|
205
214
|
+ `<span class="sb-text"><span class="sb-title">${esc(s.title)}</span><span class="sb-sub">failed to start</span></span></button>`
|
|
206
|
-
: `<div class="sb-row sb-starting" data-starting="${esc(s.route)}">${
|
|
215
|
+
: `<div class="sb-row sb-starting" data-starting="${esc(s.route)}"><span class="icon sb-plus sb-start-icon" aria-hidden="true">${svg(ICONS[s.step], 20)}</span>`
|
|
207
216
|
+ `<span class="sb-text"><span class="sb-title">${esc(s.title)}</span>${startSteps(s)}<span class="sb-sub sb-step">${esc(startCaption(s, now))}</span></span></div>`)).join('');
|
|
208
217
|
const newBoard = `<button type="button" class="sb-row sb-new" data-new-board aria-haspopup="dialog">${plus}<span class="sb-text"><span class="sb-title">New board</span></span></button>`;
|
|
209
218
|
const turnOn = device === 'off' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Turn on notifications', sub: 'Off on this device', cls: 'sb-turnon' })
|
|
@@ -225,13 +234,16 @@
|
|
|
225
234
|
const selected = here.startsWith('_hub') ? '/_hub' : `/${here.replace(/\/$/, '')}`;
|
|
226
235
|
const hint = switching ? '<span class="ks-label-hint" role="status">– press a key to switch<i class="ks-bar"></i></span>' : '';
|
|
227
236
|
const close = `<div class="nav"><button type="button" class="sb-close" data-sidebar-close aria-label="Close boards">${svg(ICONS.close, 20)}</button></div>`;
|
|
228
|
-
|
|
229
|
-
leadAttrs: { 'aria-label': 'Hide boards', title: 'Hide boards', 'data-sidebar-close': '' } })
|
|
237
|
+
const top = header({ lead: 'logo', title: 'Kickflip', level: 2, controls: false, trail: close, logoSrc: '/_hub/kickflip.png',
|
|
238
|
+
leadAttrs: { 'aria-label': 'Hide boards', title: 'Hide boards', 'data-sidebar-close': '' } });
|
|
239
|
+
const boardsLabel = `<div class="sb-label ks-label"><span class="ks-label-text">Boards</span>${hint}</div>`;
|
|
240
|
+
if (data?.shared) return `${top}<div class="sb-scroll"><section class="sb-boards" aria-label="Boards">${boardsLabel}<div class="sb-group">${boards.map(siteRow).join('')}</div></section></div>`;
|
|
241
|
+
return top
|
|
230
242
|
+ `<div class="sb-scroll"><section class="sb-utility" aria-label="Hub"><div class="sb-update" role="status">${updateRow(update, data?.upgrade)}</div><div class="sb-group">`
|
|
231
243
|
+ UTILITY.map((f) => row({ ...f, on: f.href.replace(/\/$/, '') === selected, sub: subs[f.href], mark: f.href === '/_hub/' ? moon : '', ...(f.href === '/_hub/' && usage ? { extra: usage, cls: 'sb-hub-usage' } : {}), key: keys?.get(f.href) })).join('')
|
|
232
244
|
+ `</div>${turnOn}</section>`
|
|
233
245
|
+ (pages.length ? `<section class="sb-pages" aria-label="Pages"><div class="sb-label">Pages</div><div class="sb-group">${pages.map(siteRow).join('')}</div></section>` : '')
|
|
234
|
-
+ `<section class="sb-boards" aria-label="Boards"
|
|
246
|
+
+ `<section class="sb-boards" aria-label="Boards">${boardsLabel}`
|
|
235
247
|
+ `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
|
|
236
248
|
}
|
|
237
249
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A card's thread: the orchestrator's lines (who claude, named Orchestrator), its builders' asks, the owner's messages and
|
|
2
2
|
// their replies. Needs ui.js, ui-links.js and ui-enums.js.
|
|
3
3
|
(function () {
|
|
4
|
-
const { esc, time, etaLine, replyBody, attachments,
|
|
4
|
+
const { esc, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
|
|
5
5
|
const VIA = { slack: ' · Slack', github: ' · GitHub' };
|
|
6
6
|
// A link to a card on this board (#item=<key>) or on another board (/<route>/#item=<key>, an ask's move), or ''.
|
|
7
7
|
function cardLink(link) {
|
|
@@ -13,10 +13,13 @@
|
|
|
13
13
|
}
|
|
14
14
|
// A message's state comes from domain.mjs threadOf.
|
|
15
15
|
const stateOf = (m) => m.state || (m.reply ? 'replied' : 'waiting');
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
// A message's footer: the tap it came from (m.status, such as "answers submitted"), then, to its right, Claude's
|
|
17
|
+
// pick-up while it waits (docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
|
|
18
|
+
const PICK_UP = { working: workingLine, waiting: 'Waiting for Claude…' };
|
|
19
|
+
const msgFooter = (m) => {
|
|
20
|
+
const parts = [m.status && `<span class="foot-status">${statusMark(m.status)}${esc(m.status)}</span>`, PICK_UP[stateOf(m)]].filter(Boolean);
|
|
21
|
+
return parts.length ? `<div class="reply muted small">${parts.join('')}</div>` : '';
|
|
22
|
+
};
|
|
20
23
|
// Each reply becomes Claude's own message at the time it was written, quoting the owner's message
|
|
21
24
|
// when other messages came between them. A reply with no time follows its message.
|
|
22
25
|
const withReplies = (msgs) => {
|
|
@@ -29,26 +32,27 @@
|
|
|
29
32
|
// files it adds to the message, under composer id edit:<event id>.
|
|
30
33
|
const editForm = (id, text) => `<form class="edit-form" data-edit-form="${esc(id)}" data-composer="edit:${esc(id)}">${dropZone(`<textarea rows="3">${esc(text)}</textarea>`)}
|
|
31
34
|
<div class="pending-files" data-files></div>
|
|
32
|
-
<div class="
|
|
33
|
-
// batched: an answer followed by another of the owner's answers in its state, which carries the batch's
|
|
35
|
+
<div class="send-actions"><button type="button" data-edit-cancel>Cancel</button>${sendButton('Save', 0)}</div></form>`;
|
|
36
|
+
// batched: an answer followed by another of the owner's answers in its state, which carries the batch's footer.
|
|
34
37
|
const line = (m, links, edits, batched = false) => (m.kind === 'reply'
|
|
35
38
|
? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
|
|
36
39
|
: m.kind === 'ask-link' ? `<div class="msg link"><a href="${esc(m.link)}">${esc(m.text)} →</a></div>`
|
|
37
|
-
: m.request ? `<div class="msg request">${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
38
|
-
: m.kind === '
|
|
39
|
-
: m.kind === '
|
|
40
|
-
: m.kind === '
|
|
40
|
+
: m.request ? `<div class="msg request">${statusMark(m.text)}${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
41
|
+
: m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${esc(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
42
|
+
: m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
43
|
+
: m.kind === 'calls' ? `<details class="msg calls"><summary>${statusMark('judgment calls')}${esc(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
|
|
44
|
+
: m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
|
|
41
45
|
${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
|
|
42
46
|
<div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
|
|
43
47
|
<div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
|
|
44
|
-
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log
|
|
48
|
+
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log</a></div>` : ''}
|
|
45
49
|
${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
|
|
46
50
|
${m.eventId && !batched ? msgFooter(m) : ''}
|
|
47
51
|
</div>`);
|
|
48
52
|
|
|
49
53
|
const STEP = 3;
|
|
50
54
|
const UNREAD_CAP = 10;
|
|
51
|
-
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link']);
|
|
55
|
+
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions']);
|
|
52
56
|
const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
|
|
53
57
|
// Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount); the
|
|
54
58
|
// orchestrator's answer to a builder's ask never is.
|
|
@@ -92,5 +96,14 @@
|
|
|
92
96
|
return `<button type="button"${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}">Done</button>`;
|
|
93
97
|
}
|
|
94
98
|
|
|
95
|
-
|
|
99
|
+
// docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
|
|
100
|
+
// A card's open questions asked since seenAt as one blue line in its thread, at the newest one's time, linking to the
|
|
101
|
+
// first: it.questions from the board. → [line], or [] with none new or no seen mark.
|
|
102
|
+
function newQuestionsLine(questions, seenAt) {
|
|
103
|
+
const fresh = seenAt ? (questions?.askedAt || []).filter((t) => String(t) > seenAt) : [];
|
|
104
|
+
if (!fresh.length) return [];
|
|
105
|
+
return [{ ts: fresh.at(-1), who: 'claude', kind: 'questions', text: `${fresh.length} new question${fresh.length === 1 ? '' : 's'}`, link: `questions.html#${encodeURIComponent(questions.first)}` }];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton, newQuestionsLine });
|
|
96
109
|
})();
|
package/board/public/ui.js
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
if (s < 86400) return Math.round(s / 3600) + 'h ago';
|
|
14
14
|
return Math.round(s / 86400) + 'd ago';
|
|
15
15
|
};
|
|
16
|
+
// How long ago a card's status was first set, as its status line ends: "just now", "14m", "3h", "2d"
|
|
17
|
+
// (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md).
|
|
18
|
+
const lasted = (iso) => ago(iso).replace(/ ago$/, '');
|
|
16
19
|
const since = (iso) => {
|
|
17
20
|
const s = (Date.now() - new Date(iso).getTime()) / 1000;
|
|
18
21
|
return s < 60 ? 'just now' : s < 3600 ? Math.round(s / 60) + ' min ago' : s < 86400 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' d ago';
|
|
@@ -49,23 +52,50 @@
|
|
|
49
52
|
}
|
|
50
53
|
const CLIP = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
|
|
51
54
|
const attachButton = () => `<label class="attach" title="Attach files"><input type="file" multiple data-attach aria-label="Attach files">${CLIP}</label>`;
|
|
52
|
-
// A composer's text box, which attach.js outlines with Drop to
|
|
53
|
-
|
|
55
|
+
// A composer's text box with its attach button inside at the bottom right, which attach.js outlines with Drop to
|
|
56
|
+
// attach while files are dragged over the composer.
|
|
57
|
+
const dropZone = (textarea) => `<div class="dz">${textarea}${attachButton()}<div class="dz-hint" aria-hidden="true">Drop to attach</div></div>`;
|
|
58
|
+
// docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md
|
|
59
|
+
// A composer's send buttons: the one with nth 0 presses on ⌘↵ and the one with nth 1, where there is one, on ⌥↵,
|
|
60
|
+
// wherever each sits in the row (Ctrl+↵ and
|
|
61
|
+
// Alt+↵ off a Mac), each with its key as a small gray hint that style.css shows only where there is a keyboard and room.
|
|
62
|
+
const MAC = /Mac|iPhone|iPad/.test(globalThis.navigator?.platform || globalThis.navigator?.userAgent || '');
|
|
63
|
+
const SEND_KEYS = MAC ? ['⌘↵', '⌥↵'] : ['Ctrl+↵', 'Alt+↵'];
|
|
64
|
+
const sendButton = (label, nth, attrs = '') => `<button type="submit"${nth === 0 ? ' class="primary"' : ''} data-nth="${nth}"${attrs}>${esc(label)}<span class="key-hint" aria-hidden="true">${SEND_KEYS[nth]}</span></button>`;
|
|
65
|
+
// The send button an Enter keydown in form's box presses, or null.
|
|
66
|
+
const keyedSend = (ev, form) => {
|
|
67
|
+
if (ev.key !== 'Enter' || ev.isComposing) return null;
|
|
68
|
+
const nth = (ev.metaKey || ev.ctrlKey) && !ev.altKey ? 0 : ev.altKey && !ev.metaKey && !ev.ctrlKey ? 1 : -1;
|
|
69
|
+
return nth < 0 ? null : form.querySelector(`button[type="submit"][data-nth="${nth}"]`);
|
|
70
|
+
};
|
|
54
71
|
const icon = (body, size = 18, attrs = '') => `<svg${attrs} 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>`;
|
|
55
72
|
// An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
|
|
56
73
|
const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
|
|
57
74
|
const EYE = icon('<path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/>');
|
|
58
75
|
const TRASH = icon('<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>');
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
76
|
+
const FIRE = icon('<path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9z"/>');
|
|
77
|
+
const BOLT = icon('<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>');
|
|
78
|
+
// Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md): a
|
|
79
|
+
// question's eye sends at once; a thread's eye is a toggle that Send carries (app-threads.js).
|
|
80
|
+
const protoButton = (id, { label = 'Ask for prototypes' } = {}) => `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}">${EYE}</button>`;
|
|
81
|
+
// The thread's fire, the eye's twin for a grill.
|
|
82
|
+
const grillButton = (id) => `<button type="button" class="attach proto-ask" data-grill="${esc(id)}" aria-label="Ask for a grill" title="Ask for a grill">${FIRE}</button>`;
|
|
83
|
+
// A Todo card's thread footer lightning: starts a quick build of the card at once (app-threads.js).
|
|
84
|
+
const boltButton = (id) => `<button type="button" class="attach proto-ask" data-bolt="${esc(id)}" aria-label="Quick build" title="Quick build">${BOLT}</button>`;
|
|
85
|
+
// docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
|
|
86
|
+
// A thread status line's leading mark, its icon or dot centred in one fixed box so every line's text starts at the
|
|
87
|
+
// same place: the request's eye, fire or quick build's lightning, answers' check, new questions' plain question mark, a builder's dot.
|
|
88
|
+
const mini = (body, cls = '') => `<svg class="mark-icon${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
89
|
+
const MARK_ICONS = {
|
|
90
|
+
'prototype requested': mini('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
|
|
91
|
+
'grill requested': mini('<path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9z"/>'),
|
|
92
|
+
'quick build: no grill': mini('<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>'),
|
|
93
|
+
'answers submitted': mini('<path d="M20 6 9 17l-5-5"/>'),
|
|
94
|
+
'judgment calls': mini('<path d="m9 6 6 6-6 6"/>'),
|
|
95
|
+
questions: mini('<path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 .9-1 1.7M12 17h.01"/>', ' big'),
|
|
96
|
+
};
|
|
97
|
+
const mark = (inner = '') => `<span class="mark" aria-hidden="true">${inner}</span>`;
|
|
98
|
+
const statusMark = (words) => mark(MARK_ICONS[words] || '');
|
|
69
99
|
const PENCIL = icon('<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>');
|
|
70
100
|
const BUMP = icon('<path d="M5 3h14"/><path d="m18 13-6-6-6 6"/><path d="M12 7v14"/>');
|
|
71
101
|
// Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
|
|
@@ -280,6 +310,38 @@
|
|
|
280
310
|
return promise;
|
|
281
311
|
}
|
|
282
312
|
|
|
313
|
+
// docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md
|
|
314
|
+
// A tap whose change shows only in a later board: its button draws BUSY while pending.has(id), id being
|
|
315
|
+
// "<card key>:<action>", whatever repaints it, until settle(board) finds a board made after the tap's answer for which
|
|
316
|
+
// until(board) holds. start(id, until) on the tap; answered(id, generatedAt) with the generatedAt of the POST's board;
|
|
317
|
+
// drop(id) when the POST is refused. Boards made before the POST answered never end it, so a reload that races the POST
|
|
318
|
+
// keeps the spinner.
|
|
319
|
+
// A tap whose answer carries no board (a bare fetch) calls answered(id) with no time: any board then ends it, so its
|
|
320
|
+
// until() must hold only for a change the tap caused. start(id, until, onLate): if no board ends it within
|
|
321
|
+
// PENDING_LATE_MS the entry drops and onLate() runs (the page reloads once and says so).
|
|
322
|
+
const PENDING_LATE_MS = 60000;
|
|
323
|
+
const pendings = new Map();
|
|
324
|
+
const pending = {
|
|
325
|
+
start(id, until, onLate = null) {
|
|
326
|
+
pending.drop(id);
|
|
327
|
+
const late = onLate && setTimeout(() => { pendings.delete(id); onLate(); }, PENDING_LATE_MS);
|
|
328
|
+
pendings.set(id, { until, after: null, late });
|
|
329
|
+
},
|
|
330
|
+
answered(id, after = '') { const p = pendings.get(id); if (p) p.after = after; },
|
|
331
|
+
drop(id) { clearTimeout(pendings.get(id)?.late); pendings.delete(id); },
|
|
332
|
+
has: (id) => pendings.has(id),
|
|
333
|
+
// A tap whose change is drawn by the page's own load, not by a board (a thread, a page's redraw): resolves when work
|
|
334
|
+
// settles, or after PENDING_LATE_MS calls onLate() and resolves, so waitOn's spinner rests either way.
|
|
335
|
+
within(work, onLate) {
|
|
336
|
+
let timer;
|
|
337
|
+
const late = new Promise((resolve) => { timer = setTimeout(() => { onLate(); resolve(); }, PENDING_LATE_MS); });
|
|
338
|
+
return Promise.race([work, late]).finally(() => clearTimeout(timer));
|
|
339
|
+
},
|
|
340
|
+
settle(board) {
|
|
341
|
+
for (const [id, p] of pendings) if (p.after !== null && board.generatedAt >= p.after && p.until(board)) pending.drop(id);
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
|
|
283
345
|
// While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
|
|
284
346
|
// on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
|
|
285
347
|
// then throws once about limit ms have passed. Any other answer resolves {ok, status, body}, a 404's {error} included.
|
|
@@ -309,7 +371,7 @@
|
|
|
309
371
|
|
|
310
372
|
globalThis.BoardUI = {
|
|
311
373
|
LINK_ARROW,
|
|
312
|
-
esc, record, time, ago, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, fetchJSON, RESTARTING, restartNotice,
|
|
313
|
-
attachments, attachButton, dropZone, pendingFiles, protoButton,
|
|
374
|
+
esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, RESTARTING, restartNotice,
|
|
375
|
+
attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, boltButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
|
|
314
376
|
};
|
|
315
377
|
})();
|
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
B.on(doc, 'click', (e) => tap(e, win));
|
|
86
86
|
// Switch mode (sidebar.js) answers keys and clicks in the frame as it does the shell's own.
|
|
87
87
|
if (ctx.keydown) B.on(doc, 'keydown', ctx.keydown, true);
|
|
88
|
+
if (B.newTaskKey) B.on(doc, 'keydown', B.newTaskKey);
|
|
88
89
|
if (ctx.pointerdown) B.on(doc, 'pointerdown', ctx.pointerdown, true);
|
|
89
90
|
ctx.loaded(url, title);
|
|
90
91
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// HubSettings: summary rows below Hub’s health and status open centred settings modals ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)), using
|
|
2
2
|
// settingsView's words from GET /_hub/api/settings. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
|
|
3
|
-
// hours' times and hold to /_hub/settings/quiet, and Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key
|
|
3
|
+
// hours' times and hold to /_hub/settings/quiet, and Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key and Slack's token do through linear-key.js; Done waits for
|
|
4
4
|
// a key's save to land. The rules editor saves through POST /_hub/settings/rules
|
|
5
5
|
// and marks [data-dirty] while an edit is unsaved, so the shell asks before leaving then. The data refreshes every 30 seconds while nothing is being edited.
|
|
6
6
|
(function () {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
+ `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
const sections = [['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
61
|
+
const sections = [['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['slack', 'Slack'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
62
62
|
const sectionOf = (hash) => {
|
|
63
63
|
const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
|
|
64
64
|
return sections.some(([section]) => section === id) ? id : null;
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
let flash = null;
|
|
80
80
|
let rules = null;
|
|
81
81
|
let rulesJSON = '';
|
|
82
|
-
let
|
|
82
|
+
let tokenSlot = null;
|
|
83
83
|
|
|
84
84
|
function paint() {
|
|
85
85
|
if (!last) return;
|
|
@@ -87,16 +87,16 @@
|
|
|
87
87
|
top.innerHTML = HubUI.flash(flash);
|
|
88
88
|
const scrollTop = B.$('.settings-body').scrollTop;
|
|
89
89
|
if (active === 'notifications') {
|
|
90
|
-
content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}
|
|
90
|
+
content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}</div>`;
|
|
91
91
|
} else if (active === 'quiet') {
|
|
92
92
|
content.innerHTML = quietRows(last.quiet);
|
|
93
93
|
} else if (active === 'keep-awake') {
|
|
94
94
|
content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
|
|
95
95
|
: `<div class="group">${awakeRow(last.awake)}</div><p class="note">Runs <code>caffeinate -ims</code> on the host so it doesn’t sleep while boards work. The screen can still turn off.</p>`;
|
|
96
|
-
} else if (active === 'linear') {
|
|
97
|
-
if (
|
|
96
|
+
} else if (active === 'linear' || active === 'slack') {
|
|
97
|
+
if (tokenSlot) tokenSlot.set(last[active]);
|
|
98
98
|
else {
|
|
99
|
-
|
|
99
|
+
tokenSlot = LinearKey.mount(content, { kind: active, state: last[active],
|
|
100
100
|
saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); },
|
|
101
101
|
busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
|
|
102
102
|
}
|
|
@@ -112,6 +112,7 @@
|
|
|
112
112
|
quiet: last.quiet.summary,
|
|
113
113
|
'keep-awake': last.awake.summary,
|
|
114
114
|
linear: last.linear?.summary || 'No key',
|
|
115
|
+
slack: last.slack?.summary || 'No token',
|
|
115
116
|
rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
|
|
116
117
|
closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
|
|
117
118
|
};
|
|
@@ -152,7 +153,7 @@
|
|
|
152
153
|
function closeModal(restore = true) {
|
|
153
154
|
unfitModal?.(); unfitModal = null;
|
|
154
155
|
modal.close();
|
|
155
|
-
|
|
156
|
+
tokenSlot?.destroy(); tokenSlot = null;
|
|
156
157
|
content.innerHTML = '';
|
|
157
158
|
rules = null;
|
|
158
159
|
active = null;
|
|
@@ -186,8 +187,8 @@
|
|
|
186
187
|
return true;
|
|
187
188
|
};
|
|
188
189
|
const dismiss = () => {
|
|
189
|
-
if (!
|
|
190
|
-
|
|
190
|
+
if (!tokenSlot?.busy()) { B.openSettings({ kind: 'hub' }); return; }
|
|
191
|
+
tokenSlot.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
|
|
191
192
|
};
|
|
192
193
|
B.on(modal, 'cancel', (e) => { e.preventDefault(); dismiss(); });
|
|
193
194
|
B.on(root, 'click', (e) => {
|
package/board/public/view-hub.js
CHANGED
|
@@ -39,8 +39,12 @@
|
|
|
39
39
|
const { row, tile } = HubUI;
|
|
40
40
|
const fixHtml = (fix) => fix.command ? `<code>${esc(fix.command)}</code>` : fix.settings
|
|
41
41
|
? `${esc(fix.text)} <button type="button" class="link" data-settings-open="${esc(fix.settings)}">${esc(fix.label)}</button>.` : esc(fix.text);
|
|
42
|
+
// One line per device with notifications on: kind, browser, last seen, and a Remove that forgets it.
|
|
43
|
+
const mine = (() => { try { return localStorage.getItem('kickflip.push.id'); } catch { return null; } })();
|
|
44
|
+
const deviceLine = (d) => `<span class="rd-device">${esc([d.kind, d.browser, `seen ${BoardUI.ago(d.added)}`].filter(Boolean).join(' · '))}${d.id === mine ? ' · <b>this device</b>' : ''} <button type="button" class="link" data-forget-device="${esc(d.id)}">Remove</button></span>`;
|
|
45
|
+
const devices = (it) => it.devices?.length ? `<span class="links">${it.devices.map(deviceLine).join('')}</span>` : '';
|
|
42
46
|
const rows = integrations.map((it) => row({ icon: tile(it.id, it.ready ? 'var(--color-quiet)' : 'var(--color-notice)'), title: esc(it.name), wrap: true,
|
|
43
|
-
sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}`
|
|
47
|
+
sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}${devices(it)}`
|
|
44
48
|
: `<span class="st wait">not ready</span> · ${esc(it.missing)}<span class="links"><span class="rd-fix">${fixHtml(it.fix)}</span>${it.waits ? `<span class="rd-waits">${esc(it.waits)}</span>` : ''}</span>` })).join('');
|
|
45
49
|
return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
|
|
46
50
|
<p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
|
|
@@ -73,7 +77,7 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
73
77
|
<div class="group">${nowRow(d.polling, tile('clock', d.polling.count ? 'var(--color-notice)' : 'var(--color-quiet)'))}</div>`;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
|
-
const footer = (d) => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip <route></code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span></footer>`;
|
|
80
|
+
const footer = (d) => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip <route></code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v${esc(d.version)}</span></footer>`;
|
|
77
81
|
|
|
78
82
|
// iOS shows an app badge only in a Home Screen app with notifications allowed; elsewhere this does nothing.
|
|
79
83
|
function badge(n) {
|
|
@@ -109,8 +113,11 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
109
113
|
if (!r.ok || !out) throw new Error();
|
|
110
114
|
if (B.alive()) B.toast(`${out.title} is back up, and its session is listening.`);
|
|
111
115
|
} catch { flash = { text: out?.error || `Could not restart ${title}. Is the host awake?`, error: true }; }
|
|
116
|
+
// The row keeps its Restart busy until the hub's data shows the board's new word, and only a refusal loads after
|
|
117
|
+
// (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
118
|
+
if (!flash && B.alive()) await BoardUI.pending.within(load(), () => { B.toast('Still waiting on the board'); load(); });
|
|
112
119
|
restarting.delete(route);
|
|
113
|
-
if (B.alive()) { paint(); load(); }
|
|
120
|
+
if (B.alive()) { paint(); if (flash) load(); }
|
|
114
121
|
}
|
|
115
122
|
// Restart hub asks the host to restart the hub service (POST /_hub/restart-hub), then waits for the hub to answer
|
|
116
123
|
// with other code than it ran (its x-hub-code) and reloads the app; past HUB_WAIT tries it says how to restart by hand.
|
|
@@ -145,7 +152,14 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
145
152
|
flash = { text: `The hub hasn’t come back. On the host, run ${out.command}`, error: true };
|
|
146
153
|
if (B.alive()) paint();
|
|
147
154
|
}
|
|
155
|
+
async function forgetDevice(id) {
|
|
156
|
+
const out = await fetch('/_hub/push/forget', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then((r) => r.ok, () => false);
|
|
157
|
+
if (!out) { flash = { text: 'Could not remove that device. Is the host awake?', error: true }; paint(); return; }
|
|
158
|
+
load();
|
|
159
|
+
}
|
|
148
160
|
B.on(main, 'click', (e) => {
|
|
161
|
+
const forgetter = e.target.closest?.('[data-forget-device]');
|
|
162
|
+
if (forgetter) forgetDevice(forgetter.dataset.forgetDevice);
|
|
149
163
|
const opener = e.target.closest?.('[data-settings-open]');
|
|
150
164
|
if (opener) B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener);
|
|
151
165
|
if (e.target.closest?.('[data-restart-hub]')) restartHub();
|