@lovek4d/kickflip 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +69 -37
- package/SKILL.md +19 -13
- package/bin/kickflip.mjs +12 -12
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +156 -83
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -5
- package/board/onboarding.mjs +11 -0
- package/board/prototype-base.mjs +1 -0
- package/board/prototype-page.mjs +1 -0
- package/board/public/app-arch.js +1 -1
- package/board/public/app-cards.js +27 -13
- package/board/public/app-header.js +6 -7
- package/board/public/app-lanes.js +15 -38
- package/board/public/app-needs.js +36 -31
- package/board/public/app-snapshot.js +43 -70
- package/board/public/app-threads.js +38 -46
- package/board/public/app.html +9 -4
- package/board/public/app.js +78 -32
- package/board/public/hub-kit.js +62 -2
- package/board/public/hub.css +2 -0
- package/board/public/new-task.js +8 -6
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +109 -4
- package/board/public/prototype-switcher.js +123 -29
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +64 -24
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -69
- package/board/public/style.css +108 -57
- package/board/public/ui-cards.js +61 -46
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +25 -16
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-page-header.js +19 -7
- package/board/public/ui-patch.js +7 -33
- package/board/public/ui-progress-bar.js +28 -0
- package/board/public/ui-questions.js +25 -17
- package/board/public/ui-sidebar.js +25 -23
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +57 -28
- package/board/public/ui.js +92 -17
- package/board/public/view-hub-settings.js +12 -58
- package/board/public/view-hub.js +2 -1
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +90 -68
- package/board/public/view-settings.js +133 -15
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +19 -19
- package/board/stop.mjs +1 -0
- package/board/store.mjs +27 -18
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +22 -21
- package/hooks-plugin/hooks/usage-gate.mjs +20 -3
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +1 -1
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/review-send.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -1
- package/hub/slack.mjs +2 -2
- 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 +1 -1
- package/shared/card-prs.mjs +1 -1
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
|
@@ -1,73 +1,64 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
1
|
+
// The board this device last held, saved in IndexedDB with its change number, which the board page paints live on open
|
|
2
|
+
// and catches up from (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md). Needs ui.js.
|
|
3
3
|
(function () {
|
|
4
|
-
// Characters, one board's most; app-snapshot.js keeps every board's within three of these.
|
|
5
|
-
const SNAPSHOT_MAX = 900000;
|
|
6
4
|
const ACTIVE_LANES = ['review', 'stale', 'progress', 'todo'];
|
|
7
5
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// `keep` first and the rest while they fit under max; threads of items no longer on the board are dropped. When the
|
|
15
|
-
// board still won't fit, Done cards go too. build is the saving page's (app.html data-build). Null when open lanes alone won't fit.
|
|
16
|
-
function packSnapshot(board, threads, { build, savedAt, keep = [], max = SNAPSHOT_MAX } = {}) {
|
|
17
|
-
const headOf = (b) => `{"build":${JSON.stringify(build)},"savedAt":${JSON.stringify(savedAt)},"board":${JSON.stringify(b)},"threads":{`;
|
|
18
|
-
let shown = firstPaint(board, false);
|
|
19
|
-
let head = headOf(shown);
|
|
20
|
-
if (head.length + 2 > max) {
|
|
21
|
-
shown = firstPaint(board, true);
|
|
22
|
-
head = headOf(shown);
|
|
23
|
-
if (head.length + 2 > max) return null;
|
|
24
|
-
}
|
|
25
|
-
const onBoard = new Set(shown.items.map((i) => i.key));
|
|
26
|
-
let out = head;
|
|
27
|
-
let n = 0;
|
|
28
|
-
for (const key of new Set([...keep, ...threads.keys()])) {
|
|
29
|
-
if (!onBoard.has(key) || !threads.has(key)) continue;
|
|
30
|
-
const part = `${n ? ',' : ''}${JSON.stringify(key)}:${JSON.stringify(threads.get(key))}`;
|
|
31
|
-
if (out.length + part.length + 2 > max) continue;
|
|
32
|
-
out += part;
|
|
33
|
-
n++;
|
|
34
|
-
}
|
|
35
|
-
return `${out}}}`;
|
|
36
|
-
}
|
|
6
|
+
// threads maps item keys to thread messages (app.js state.threadCache). → {build, savedAt, board, threads} with the
|
|
7
|
+
// threads of the cards on the board; build is the saving page's (app.html data-build).
|
|
8
|
+
const copyOf = (board, threads, { build, savedAt }) => {
|
|
9
|
+
const onBoard = new Set(board.items.map((i) => i.key));
|
|
10
|
+
return { build, savedAt, board, threads: Object.fromEntries([...threads].filter(([key]) => onBoard.has(key))) };
|
|
11
|
+
};
|
|
37
12
|
|
|
38
|
-
// → {savedAt, board, threads
|
|
39
|
-
//
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
try { s = JSON.parse(raw); } catch { return null; }
|
|
43
|
-
if (s?.build !== build || !Array.isArray(s.board?.items) || typeof s.savedAt !== 'string' || !Number.isFinite(Date.parse(s.savedAt))) return null;
|
|
13
|
+
// → {savedAt, board, threads}, or null for anything copyOf didn't write under this build: code from another build may
|
|
14
|
+
// read the board's shape differently, and a paint that throws stops the live load.
|
|
15
|
+
function readCopy(s, build) {
|
|
16
|
+
if (!s || s.build !== build || !Array.isArray(s.board?.items) || !Number.isInteger(s.board.version) || typeof s.savedAt !== 'string' || !Number.isFinite(Date.parse(s.savedAt))) return null;
|
|
44
17
|
const threads = Object.fromEntries(Object.entries(s.threads && typeof s.threads === 'object' ? s.threads : {}).filter(([, m]) => Array.isArray(m)));
|
|
45
18
|
return { savedAt: s.savedAt, board: s.board, threads };
|
|
46
19
|
}
|
|
47
20
|
|
|
21
|
+
// One transaction on a store of the copies' database: 'boards', each board's copy, or 'pages', a side page's answers
|
|
22
|
+
// (pageCopy). fn(store) → the request whose result it resolves with. Rejects where IndexedDB is missing or refuses.
|
|
23
|
+
function copyStore(name, mode, fn) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const open = indexedDB.open('kickflip-boards', 2);
|
|
26
|
+
open.onupgradeneeded = () => { for (const s of ['boards', 'pages']) if (!open.result.objectStoreNames.contains(s)) open.result.createObjectStore(s); };
|
|
27
|
+
open.onerror = () => reject(open.error);
|
|
28
|
+
open.onsuccess = () => {
|
|
29
|
+
const db = open.result;
|
|
30
|
+
const tx = db.transaction(name, mode);
|
|
31
|
+
const req = fn(tx.objectStore(name));
|
|
32
|
+
tx.oncomplete = () => { db.close(); resolve(req.result); };
|
|
33
|
+
tx.onerror = tx.onabort = () => { db.close(); reject(tx.error); };
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A side page's last answer from each URL it reads, Notes' and Settings', for showing while the board can't be reached
|
|
39
|
+
// (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md). read(url) → {savedAt, body} or null.
|
|
40
|
+
const pageCopy = {
|
|
41
|
+
save: (url, body) => copyStore('pages', 'readwrite', (store) => store.put({ savedAt: new Date().toISOString(), body }, url)).catch(() => {}),
|
|
42
|
+
read: (url) => copyStore('pages', 'readonly', (store) => store.get(url)).then((c) => (typeof c?.savedAt === 'string' ? c : null), () => null),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// The copy of the board at key, the board view's site URL: the copy build saved, or with build null any build's.
|
|
46
|
+
// → {savedAt, board, threads} or null.
|
|
47
|
+
const boardCopy = (key, build) => copyStore('boards', 'readonly', (store) => store.get(key)).then((s) => readCopy(s, build ?? s?.build), () => null);
|
|
48
|
+
|
|
48
49
|
// The cards whose threads load right after the board, so tapping one opens it filled; open cards load with the board.
|
|
49
50
|
const preloadKeys = (items, open) => {
|
|
50
51
|
const opened = new Set(open);
|
|
51
52
|
return items.filter((i) => ACTIVE_LANES.includes(i.lane) && !opened.has(i.key)).map((i) => i.key);
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
// What the board page takes taps on. view is 'snapshot' while the last visit shows, 'fresh' once a board load landed,
|
|
55
|
-
// and null before either. blocked selects the controls that send to the host and wait for the fresh board; style.css's
|
|
56
|
-
// INT-61 block dims the same list.
|
|
57
|
-
const SNAPSHOT_BLOCKED = ['.actions button', 'button[data-plan]', 'button[data-bump]', 'button[data-card-bump]', 'button[data-edit]', 'button[data-withdraw]',
|
|
58
|
-
'button[data-check-github]', 'form[data-reply] button[type=submit]', 'form[data-reply] button[data-quick]', 'form[data-reply] button[data-proto]', 'form[data-reply] button[data-confirm]', '.prompt button'].join(', ');
|
|
59
|
-
const liveControls = (view) => {
|
|
60
|
-
const fresh = view !== 'snapshot';
|
|
61
|
-
return { read: true, ask: true, cards: fresh, send: fresh, blocked: fresh ? '' : SNAPSHOT_BLOCKED };
|
|
62
|
-
};
|
|
63
|
-
|
|
64
55
|
// before and after map a line's id to its text; the ids of the lines that are new or read differently flash once.
|
|
65
56
|
const changedLines = (before, after) => Object.keys(after).filter((id) => globalThis.BoardUI.record(before, id) !== after[id]);
|
|
66
57
|
|
|
67
|
-
// What counts as a card's update: a new thread line, a status or Next line change, or a PR or CI change
|
|
58
|
+
// What counts as a card's update: a new thread line, a lane, status or Next line change, or a PR or CI change
|
|
68
59
|
// (docs/adr/2026-11-02-2354-an-updated-card-takes-a-brief-tint.md). Two board items read the same exactly when it is unchanged.
|
|
69
|
-
const updateSign = (it) => JSON.stringify([it.threadCount ?? 0, it.
|
|
60
|
+
const updateSign = (it) => JSON.stringify([it.threadCount ?? 0, it.lane ?? null, it.builder?.progress ?? null, it.working ?? null, it.waitsOn ?? null,
|
|
70
61
|
it.next ?? null, it.ask?.state ?? null, ...(it.prs || []).map((pr) => [pr.state, pr.isDraft, pr.reviewDecision, pr.checks?.state ?? null])]);
|
|
71
62
|
|
|
72
|
-
Object.assign(globalThis.BoardUI, {
|
|
63
|
+
Object.assign(globalThis.BoardUI, { copyOf, readCopy, copyStore, pageCopy, boardCopy, preloadKeys, changedLines, updateSign });
|
|
73
64
|
})();
|
|
@@ -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, dropZone, sendButton, editButton, withdrawButton,
|
|
4
|
+
const { esc, escText, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, thinkingLine, 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) {
|
|
@@ -15,52 +15,79 @@
|
|
|
15
15
|
const stateOf = (m) => m.state || (m.reply ? 'replied' : 'waiting');
|
|
16
16
|
// A message's footer: the tap it came from (m.status, such as "answers submitted"), then, to its right, Claude's
|
|
17
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:
|
|
18
|
+
const PICK_UP = { working: thinkingLine, waiting: () => 'Waiting for Claude…' };
|
|
19
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);
|
|
20
|
+
const parts = [m.status && `<span class="foot-status">${statusMark(m.status)}${esc(m.status)}</span>`, PICK_UP[stateOf(m)]?.()].filter(Boolean);
|
|
21
21
|
return parts.length ? `<div class="reply muted small">${parts.join('')}</div>` : '';
|
|
22
22
|
};
|
|
23
23
|
// Each reply becomes Claude's own message at the time it was written, quoting the owner's message
|
|
24
24
|
// when other messages came between them. A reply with no time follows its message.
|
|
25
25
|
const withReplies = (msgs) => {
|
|
26
|
+
// A folded submit's answers each reply, quoting their own block.
|
|
26
27
|
const out = msgs.flatMap((m, i) => [{ m, at: String(m.ts), i, part: 0 },
|
|
27
|
-
...(m.eventId &&
|
|
28
|
+
...(m.answers || [m]).filter((a) => a.eventId && a.reply).map((a) => ({ m: { ts: a.reply.at || m.ts, who: 'claude', kind: 'reply', reply: a.reply }, of: m, quoted: m.answers ? a.text : m.text, at: String(a.reply.at || m.ts), i, part: 1 }))]);
|
|
28
29
|
out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
|
|
29
|
-
return out.map(({ m, of }, n) => (of ? { ...m, quote: out[n - 1]?.m === of ? '' :
|
|
30
|
+
return out.map(({ m, of, quoted }, n) => (of ? { ...m, quote: out[n - 1]?.m === of && !(of.answers?.length > 1) ? '' : quoted || '' } : m));
|
|
30
31
|
};
|
|
31
32
|
// A message being edited shows its edit box in place of its text; app-asks.js handles the form, and attach.js the
|
|
32
33
|
// files it adds to the message, under composer id edit:<event id>.
|
|
33
34
|
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>`)}
|
|
34
35
|
<div class="pending-files" data-files></div>
|
|
35
36
|
<div class="send-actions"><button type="button" data-edit-cancel>Cancel</button>${sendButton('Save', 0)}</div></form>`;
|
|
36
|
-
|
|
37
|
-
const line = (m, links, edits, batched = false) => (m.kind === 'reply'
|
|
37
|
+
const line = (m, links, edits) => (m.kind === 'reply'
|
|
38
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>`
|
|
39
|
-
: m.kind === 'ask-link' ? `<div class="msg link"
|
|
40
|
-
|
|
41
|
-
: m.kind === '
|
|
39
|
+
: m.kind === 'ask-link' ? `<div class="msg link">${statusMark('ask-link')}<a href="${esc(m.link)}">${escText(m.text)} →</a></div>`
|
|
40
|
+
// docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md: a visual's page opens full page.
|
|
41
|
+
: m.kind === 'visual' ? `<div class="msg link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
42
|
+
// A request line's mark is its words' icon, or m.mark's for a line whose words vary; a Full person's names them.
|
|
43
|
+
: m.request && m.card ? `<div class="msg request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
44
|
+
: m.request ? `<div class="msg request">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
|
|
45
|
+
// docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md: a card's move between lanes.
|
|
46
|
+
: m.kind === 'move' ? `<div class="msg request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
47
|
+
: m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
48
|
+
: m.folded ? foldedRun(m, links, edits)
|
|
42
49
|
: 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')}${
|
|
44
|
-
: m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${
|
|
50
|
+
: m.kind === 'calls' ? `<details class="msg calls"><summary>${statusMark('judgment calls')}${escText(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>`
|
|
51
|
+
: m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${escText(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)}"` : ''}>
|
|
45
52
|
${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
|
|
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>
|
|
53
|
+
<div class="who">${m.who === 'love' ? (m.by ? esc(m.by) : '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>
|
|
47
54
|
<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)}
|
|
48
55
|
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log</a></div>` : ''}
|
|
49
56
|
${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
|
|
50
|
-
${m.eventId
|
|
57
|
+
${m.eventId ? msgFooter(m) : ''}
|
|
51
58
|
</div>`);
|
|
52
59
|
|
|
60
|
+
// One agent run (domain.mjs threadOf gives its started line, status lines and finished line the same `run`) is one
|
|
61
|
+
// closed details line at its started line's place: the agent, running with its newest status text or finished, and
|
|
62
|
+
// no line count, and ending with a log link beside the summary when the run has a log; expanding it shows the run's lines.
|
|
63
|
+
function foldedRun(m, links, edits) {
|
|
64
|
+
const statuses = m.folded.filter((f) => f.kind === 'status');
|
|
65
|
+
const running = !m.folded.some((f) => f.finished);
|
|
66
|
+
const newest = statuses.at(-1);
|
|
67
|
+
const tail = running ? (newest ? `running · ${escText(newest.text)}` : 'running') : 'finished';
|
|
68
|
+
const log = m.folded.find((f) => f.log)?.log;
|
|
69
|
+
const logLink = log ? ` <a class="fold-log" href="log.html?file=${encodeURIComponent(log)}">log</a>` : '';
|
|
70
|
+
return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${running ? '' : ' class="finished"'}>${statusMark('status lines')}${escText(m.agent)} · ${tail}${logLink}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}</details>`;
|
|
71
|
+
}
|
|
72
|
+
function foldStatus(lines) {
|
|
73
|
+
const runs = new Map();
|
|
74
|
+
for (const m of lines) if (m.run && (m.kind === 'status' || m.kind === 'agent')) runs.set(m.run, [...(runs.get(m.run) || []), m]);
|
|
75
|
+
return lines.flatMap((m) => {
|
|
76
|
+
const run = m.run && (m.kind === 'status' || m.kind === 'agent') ? runs.get(m.run) : null;
|
|
77
|
+
if (!run) return [m];
|
|
78
|
+
return m === run[0] ? [{ ...m, folded: run }] : [];
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
53
82
|
const STEP = 3;
|
|
54
83
|
const UNREAD_CAP = 10;
|
|
55
|
-
|
|
84
|
+
// Every kind of system line; each leads with a mark, and a test renders each, so a new kind needs an entry in ui.js MARK_ICONS.
|
|
85
|
+
const SYSTEM_KINDS = ['status', 'agent', 'calls', 'ask-link', 'questions', 'visual', 'command', 'move'];
|
|
86
|
+
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions', 'move']);
|
|
56
87
|
const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
|
|
57
88
|
// Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount); the
|
|
58
89
|
// orchestrator's answer to a builder's ask never is.
|
|
59
|
-
const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
|
|
60
|
-
const batchedAt = (lines, i) => {
|
|
61
|
-
const next = lines.slice(i + 1).find(isMessage);
|
|
62
|
-
return lines[i].kind === 'owner-answer' && next?.kind === 'owner-answer' && stateOf(next) === stateOf(lines[i]);
|
|
63
|
-
};
|
|
90
|
+
const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.kind !== 'command' && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
|
|
64
91
|
// The window opens from the oldest unread message, at most UNREAD_CAP messages back; a message being edited always shows.
|
|
65
92
|
function threadWindow(lines, { seenAt = null, more = 0, upto = null }, edits) {
|
|
66
93
|
const counted = lines.flatMap((m, i) => (isMessage(m) && (!upto || String(m.ts) <= upto) ? [i] : []));
|
|
@@ -79,21 +106,23 @@
|
|
|
79
106
|
// draws the open card's window, so lines after `upto` add below without hiding older ones; null draws every line.
|
|
80
107
|
function threadMessages(msgs, links = {}, edits = new Map(), view = null) {
|
|
81
108
|
if (!msgs.length) return '<div class="muted small">No messages here yet.</div>';
|
|
82
|
-
const lines = withReplies(msgs);
|
|
109
|
+
const lines = foldStatus(withReplies(msgs));
|
|
83
110
|
const { start, hidden } = view ? threadWindow(lines, view, edits) : { start: 0, hidden: 0 };
|
|
84
111
|
const shown = lines.slice(start);
|
|
85
112
|
if (start && shown[0].kind === 'reply' && !shown[0].quote) shown[0] = { ...shown[0], quote: lines[start - 1].text || '' };
|
|
86
|
-
return (hidden ? moreButton(hidden) : '') + shown.map((m
|
|
113
|
+
return (hidden ? moreButton(hidden) : '') + shown.map((m) => line(m, links, edits)).join('');
|
|
87
114
|
}
|
|
88
115
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
116
|
+
// docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md
|
|
117
|
+
// An In Review card's Done, the second of its thread box's pair after Reply, on ⌥↵. It confirms and sends ask-done or
|
|
118
|
+
// card-done (app-cards.js); the board applies it before the session follows up
|
|
119
|
+
// (docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md).
|
|
91
120
|
// Orange while it clears an ask's Action needed, primary while a reopened card waits only on it
|
|
92
121
|
// (docs/adr/2026-11-03-0137-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
|
|
122
|
+
const doneTone = (it) => (it.resolvedBy === BUTTON.done ? 'resolves' : it.awaitsDone ? 'closes' : '');
|
|
93
123
|
function doneButton(it) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return `<button type="button"${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}">Done</button>`;
|
|
124
|
+
const tone = doneTone(it);
|
|
125
|
+
return sendButton('Done', 1, `${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}"`);
|
|
97
126
|
}
|
|
98
127
|
|
|
99
128
|
// docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
|
|
@@ -105,5 +134,5 @@
|
|
|
105
134
|
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
135
|
}
|
|
107
136
|
|
|
108
|
-
Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton, newQuestionsLine });
|
|
137
|
+
Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton, doneTone, newQuestionsLine, SYSTEM_KINDS });
|
|
109
138
|
})();
|
package/board/public/ui.js
CHANGED
|
@@ -53,8 +53,9 @@
|
|
|
53
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>';
|
|
54
54
|
const attachButton = () => `<label class="attach" title="Attach files"><input type="file" multiple data-attach aria-label="Attach files">${CLIP}</label>`;
|
|
55
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
|
-
|
|
56
|
+
// attach while files are dragged over the composer. attach: false leaves the button out, for a composer that puts it
|
|
57
|
+
// on its row (app-threads.js).
|
|
58
|
+
const dropZone = (textarea, { attach = true } = {}) => `<div class="dz${attach ? '' : ' bare'}">${textarea}${attach ? attachButton() : ''}<div class="dz-hint" aria-hidden="true">Drop to attach</div></div>`;
|
|
58
59
|
// docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md
|
|
59
60
|
// A composer's send buttons: the one with nth 0 presses on ⌘↵ and the one with nth 1, where there is one, on ⌥↵,
|
|
60
61
|
// wherever each sits in the row (Ctrl+↵ and
|
|
@@ -71,27 +72,46 @@
|
|
|
71
72
|
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>`;
|
|
72
73
|
// An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
|
|
73
74
|
const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
|
|
75
|
+
// Escaped text with each ↗ (and a trailing U+FE0E) drawn as LINK_ARROW; for owner-facing text, never an attribute.
|
|
76
|
+
const escText = (s) => esc(s).replace(/\u2197\uFE0E?/g, LINK_ARROW);
|
|
74
77
|
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"/>');
|
|
75
78
|
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"/>');
|
|
76
79
|
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
|
|
80
|
+
const IMAGE_PATHS = '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>';
|
|
78
81
|
// Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md): a
|
|
79
82
|
// question's eye sends at once; a thread's eye is a toggle that Send carries (app-threads.js).
|
|
80
83
|
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
84
|
// The thread's fire, the eye's twin for a grill.
|
|
82
85
|
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
|
-
//
|
|
84
|
-
|
|
86
|
+
// The thread's image, asking for a visual: a page an agent draws to answer in the thread (app-threads.js;
|
|
87
|
+
// docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md).
|
|
88
|
+
const visualButton = (id) => `<button type="button" class="attach proto-ask" data-visual="${esc(id)}" aria-label="Ask for a visual" title="Ask for a visual">${icon(IMAGE_PATHS)}</button>`;
|
|
85
89
|
// docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
|
|
86
90
|
// 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:
|
|
91
|
+
// same place: a command line's icon by its words (domain.mjs COMMANDS: the request's eye, fire or image, Implement's
|
|
92
|
+
// code brackets, Hold's pause, Resume's play, Done's circled check), Reply's arrow, answers' check, a reopened
|
|
93
|
+
// question's turn-back arrow, new questions' plain question mark, a builder's dot.
|
|
88
94
|
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>`;
|
|
95
|
+
const LINK = mini('<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/>');
|
|
89
96
|
const MARK_ICONS = {
|
|
90
|
-
'
|
|
91
|
-
'
|
|
92
|
-
'
|
|
97
|
+
'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"/>'),
|
|
98
|
+
'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"/>'),
|
|
99
|
+
'Visual requested': mini(IMAGE_PATHS),
|
|
100
|
+
'Re-ask requested': mini('<path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/><path d="M3 21v-5h5"/>'),
|
|
101
|
+
visual: mini(IMAGE_PATHS),
|
|
102
|
+
'ask-link': mini('<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>'),
|
|
103
|
+
move: mini('<path d="M3 7h14"/><path d="m13 3 4 4-4 4"/><path d="M21 17H7"/><path d="m11 13-4 4 4 4"/>'),
|
|
104
|
+
'Implementation requested': mini('<path d="m8 6-6 6 6 6"/><path d="m16 6 6 6-6 6"/>'),
|
|
105
|
+
Held: mini('<path d="M9 5v14"/><path d="M15 5v14"/>'),
|
|
106
|
+
Resumed: mini('<path d="M7 4v16l13-8z"/>'),
|
|
107
|
+
'Related to': LINK, 'Unrelated from': LINK, 'Follow-up': LINK, 'Follow-up of': LINK,
|
|
108
|
+
'Marked done': mini('<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>'),
|
|
109
|
+
Reply: mini('<path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 6 6v5"/>'),
|
|
110
|
+
Implement: mini('<path d="m8 6-6 6 6 6"/><path d="m16 6 6 6-6 6"/>'),
|
|
93
111
|
'answers submitted': mini('<path d="M20 6 9 17l-5-5"/>'),
|
|
112
|
+
'question reopened': mini('<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>'),
|
|
94
113
|
'judgment calls': mini('<path d="m9 6 6 6-6 6"/>'),
|
|
114
|
+
'status lines': mini('<path d="m9 6 6 6-6 6"/>'),
|
|
95
115
|
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
116
|
};
|
|
97
117
|
const mark = (inner = '') => `<span class="mark" aria-hidden="true">${inner}</span>`;
|
|
@@ -110,6 +130,12 @@
|
|
|
110
130
|
? `<button class="ghost icon bumped" data-card-bump="${esc(key)}" data-bumped aria-pressed="true" aria-label="Unbump" title="Bumped. Tap to unbump">${BUMP}</button>`
|
|
111
131
|
: `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
|
|
112
132
|
const workingLine = '<span class="thinking"><span class="working-dot" aria-hidden="true"></span>thinking</span>';
|
|
133
|
+
// Every paused status, whatever its place, is this one class (style.css .paused); BoardUI.usagePause is set by the
|
|
134
|
+
// board view (app.js) to read the hub's usage pause.
|
|
135
|
+
const PAUSED = '<span class="paused">paused</span>';
|
|
136
|
+
const isPaused = () => BoardUI.usagePause?.()?.state === 'paused';
|
|
137
|
+
// The orchestrator's "thinking" while its usage allows, else paused.
|
|
138
|
+
const thinkingLine = () => (isPaused() ? PAUSED : workingLine);
|
|
113
139
|
// html is markup the caller has escaped; href, title and attrs values are escaped here.
|
|
114
140
|
const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready', 'merged']);
|
|
115
141
|
function badge(tone, html, { href, title, button = false, attrs = {} } = {}) {
|
|
@@ -131,11 +157,11 @@
|
|
|
131
157
|
// under el it was, so a card that moves lanes keeps its open thread. A textarea keeps what was typed in it, and an
|
|
132
158
|
// element matching opts.keep stays as it is, for markup another part paints. The focused field keeps its focus when
|
|
133
159
|
// its card moves (focusSafe). setHTML skips an unchanged rebuild, and reopens each <details> the reader left open,
|
|
134
|
-
// matched by its owning [data-key] and summary text. Open state is tracked page-wide, so content filled in after a
|
|
160
|
+
// matched by its owning [data-key] and summary text (a card itself by its key alone, its summary text changing as it ages). Open state is tracked page-wide, so content filled in after a
|
|
135
161
|
// parent rebuild reopens too.
|
|
136
162
|
const lastHtml = new WeakMap();
|
|
137
163
|
const openDetails = new Set();
|
|
138
|
-
const detailsKey = (d) => `${(d.closest('[data-key]') || {}).dataset?.key || ''}|${(d.querySelector(':scope > summary')?.textContent || '').trim()}`;
|
|
164
|
+
const detailsKey = (d) => d.matches('[data-key]') ? d.dataset.key : `${(d.closest('[data-key]') || {}).dataset?.key || ''}|${(d.querySelector(':scope > summary')?.textContent || '').trim()}`;
|
|
139
165
|
globalThis.document?.addEventListener('toggle', (ev) => {
|
|
140
166
|
if (ev.target.tagName !== 'DETAILS') return;
|
|
141
167
|
if (ev.target.open) openDetails.add(detailsKey(ev.target)); else openDetails.delete(detailsKey(ev.target));
|
|
@@ -342,11 +368,57 @@
|
|
|
342
368
|
},
|
|
343
369
|
};
|
|
344
370
|
|
|
371
|
+
// Why a board request failed, from what the hub marks on its answer (hub/hub.mjs sendDown, hub/front-door.mjs refuse)
|
|
372
|
+
// (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md). answer is the response, or null when
|
|
373
|
+
// nothing answered or the request timed out; online is navigator.onLine. → {state, line, hint, retries}: the landing's
|
|
374
|
+
// two lines, and whether the board can come back by itself, so the page keeps retrying.
|
|
375
|
+
const BACK = 'This page comes back by itself.';
|
|
376
|
+
const DOWN = {
|
|
377
|
+
restarting: { line: 'Board restarting' },
|
|
378
|
+
stopped: { line: 'Board stopped', hint: 'Nothing answers on its port. Its Claude session has to start it.' },
|
|
379
|
+
removed: { line: 'This board is gone', hint: 'The hub no longer lists it.', retries: false },
|
|
380
|
+
access: { line: 'You no longer have access', hint: 'Ask its owner to share it again.', retries: false },
|
|
381
|
+
erroring: { line: 'Board restarting', hint: 'It answers with errors. Retrying for 30 seconds.' },
|
|
382
|
+
unreachable: { line: 'Board offline', hint: 'The hub, your connection or the Mac is down. Retrying.' },
|
|
383
|
+
};
|
|
384
|
+
const RESTART_WHY = { code: 'Its code changed.', starting: 'It’s starting.', crashed: 'It stopped, and the hub is starting it again.' };
|
|
385
|
+
function downOf(answer, online = navigator.onLine !== false) {
|
|
386
|
+
const why = online && answer ? answer.headers.get('x-kickflip-down') : null;
|
|
387
|
+
const state = !online || !answer ? 'unreachable'
|
|
388
|
+
: RESTART_WHY[why] ? 'restarting'
|
|
389
|
+
: DOWN[why] ? why
|
|
390
|
+
: answer.headers.get('x-app-shell') ? 'access'
|
|
391
|
+
: why || answer.headers.get('x-kickflip-hub') ? 'erroring' : 'unreachable';
|
|
392
|
+
return { state, retries: true, ...DOWN[state], ...(state === 'restarting' ? { hint: `${RESTART_WHY[why]} ${BACK}` } : {}) };
|
|
393
|
+
}
|
|
394
|
+
// A view's place when it has no copy to show: the kickflip mark and down's two lines, as the no-access page has them.
|
|
395
|
+
const downLanding = (down) => `<div class="down-landing" data-down="${down.state}"><img class="down-landing-mark" src="/_hub/kickflip.png" width="70" height="60" alt="">
|
|
396
|
+
<p class="down-landing-line">${esc(down.line)}</p><p class="down-landing-hint">${esc(down.hint)}</p></div>`;
|
|
397
|
+
// The line over a side page painted from its copy while the board can't be reached.
|
|
398
|
+
const copyNote = (what, savedAt) => `<p class="note copy-note">Copy of ${esc(what)} as of ${since(savedAt)}. Read only.</p>`;
|
|
399
|
+
// The buttons that send, which a board out of reach holds (style.css .offline): every one greyed and stopped, keeping its
|
|
400
|
+
// label and size. Reading controls, folds, links, Edit and drafts stay live.
|
|
401
|
+
const SENDS = `form button[type="submit"], .actions button, button[data-confirm], button[data-deck-pick], button[data-review-answer], button[data-prompt-send], button[data-prompt-choice], button[data-quick], button[data-card-bump], button[data-bump], button[data-withdraw], button[data-band-move], button[data-grill], button[data-proto], button[data-visual], button[data-plan], button[data-main-pull], button[data-arch-run], button[data-try], button[data-reopen]`;
|
|
402
|
+
// While held(), a tap on one of root's sending buttons, and any form's submit there, Enter's included, goes nowhere, and onHeld
|
|
403
|
+
// tells the tap it is waiting. on is the view's B.on.
|
|
404
|
+
function holdSends(on, root, held, onHeld) {
|
|
405
|
+
const stop = (ev) => {
|
|
406
|
+
if (!held() || (ev.type === 'click' && !ev.target.closest?.(SENDS))) return;
|
|
407
|
+
ev.preventDefault();
|
|
408
|
+
ev.stopImmediatePropagation();
|
|
409
|
+
onHeld();
|
|
410
|
+
};
|
|
411
|
+
on(root, 'click', stop, true);
|
|
412
|
+
on(root, 'submit', stop, true);
|
|
413
|
+
}
|
|
414
|
+
|
|
345
415
|
// While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
|
|
346
|
-
// on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
|
|
347
|
-
// then throws once about limit ms have passed. Any other answer resolves
|
|
416
|
+
// on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs, down) and retries with a growing backoff,
|
|
417
|
+
// then throws once about limit ms have passed, the error carrying down (downOf). Any other answer resolves
|
|
418
|
+
// {ok, status, body}, a 404's {error} included.
|
|
348
419
|
// tag is the version of the board the page holds: an unchanged board answers 304, which resolves with body null
|
|
349
420
|
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
421
|
+
const WAITING = 'Waiting for the board to reconnect';
|
|
350
422
|
const RESTARTING = 'Board restarting…';
|
|
351
423
|
const restartNotice = () => `<span class="muted small restarting">${RESTARTING}</span>`;
|
|
352
424
|
async function fetchJSON(url, { onWait = () => {}, limit = 30000, tag = null, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
@@ -354,15 +426,18 @@
|
|
|
354
426
|
let delay = 500;
|
|
355
427
|
for (;;) {
|
|
356
428
|
let failure;
|
|
429
|
+
let answer = null;
|
|
357
430
|
try {
|
|
358
431
|
const r = await get(url, { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) });
|
|
359
432
|
if (r.status === 304) return { ok: true, status: 304, body: null };
|
|
360
433
|
const body = await r.text().then((t) => JSON.parse(t)).catch(() => undefined);
|
|
361
434
|
if (body !== undefined && r.status < 500) return { ok: r.ok, status: r.status, body };
|
|
435
|
+
answer = r;
|
|
362
436
|
failure = new Error(r.status >= 500 ? `the board answered ${r.status}` : 'the board did not answer with JSON');
|
|
363
437
|
} catch { failure = new Error('Offline. Is the host awake?'); }
|
|
438
|
+
failure.down = downOf(answer);
|
|
364
439
|
if (waited >= limit) throw failure;
|
|
365
|
-
onWait(waited);
|
|
440
|
+
onWait(waited, failure.down);
|
|
366
441
|
await sleep(delay);
|
|
367
442
|
waited += delay;
|
|
368
443
|
delay = Math.min(delay * 2, 4000);
|
|
@@ -370,8 +445,8 @@
|
|
|
370
445
|
}
|
|
371
446
|
|
|
372
447
|
globalThis.BoardUI = {
|
|
373
|
-
LINK_ARROW,
|
|
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,
|
|
448
|
+
LINK_ARROW, escText,
|
|
449
|
+
esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, WAITING, RESTARTING, restartNotice, downOf, downLanding, copyNote, SENDS, holdSends,
|
|
450
|
+
attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, visualButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, thinkingLine, isPaused, PAUSED, BADGE_TONES, badge,
|
|
376
451
|
};
|
|
377
452
|
})();
|
|
@@ -1,5 +1,5 @@
|
|
|
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
|
-
// settingsView's words from GET /_hub/api/settings. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
|
|
2
|
+
// settingsView's words from GET /_hub/api/settings. Pause hub, first, posts to /_hub/settings/hub-pause. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
|
|
3
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.
|
|
@@ -9,28 +9,10 @@
|
|
|
9
9
|
const { esc } = HubUI;
|
|
10
10
|
const ACTION = '/_hub/settings/notifications';
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
};
|
|
17
|
-
function customBounds(input, reset = false) {
|
|
18
|
-
const now = Date.now();
|
|
19
|
-
input.min = localInput(new Date(now + 60_000));
|
|
20
|
-
input.max = localInput(new Date(now + 30 * 86_400_000));
|
|
21
|
-
if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// The one duration picker Snooze and Keep awake share (hub/duration.mjs): chips that post `name` to `action` at once,
|
|
25
|
-
// then Custom, whose date and time posts as `name` too. While its setting is on, `on` replaces it with when it ends
|
|
26
|
-
// and a button that posts `name`=off.
|
|
27
|
-
function durationPicker({ id, title, icon, action, name, choices, on, button }) {
|
|
28
|
-
if (on) return `<div class="row dur-on">${icon}<span class="body"><span class="text title">${esc(on.title)}</span><form method="post" action="${action}"><button class="btn quiet" name="${name}" value="off">${button}</button></form></span></div>`;
|
|
29
|
-
if (!choices) return '';
|
|
30
|
-
const chips = choices.map((c) => `<button class="btn quiet" name="${name}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
|
|
31
|
-
return `<div class="block" role="group" aria-labelledby="${id}Title"><div class="dur-head">${icon}<span class="title" id="${id}Title">${title}</span></div><div class="dur-chips"><form class="dur-choices" method="post" action="${action}">${chips}</form><form class="dur-custom" method="post" action="${action}"><input type="hidden" name="${name}"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-dur-at required aria-label="Custom ${title.toLowerCase()} until a date and time"></form></div></div>`;
|
|
32
|
-
}
|
|
33
|
-
|
|
12
|
+
const { durationPicker } = HubKit;
|
|
13
|
+
// Pause hub (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md), gray as every paused status.
|
|
14
|
+
const pauseRow = (p) => durationPicker({ id: 'pauseHub', title: 'Pause hub', icon: HubUI.tile('pause', 'var(--color-quiet)'),
|
|
15
|
+
action: '/_hub/settings/hub-pause', name: 'pause', choices: p.choices, on: p.on, button: 'Resume' });
|
|
34
16
|
const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
|
|
35
17
|
choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
|
|
36
18
|
// Grey while off and orange while on, as the machine working (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md).
|
|
@@ -58,7 +40,7 @@
|
|
|
58
40
|
+ `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
|
|
59
41
|
}
|
|
60
42
|
|
|
61
|
-
const sections = [['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['slack', 'Slack'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
43
|
+
const sections = [['pause', 'Pause hub'], ['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['slack', 'Slack'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
62
44
|
const sectionOf = (hash) => {
|
|
63
45
|
const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
|
|
64
46
|
return sections.some(([section]) => section === id) ? id : null;
|
|
@@ -80,13 +62,16 @@
|
|
|
80
62
|
let rules = null;
|
|
81
63
|
let rulesJSON = '';
|
|
82
64
|
let tokenSlot = null;
|
|
65
|
+
const pickers = HubKit.durationPickers(B);
|
|
83
66
|
|
|
84
67
|
function paint() {
|
|
85
68
|
if (!last) return;
|
|
86
69
|
const { row, tile, deviceRow } = HubUI;
|
|
87
70
|
top.innerHTML = HubUI.flash(flash);
|
|
88
71
|
const scrollTop = B.$('.settings-body').scrollTop;
|
|
89
|
-
if (active === '
|
|
72
|
+
if (active === 'pause') {
|
|
73
|
+
content.innerHTML = `<div class="group">${pauseRow(last.pause)}</div><p class="note">Running steps finish. No new builders, planners or pulls start on any board until then, as when usage reaches the pause line.</p>`;
|
|
74
|
+
} else if (active === 'notifications') {
|
|
90
75
|
content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}</div>`;
|
|
91
76
|
} else if (active === 'quiet') {
|
|
92
77
|
content.innerHTML = quietRows(last.quiet);
|
|
@@ -103,11 +88,11 @@
|
|
|
103
88
|
} else if (active === 'closed') {
|
|
104
89
|
content.innerHTML = `<div class="group">${(last.closed || []).length ? last.closed.map((b) => `<div class="row"><span class="body"><span class="text"><span class="title">${esc(b.title)}</span><span class="sub wrap">/${esc(b.route)}</span></span><button class="btn" type="button" data-reopen="${esc(b.route)}"${reopening === b.route ? BoardUI.BUSY : reopening ? ' disabled' : ''}>Reopen</button></span></div>`).join('') : '<p class="note">No closed boards.</p>'}</div>`;
|
|
105
90
|
}
|
|
106
|
-
|
|
107
|
-
if (input) customBounds(input, true);
|
|
91
|
+
pickers.painted();
|
|
108
92
|
B.$('.settings-body').scrollTop = scrollTop;
|
|
109
93
|
B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
|
|
110
94
|
const summaries = {
|
|
95
|
+
pause: last.pause.summary,
|
|
111
96
|
notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
|
|
112
97
|
quiet: last.quiet.summary,
|
|
113
98
|
'keep-awake': last.awake.summary,
|
|
@@ -207,37 +192,6 @@
|
|
|
207
192
|
B.atUnmount(() => closeModal(false));
|
|
208
193
|
const device = HubKit.device(B, { paint, changed: () => ctx.sidebarChanged?.() });
|
|
209
194
|
device.ready.then(() => { if (B.alive()) paint(); });
|
|
210
|
-
let openPicker = null;
|
|
211
|
-
B.on(root, 'click', (e) => {
|
|
212
|
-
const input = e.target.closest?.('[data-dur-at]');
|
|
213
|
-
if (!input) return;
|
|
214
|
-
openPicker = input;
|
|
215
|
-
customBounds(input, input.value === input.defaultValue);
|
|
216
|
-
try { input.showPicker(); } catch { /* iOS opens its wheel on the tap itself. */ }
|
|
217
|
-
});
|
|
218
|
-
// A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
|
|
219
|
-
B.on(root, 'pointerdown', (e) => {
|
|
220
|
-
const input = e.target.closest?.('.dur-choices button') && B.$('[data-dur-at]');
|
|
221
|
-
if (!input) return;
|
|
222
|
-
openPicker = null;
|
|
223
|
-
input.value = input.defaultValue;
|
|
224
|
-
});
|
|
225
|
-
// Closing an opened picker accepts its suggested time; an unchanged field reached by Tab does nothing.
|
|
226
|
-
const saveCustom = (input) => {
|
|
227
|
-
if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
|
|
228
|
-
const at = new Date(input.value);
|
|
229
|
-
if (Number.isNaN(at.getTime())) return;
|
|
230
|
-
input.form.querySelector('input[type="hidden"]').value = at.toISOString();
|
|
231
|
-
input.form.requestSubmit();
|
|
232
|
-
openPicker = null;
|
|
233
|
-
input.defaultValue = input.value;
|
|
234
|
-
};
|
|
235
|
-
B.on(root, 'change', (e) => {
|
|
236
|
-
if (e.target.matches?.('[data-dur-at]') && document.activeElement !== e.target) saveCustom(e.target);
|
|
237
|
-
});
|
|
238
|
-
B.on(root, 'focusout', (e) => {
|
|
239
|
-
if (e.target.matches?.('[data-dur-at]')) saveCustom(e.target);
|
|
240
|
-
});
|
|
241
195
|
B.on(root, 'click', async (e) => {
|
|
242
196
|
const button = e.target.closest?.('[data-reopen]');
|
|
243
197
|
if (!button || reopening) return;
|