@lovek4d/kickflip 0.3.1 → 0.5.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 +170 -118
- package/README.md +23 -14
- package/SKILL.md +40 -38
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +34 -26
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/auto-review.mjs +2 -0
- package/board/bin/board.mjs +213 -74
- package/board/domain.mjs +8 -5
- package/board/finish.mjs +4 -0
- package/board/headless.mjs +4 -0
- package/board/merge3.mjs +1 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +2 -2
- package/board/public/app-asks.js +39 -103
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +166 -73
- package/board/public/app-core.js +2 -23
- package/board/public/app-header.js +12 -5
- package/board/public/app-lanes.js +201 -47
- package/board/public/app-needs.js +2 -1
- package/board/public/app-reader.js +174 -0
- package/board/public/app-snapshot.js +2 -2
- package/board/public/app-threads.js +53 -42
- package/board/public/app.html +4 -1
- package/board/public/app.js +78 -96
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +59 -14
- package/board/public/hub-kit.js +9 -6
- package/board/public/hub.css +6 -18
- package/board/public/linear-key.js +6 -4
- package/board/public/log.html +48 -0
- package/board/public/new-task.js +121 -0
- package/board/public/prompts.js +9 -8
- package/board/public/prototype-kit.js +6 -6
- package/board/public/prototype-switcher.js +13 -5
- package/board/public/prototype.html +1 -0
- package/board/public/router.js +8 -7
- package/board/public/rules-editor.js +3 -4
- package/board/public/shell.js +24 -6
- package/board/public/sidebar.css +22 -1
- package/board/public/sidebar.js +64 -31
- package/board/public/style.css +245 -64
- package/board/public/toasts.js +2 -2
- package/board/public/ui-cards.js +205 -75
- package/board/public/ui-enums.js +6 -5
- package/board/public/ui-header.js +10 -10
- package/board/public/ui-hub.js +4 -4
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +9 -6
- package/board/public/ui-page-header.js +10 -7
- package/board/public/ui-patch.js +53 -0
- package/board/public/ui-phase-bars.js +17 -0
- package/board/public/ui-prompts.js +8 -8
- package/board/public/ui-questions.js +61 -29
- package/board/public/ui-sidebar.js +54 -24
- package/board/public/ui-snapshot.js +20 -9
- package/board/public/ui-threads.js +40 -21
- package/board/public/ui.js +116 -18
- package/board/public/view-frame.js +2 -1
- package/board/public/view-hub-settings.js +25 -18
- package/board/public/view-hub.js +53 -12
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +142 -62
- package/board/public/view-settings.js +189 -52
- package/board/review.mjs +1 -1
- package/board/server.mjs +20 -12
- package/board/store.mjs +15 -12
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +21 -15
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
- package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/front-door.mjs +20 -0
- package/hub/github.mjs +4 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +11 -14
- package/hub/incoming.mjs +2 -0
- package/hub/linear.mjs +2 -2
- package/hub/live.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- package/hub/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -1
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -2
- package/hub/repos.mjs +3 -3
- package/hub/restart-board.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/sw.js +1 -1
- package/hub/try.mjs +8 -4
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +5 -1
- package/shared/card-prs.mjs +1 -0
- package/shared/colors.css +5 -4
- package/shared/fake-gh.mjs +59 -8
- package/shared/owner-check.mjs +3 -0
- package/shared/repos.mjs +1 -1
- package/shared/tailscale.mjs +1 -0
- package/shared/usage-gate.mjs +1 -1
- package/board/public/view-polling.js +0 -87
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// A card's thread:
|
|
1
|
+
// A card's thread: the orchestrator's lines (who claude, named Orchestrator), its builders' asks, the owner's messages and
|
|
2
|
+
// their replies. Needs ui.js, ui-links.js and ui-enums.js.
|
|
2
3
|
(function () {
|
|
3
|
-
const { esc, time, etaLine, replyBody, attachments, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, BUTTON } = globalThis.BoardUI;
|
|
4
|
+
const { esc, time, etaLine, replyBody, attachments, attachButton, dropZone, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
|
|
4
5
|
const VIA = { slack: ' · Slack', github: ' · GitHub' };
|
|
5
6
|
// A link to a card on this board (#item=<key>) or on another board (/<route>/#item=<key>, an ask's move), or ''.
|
|
6
7
|
function cardLink(link) {
|
|
@@ -12,10 +13,13 @@
|
|
|
12
13
|
}
|
|
13
14
|
// A message's state comes from domain.mjs threadOf.
|
|
14
15
|
const stateOf = (m) => m.state || (m.reply ? 'replied' : 'waiting');
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
};
|
|
19
23
|
// Each reply becomes Claude's own message at the time it was written, quoting the owner's message
|
|
20
24
|
// when other messages came between them. A reply with no time follows its message.
|
|
21
25
|
const withReplies = (msgs) => {
|
|
@@ -24,29 +28,35 @@
|
|
|
24
28
|
out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
|
|
25
29
|
return out.map(({ m, of }, n) => (of ? { ...m, quote: out[n - 1]?.m === of ? '' : of.text || '' } : m));
|
|
26
30
|
};
|
|
27
|
-
// A message being edited shows its edit box in place of its text; app-asks.js handles the form.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
// A message being edited shows its edit box in place of its text; app-asks.js handles the form, and attach.js the
|
|
32
|
+
// files it adds to the message, under composer id edit:<event id>.
|
|
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>`)}
|
|
34
|
+
<div class="pending-files" data-files></div>
|
|
35
|
+
<div class="row">${attachButton()}<span><button type="button" data-edit-cancel>Cancel</button> <button class="primary" type="submit">Save</button></span></div></form>`;
|
|
36
|
+
// batched: an answer followed by another of the owner's answers in its state, which carries the batch's footer.
|
|
31
37
|
const line = (m, links, edits, batched = false) => (m.kind === 'reply'
|
|
32
|
-
? `<div class="msg claude reply-msg"><div class="who">
|
|
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>`
|
|
33
39
|
: m.kind === 'ask-link' ? `<div class="msg link"><a href="${esc(m.link)}">${esc(m.text)} →</a></div>`
|
|
34
|
-
: m.
|
|
35
|
-
: m.kind === '
|
|
36
|
-
: 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)}"` : ''}>
|
|
37
45
|
${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
|
|
38
|
-
<div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? '
|
|
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>
|
|
39
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)}
|
|
48
|
+
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log →</a></div>` : ''}
|
|
40
49
|
${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
|
|
41
50
|
${m.eventId && !batched ? msgFooter(m) : ''}
|
|
42
51
|
</div>`);
|
|
43
52
|
|
|
44
53
|
const STEP = 3;
|
|
45
54
|
const UNREAD_CAP = 10;
|
|
46
|
-
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link']);
|
|
55
|
+
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions']);
|
|
47
56
|
const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
|
|
48
|
-
// Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount)
|
|
49
|
-
|
|
57
|
+
// Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount); the
|
|
58
|
+
// 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;
|
|
50
60
|
const batchedAt = (lines, i) => {
|
|
51
61
|
const next = lines.slice(i + 1).find(isMessage);
|
|
52
62
|
return lines[i].kind === 'owner-answer' && next?.kind === 'owner-answer' && stateOf(next) === stateOf(lines[i]);
|
|
@@ -77,14 +87,23 @@
|
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
// The reply form's Done sends ask-done or card-done; the board applies it before the session follows up.
|
|
80
|
-
// docs/adr/
|
|
90
|
+
// docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md
|
|
81
91
|
// Orange while it clears an ask's Action needed, primary while a reopened card waits only on it
|
|
82
|
-
// (docs/adr/
|
|
92
|
+
// (docs/adr/2026-11-03-0137-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
|
|
83
93
|
function doneButton(it) {
|
|
84
94
|
if (!it?.finishable) return '';
|
|
85
95
|
const tone = it.resolvedBy === BUTTON.markDone ? 'resolves' : it.awaitsDone ? 'closes' : '';
|
|
86
96
|
return `<button type="button"${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}">Done</button>`;
|
|
87
97
|
}
|
|
88
98
|
|
|
89
|
-
|
|
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 });
|
|
90
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,21 +52,33 @@
|
|
|
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>`;
|
|
55
|
+
// A composer's text box, which attach.js outlines with Drop to attach while files are dragged over the composer.
|
|
56
|
+
const dropZone = (textarea) => `<div class="dz">${textarea}<div class="dz-hint" aria-hidden="true">Drop to attach</div></div>`;
|
|
52
57
|
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>`;
|
|
53
58
|
// An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
|
|
54
59
|
const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
|
|
55
60
|
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"/>');
|
|
56
61
|
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"/>');
|
|
57
|
-
const
|
|
58
|
-
// Asks Claude for throwaway prototypes of one item (docs/adr/
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
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"/>');
|
|
63
|
+
// Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md): a
|
|
64
|
+
// question's eye sends at once; a thread's eye is a toggle that Send carries (app-threads.js).
|
|
65
|
+
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>`;
|
|
66
|
+
// The thread's fire, the eye's twin for a grill.
|
|
67
|
+
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>`;
|
|
68
|
+
// docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
|
|
69
|
+
// A thread status line's leading mark, its icon or dot centred in one fixed box so every line's text starts at the
|
|
70
|
+
// same place: the request's eye, fire or quick build's lightning, answers' check, new questions' plain question mark, a builder's dot.
|
|
71
|
+
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>`;
|
|
72
|
+
const MARK_ICONS = {
|
|
73
|
+
'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"/>'),
|
|
74
|
+
'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"/>'),
|
|
75
|
+
'quick build: no grill, as you asked': mini('<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>'),
|
|
76
|
+
'answers submitted': mini('<path d="M20 6 9 17l-5-5"/>'),
|
|
77
|
+
'judgment calls': mini('<path d="m9 6 6 6-6 6"/>'),
|
|
78
|
+
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'),
|
|
79
|
+
};
|
|
80
|
+
const mark = (inner = '') => `<span class="mark" aria-hidden="true">${inner}</span>`;
|
|
81
|
+
const statusMark = (words) => mark(MARK_ICONS[words] || '');
|
|
67
82
|
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"/>');
|
|
68
83
|
const BUMP = icon('<path d="M5 3h14"/><path d="m18 13-6-6-6 6"/><path d="M12 7v14"/>');
|
|
69
84
|
// Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
|
|
@@ -71,15 +86,15 @@
|
|
|
71
86
|
const editButton = (id) => `<button class="ghost icon" data-edit="${esc(id)}" aria-label="Edit" title="Edit">${PENCIL}</button>`;
|
|
72
87
|
// A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange once bumped and disabled on the latest
|
|
73
88
|
// bump. A work card's is the same icon: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where
|
|
74
|
-
// it is filled orange, card-unbump {key} (docs/adr/
|
|
75
|
-
// docs/adr/
|
|
89
|
+
// it is filled orange, card-unbump {key} (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
|
|
90
|
+
// docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
|
|
76
91
|
const bumpButton = (it, top) => `<button class="ghost icon${it.bumpedAt ? ' bumped' : ''}" data-bump="${esc(it.id)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
|
|
77
92
|
const cardBumpButton = (key, bumped) => (bumped
|
|
78
93
|
? `<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>`
|
|
79
94
|
: `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
|
|
80
|
-
const workingLine = '<span class="working-dot" aria-hidden="true"></span>
|
|
95
|
+
const workingLine = '<span class="thinking"><span class="working-dot" aria-hidden="true"></span>thinking</span>';
|
|
81
96
|
// html is markup the caller has escaped; href, title and attrs values are escaped here.
|
|
82
|
-
const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready']);
|
|
97
|
+
const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready', 'merged']);
|
|
83
98
|
function badge(tone, html, { href, title, button = false, attrs = {} } = {}) {
|
|
84
99
|
if (!BADGE_TONES.includes(tone)) throw new Error(`unknown badge tone: ${tone}`);
|
|
85
100
|
const extra = Object.entries({ href, title, ...attrs }).filter(([, v]) => v != null).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
|
|
@@ -212,21 +227,104 @@
|
|
|
212
227
|
}
|
|
213
228
|
lastHtml.set(el, html);
|
|
214
229
|
if (openDetails.size) for (const d of el.querySelectorAll('details:not([open])')) if (openDetails.has(detailsKey(d))) d.open = true;
|
|
230
|
+
for (const w of waits) { const b = waitingButton(w); if (b && !b.classList.contains(w.mark)) markWaiting(b, w); }
|
|
215
231
|
return true;
|
|
216
232
|
}
|
|
217
233
|
|
|
234
|
+
// Closes <details> d at once: its toggle event comes a task later, and a setHTML before it would reopen it.
|
|
235
|
+
function closeDetails(d) {
|
|
236
|
+
openDetails.delete(detailsKey(d));
|
|
237
|
+
d.open = false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A tap that saves something waits for the server (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md,
|
|
241
|
+
// docs/adr/2026-11-03-1915-every-pending-control-waits-in-one-style.md): waitOn(control, promise, {group}) disables
|
|
242
|
+
// control until promise settles and marks it tap-waiting and aria-busy. style.css button[aria-busy="true"] keeps a
|
|
243
|
+
// button's colours and width and shows a spinner in its text colour in place of its content, or pulses a bump icon
|
|
244
|
+
// orange. A toggle, select or field keeps its face, with a spinner beside it: after its caption in a .tk-field, else
|
|
245
|
+
// just before it. group: the other choices of the same setting, disabled (tap-blocked) with no spinner meanwhile.
|
|
246
|
+
// A repaint meanwhile (setHTML) patches each back to its markup, so setHTML marks it again, found by its id and data
|
|
247
|
+
// attributes when the repaint replaced it. Settling restores the disabled and aria-busy its latest markup had. → promise
|
|
248
|
+
// Work that outlives its request (a restart, an update, a merge) draws its button with BUSY in its markup until the
|
|
249
|
+
// state the page draws from says the work is done.
|
|
250
|
+
const BUSY = ' disabled aria-busy="true"';
|
|
251
|
+
const waits = new Set();
|
|
252
|
+
function markWaiting(b, w) {
|
|
253
|
+
w.el = b;
|
|
254
|
+
w.disabled = b.disabled;
|
|
255
|
+
w.busy = b.getAttribute('aria-busy');
|
|
256
|
+
b.disabled = true;
|
|
257
|
+
b.classList.add(w.mark);
|
|
258
|
+
if (w.mark !== 'tap-waiting') return;
|
|
259
|
+
b.setAttribute('aria-busy', 'true');
|
|
260
|
+
if (b.tagName === 'BUTTON') return;
|
|
261
|
+
w.spin?.remove();
|
|
262
|
+
w.spin = document.createElement('span');
|
|
263
|
+
w.spin.setAttribute('class', 'try-spin tap-beside');
|
|
264
|
+
w.spin.setAttribute('aria-hidden', 'true');
|
|
265
|
+
const caption = b.closest('.tk-field')?.firstElementChild;
|
|
266
|
+
if (caption && caption !== b) caption.appendChild(w.spin);
|
|
267
|
+
else b.before(w.spin);
|
|
268
|
+
}
|
|
269
|
+
const waitingButton = (w) => (w.el.isConnected && w.el.matches(w.selector) ? w.el
|
|
270
|
+
: w.named ? document.body.querySelector(w.selector) : null);
|
|
271
|
+
function waitFor(el, mark) {
|
|
272
|
+
const named = [...el.attributes].filter(({ name }) => name === 'id' || name.startsWith('data-'));
|
|
273
|
+
const w = { mark, selector: el.tagName.toLowerCase() + named.map(({ name, value }) => (value ? `[${name}="${CSS.escape(value)}"]` : `[${name}]`)).join(''), named: named.length > 0 };
|
|
274
|
+
markWaiting(el, w);
|
|
275
|
+
waits.add(w);
|
|
276
|
+
return w;
|
|
277
|
+
}
|
|
278
|
+
function waitOn(control, promise, { group = [] } = {}) {
|
|
279
|
+
const mine = [waitFor(control, 'tap-waiting'), ...[...group].filter((el) => el !== control).map((el) => waitFor(el, 'tap-blocked'))];
|
|
280
|
+
const done = () => {
|
|
281
|
+
for (const w of mine) {
|
|
282
|
+
waits.delete(w);
|
|
283
|
+
w.spin?.remove();
|
|
284
|
+
// setHTML keeps w.el the node it last marked; one a plain innerHTML replaced is gone with its mark.
|
|
285
|
+
const b = w.el;
|
|
286
|
+
if (!b.classList.contains(w.mark)) continue;
|
|
287
|
+
b.classList.remove(w.mark);
|
|
288
|
+
if (w.mark === 'tap-waiting') { if (w.busy === null) b.removeAttribute('aria-busy'); else b.setAttribute('aria-busy', w.busy); }
|
|
289
|
+
b.disabled = w.disabled;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
promise.then(done, done);
|
|
293
|
+
return promise;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md
|
|
297
|
+
// A tap whose change shows only in a later board: its button draws BUSY while pending.has(id), id being
|
|
298
|
+
// "<card key>:<action>", whatever repaints it, until settle(board) finds a board made after the tap's answer for which
|
|
299
|
+
// until(board) holds. start(id, until) on the tap; answered(id, generatedAt) with the generatedAt of the POST's board;
|
|
300
|
+
// drop(id) when the POST is refused. Boards made before the POST answered never end it, so a reload that races the POST
|
|
301
|
+
// keeps the spinner.
|
|
302
|
+
const pendings = new Map();
|
|
303
|
+
const pending = {
|
|
304
|
+
start(id, until) { pendings.set(id, { until, after: null }); },
|
|
305
|
+
answered(id, after) { const p = pendings.get(id); if (p) p.after = after; },
|
|
306
|
+
drop(id) { pendings.delete(id); },
|
|
307
|
+
has: (id) => pendings.has(id),
|
|
308
|
+
settle(board) {
|
|
309
|
+
for (const [id, p] of pendings) if (p.after !== null && board.generatedAt >= p.after && p.until(board)) pendings.delete(id);
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
|
|
218
313
|
// While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
|
|
219
314
|
// on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
|
|
220
315
|
// then throws once about limit ms have passed. Any other answer resolves {ok, status, body}, a 404's {error} included.
|
|
316
|
+
// tag is the version of the board the page holds: an unchanged board answers 304, which resolves with body null
|
|
317
|
+
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
221
318
|
const RESTARTING = 'Board restarting…';
|
|
222
319
|
const restartNotice = () => `<span class="muted small restarting">${RESTARTING}</span>`;
|
|
223
|
-
async function fetchJSON(url, { onWait = () => {}, limit = 30000, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
320
|
+
async function fetchJSON(url, { onWait = () => {}, limit = 30000, tag = null, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
224
321
|
let waited = 0;
|
|
225
322
|
let delay = 500;
|
|
226
323
|
for (;;) {
|
|
227
324
|
let failure;
|
|
228
325
|
try {
|
|
229
|
-
const r = await get(url, { cache: 'no-store' });
|
|
326
|
+
const r = await get(url, { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) });
|
|
327
|
+
if (r.status === 304) return { ok: true, status: 304, body: null };
|
|
230
328
|
const body = await r.text().then((t) => JSON.parse(t)).catch(() => undefined);
|
|
231
329
|
if (body !== undefined && r.status < 500) return { ok: r.ok, status: r.status, body };
|
|
232
330
|
failure = new Error(r.status >= 500 ? `the board answered ${r.status}` : 'the board did not answer with JSON');
|
|
@@ -241,7 +339,7 @@
|
|
|
241
339
|
|
|
242
340
|
globalThis.BoardUI = {
|
|
243
341
|
LINK_ARROW,
|
|
244
|
-
esc, record, time, ago, since, clock, etaLine, cardTime, replyBody, setHTML, fetchJSON, RESTARTING, restartNotice,
|
|
245
|
-
attachments, attachButton, pendingFiles, protoButton,
|
|
342
|
+
esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, RESTARTING, restartNotice,
|
|
343
|
+
attachments, attachButton, dropZone, pendingFiles, protoButton, grillButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
|
|
246
344
|
};
|
|
247
345
|
})();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Any other page on the hub as a view of the app shell (shell.js): another site, a prototype, or a board page that isn't
|
|
2
|
-
// a view of its own, in a same-origin frame beside the board header (docs/adr/
|
|
2
|
+
// a view of its own, in a same-origin frame beside the board header (docs/adr/2026-10-29-2005-one-app-for-the-whole-hub.md). The
|
|
3
3
|
// header's title follows the page's, and Open alone ↗ loads the page by itself (?alone=1, hub/shell.mjs pageOf). A
|
|
4
4
|
// page that draws the board header itself, such as a prototype or another site's page, hides this one; its logo then
|
|
5
5
|
// toggles the sidebar as this one's would, and hides while the rail shows. The page's own navigations stay in the frame
|
|
@@ -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
|
-
// HubSettings: summary rows below Hub’s health and status open centred settings modals (
|
|
2
|
-
// settingsView's words from GET /_hub/api/settings. Each toggle and snooze posts to /_hub/settings/notifications,
|
|
3
|
-
// Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key does through linear-key.js; Done waits for
|
|
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
|
|
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 does 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 () {
|
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
|
|
34
34
|
const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
|
|
35
35
|
choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
|
|
36
|
-
// Grey while off and orange while on, as the machine working (docs/adr/
|
|
36
|
+
// Grey while off and orange while on, as the machine working (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md).
|
|
37
37
|
const awakeRow = (a) => durationPicker({ id: 'awake', title: 'Keep awake', icon: HubUI.tile('coffee', a.on ? 'var(--color-notice)' : 'var(--color-quiet)'),
|
|
38
38
|
action: '/_hub/settings/keep-awake', name: 'keep_awake', choices: a.choices, on: a.on, button: 'Turn off' });
|
|
39
39
|
|
|
40
|
-
// The on/off, snooze and weekend rows (docs/adr/
|
|
40
|
+
// The on/off, snooze and weekend rows (docs/adr/2026-10-29-1639-notifications-can-be-off-snoozed-or-off-on-weekends.md).
|
|
41
41
|
function notificationRows(d) {
|
|
42
42
|
const { tile } = HubUI;
|
|
43
43
|
const toggle = ({ icon, set, title, checked, attrs = '' }) => `<form class="row field" method="post" action="${ACTION}"><input type="hidden" name="set" value="${set}">${icon}<label class="body"><span class="text"><span class="title">${title}</span></span><input type="checkbox" name="value" value="on" class="toggle"${attrs}${checked ? ' checked' : ''}></label></form>`;
|
|
@@ -47,9 +47,20 @@
|
|
|
47
47
|
return `${onRow}\n${snoozeRow(d)}\n${weekendRow}`;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
// Quiet hours (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md): start and
|
|
51
|
+
// end, each saved once its picker closes, the overnight hold, the muted cadence hint and what the snoozes and wake read.
|
|
52
|
+
function quietRows(q) {
|
|
53
|
+
const { row, tile } = HubUI;
|
|
54
|
+
const time = (name, title, value) => `<form class="row field" method="post" action="/_hub/settings/quiet"><label class="body"><span class="text title">${title}</span><input type="time" name="${name}" value="${esc(value)}" required data-autosave></label></form>`;
|
|
55
|
+
const holdRow = `<form class="row field" method="post" action="/_hub/settings/quiet"><input type="hidden" name="set" value="hold">${tile('moon', 'var(--accent)')}<label class="body"><span class="text"><span class="title">Hold pushes</span><span class="sub wrap">${esc(q.hold.sub)}</span></span><input type="checkbox" name="value" value="on" class="toggle" data-submit${q.hold.on ? ' checked' : ''}></label></form>`;
|
|
56
|
+
const reads = q.reads.map((r) => row({ title: esc(r.title), sub: `<span class="sub wrap">${esc(r.sub)}</span>` })).join('');
|
|
57
|
+
return `<div class="group">${time('start', 'Starts', q.start)}${time('end', 'Ends', q.end)}${holdRow}</div><p class="quiet-hint">${esc(q.hint)}</p>`
|
|
58
|
+
+ `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sections = [['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
51
62
|
const sectionOf = (hash) => {
|
|
52
|
-
const id = { push: 'notifications', 'closed-boards': 'closed' }[hash?.slice(1)] || hash?.slice(1);
|
|
63
|
+
const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
|
|
53
64
|
return sections.some(([section]) => section === id) ? id : null;
|
|
54
65
|
};
|
|
55
66
|
const markup = () => `${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="${id}" data-settings-open="${id}" aria-haspopup="dialog"><span class="text"><span class="title">${title}</span><span class="sub" data-summary="${id}">Loading…</span></span><span class="chev" aria-hidden="true">›</span></button>`).join('')}
|
|
@@ -65,7 +76,6 @@
|
|
|
65
76
|
let settingsURL = '/_hub/';
|
|
66
77
|
let returnFocus = null;
|
|
67
78
|
let unfitModal = null;
|
|
68
|
-
let polling = null;
|
|
69
79
|
let flash = null;
|
|
70
80
|
let rules = null;
|
|
71
81
|
let rulesJSON = '';
|
|
@@ -78,6 +88,8 @@
|
|
|
78
88
|
const scrollTop = B.$('.settings-body').scrollTop;
|
|
79
89
|
if (active === 'notifications') {
|
|
80
90
|
content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}${last.presence ? row({ icon: tile('laptop', 'var(--color-quiet)'), title: esc(last.presence.title) }) : ''}</div>`;
|
|
91
|
+
} else if (active === 'quiet') {
|
|
92
|
+
content.innerHTML = quietRows(last.quiet);
|
|
81
93
|
} else if (active === 'keep-awake') {
|
|
82
94
|
content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
|
|
83
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>`;
|
|
@@ -89,7 +101,7 @@
|
|
|
89
101
|
busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
|
|
90
102
|
}
|
|
91
103
|
} else if (active === 'closed') {
|
|
92
|
-
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 ?
|
|
104
|
+
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>`;
|
|
93
105
|
}
|
|
94
106
|
const input = B.$('[data-dur-at]');
|
|
95
107
|
if (input) customBounds(input, true);
|
|
@@ -97,7 +109,7 @@
|
|
|
97
109
|
B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
|
|
98
110
|
const summaries = {
|
|
99
111
|
notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
|
|
100
|
-
|
|
112
|
+
quiet: last.quiet.summary,
|
|
101
113
|
'keep-awake': last.awake.summary,
|
|
102
114
|
linear: last.linear?.summary || 'No key',
|
|
103
115
|
rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
|
|
@@ -138,7 +150,6 @@
|
|
|
138
150
|
}
|
|
139
151
|
|
|
140
152
|
function closeModal(restore = true) {
|
|
141
|
-
polling?.unmount(); polling = null;
|
|
142
153
|
unfitModal?.(); unfitModal = null;
|
|
143
154
|
modal.close();
|
|
144
155
|
linearKey?.destroy(); linearKey = null;
|
|
@@ -168,13 +179,9 @@
|
|
|
168
179
|
modal.showModal();
|
|
169
180
|
B.$('.settings-body').scrollTop = 0;
|
|
170
181
|
B.$('#hubSettingsTitle').focus({ preventScroll: true });
|
|
171
|
-
if (id === 'polling') {
|
|
172
|
-
polling = HubPolling.create(content, siteRoot, { ...ctx, view, changed: () => B.refreshSettings?.() });
|
|
173
|
-
polling.mount();
|
|
174
|
-
}
|
|
175
182
|
}
|
|
176
|
-
}
|
|
177
|
-
settingsURL = AppRouter.urlOf({ kind: 'hub', ...(id ? { hash: `#${id}` } : {})
|
|
183
|
+
}
|
|
184
|
+
settingsURL = AppRouter.urlOf({ kind: 'hub', ...(id ? { hash: `#${id}` } : {}) });
|
|
178
185
|
history.replaceState(history.state, '', settingsURL);
|
|
179
186
|
return true;
|
|
180
187
|
};
|
|
@@ -254,7 +261,7 @@
|
|
|
254
261
|
else on.checked = true;
|
|
255
262
|
});
|
|
256
263
|
const forms = HubKit.forms(B, {
|
|
257
|
-
accepts: (action) => action !== '/_hub/push/flush'
|
|
264
|
+
accepts: (action) => action !== '/_hub/push/flush',
|
|
258
265
|
answer(out) {
|
|
259
266
|
last = out;
|
|
260
267
|
flash = null;
|
package/board/public/view-hub.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The hub page, /_hub/, a view of the app shell (shell.js): a banner while pushes are held or snoozed, the Usage card with Claude model and Codex share, Needs you for the pending
|
|
2
2
|
// prompts of sessions that own no board, Health, Integrations (readiness.mjs words), each site's health and board links,
|
|
3
|
-
// polling status, then hub settings summary rows (
|
|
3
|
+
// polling status, then hub settings summary rows ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)). A board's row can carry Restart (hub/restart-board.mjs), and
|
|
4
|
+
// the Hub row Restart hub while the hub runs older code than its files. Health comes from GET /_hub/api/hub.
|
|
4
5
|
// Its data refreshes every 30 seconds while it shows (hub-kit.js live).
|
|
5
6
|
(function () {
|
|
6
7
|
// The last data, which a return to the view paints at once.
|
|
@@ -11,14 +12,14 @@
|
|
|
11
12
|
return sessions.map((s) => `<div class="remembered"><p>Always allow · session <code>${esc(s.session)}</code></p><ul>${s.rules.map((rule) => `<li><code>${esc(rule)}</code></li>`).join('')}</ul></div>`).join('');
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
const RESTARTING = '<span class="st wait">
|
|
15
|
+
const RESTARTING = '<span class="st wait">restarting</span> · stopping its session, then starting it again';
|
|
15
16
|
|
|
16
17
|
// A board with Restart (hub/restart-board.mjs restartable) keeps its link and chevron around the button, since a
|
|
17
|
-
// button can't sit inside a link.
|
|
18
|
+
// button can't sit inside a link. Restart waits (ui.js BUSY) until its POST answers, once the board is back up.
|
|
18
19
|
function restartRow(s, sub, busy) {
|
|
19
20
|
const href = esc(s.href);
|
|
20
21
|
return `<div class="row site-row"><a class="site-link" href="${href}">${s.icon}<span class="text"><span class="title">${esc(s.title)}</span><span class="sub wrap">${sub}</span></span></a>`
|
|
21
|
-
+ `<button type="button" class="btn quiet restart-btn" data-restart="${esc(s.route)}" data-title="${esc(s.title)}" aria-label="Restart ${esc(s.title)}"${busy ?
|
|
22
|
+
+ `<button type="button" class="btn quiet restart-btn" data-restart="${esc(s.route)}" data-title="${esc(s.title)}" aria-label="Restart ${esc(s.title)}"${busy ? BoardUI.BUSY : ''}>Restart</button>`
|
|
22
23
|
+ `<a class="chev" href="${href}" tabindex="-1" aria-hidden="true">›</a></div>`;
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -39,8 +40,8 @@
|
|
|
39
40
|
const fixHtml = (fix) => fix.command ? `<code>${esc(fix.command)}</code>` : fix.settings
|
|
40
41
|
? `${esc(fix.text)} <button type="button" class="link" data-settings-open="${esc(fix.settings)}">${esc(fix.label)}</button>.` : esc(fix.text);
|
|
41
42
|
const rows = integrations.map((it) => row({ icon: tile(it.id, it.ready ? 'var(--color-quiet)' : 'var(--color-notice)'), title: esc(it.name), wrap: true,
|
|
42
|
-
sub: it.ready ? `<span class="st ok">
|
|
43
|
-
: `<span class="st wait">
|
|
43
|
+
sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}`
|
|
44
|
+
: `<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('');
|
|
44
45
|
return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
|
|
45
46
|
<p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
|
|
46
47
|
`;
|
|
@@ -52,11 +53,20 @@
|
|
|
52
53
|
return shown.length ? `<section class="waiting"><h2>Needs you · ${shown.length}</h2><ul>${shown.map((p) => BoardUI.promptItem(p, prompts.state(p))).join('')}</ul></section>` : '';
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
// Restart hub, while the hub runs older code than its files (hub/views.mjs hubView's hub.restart). It waits (ui.js BUSY)
|
|
57
|
+
// from the tap until the hub answers with its new code and the page reloads.
|
|
58
|
+
const restartHubButton = (busy) => `<button type="button" class="btn quiet restart-btn" id="restart-hub" data-restart-hub${busy ? BoardUI.BUSY : ''}>Restart hub</button>`;
|
|
59
|
+
|
|
60
|
+
function html(d, { flash, prompts, restarting, restartingHub }) {
|
|
56
61
|
const { row, tile, nowRow } = HubUI;
|
|
57
62
|
const usage = HubUI.usageCard(d.usage, d.codex, d.pause, d.claude);
|
|
63
|
+
// The status word is plain text, except while the hub runs older code: then it opens the Restart hub confirm and waits like that button.
|
|
64
|
+
const word = d.hub.restart
|
|
65
|
+
? `<button type="button" class="st ${esc(d.hub.tone)}" data-restart-hub${restartingHub ? BoardUI.BUSY : ''}>${esc(d.hub.word)}</button>`
|
|
66
|
+
: `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span>`;
|
|
58
67
|
return `${HubUI.flash(flash)}${HubUI.pauseBanner(d.pause)}${HubUI.holdBanner(d.hold)}${usage}${promptsHtml(d.prompts || [], prompts)}<h2 class="label"${usage ? '' : ' style="margin-top:4px"'}>Health</h2>
|
|
59
|
-
<div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub:
|
|
68
|
+
<div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `${word} ·${esc(d.hub.detail)}${d.awake ? `<span class="links"><span><span class="awake-on">Keeping awake</span> ${esc(d.awake)}</span></span>` : ''}`, wrap: true,
|
|
69
|
+
trail: d.hub.restart ? restartHubButton(restartingHub) : '' })}
|
|
60
70
|
${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip <route></code>.' }) : ''}</div>
|
|
61
71
|
${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembered?.length ? `<h2 class="label">Sessions without a board</h2><div class="group">${rememberedHtml(d.remembered)}</div>` : ''}<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, the fetch of its repo, events (its session and a watcher run, or the hub can wake it), daily wakes and reviewer links. One that just broke reads settling first.</p>
|
|
62
72
|
<h2 class="label">Polling status</h2>
|
|
@@ -102,19 +112,50 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
102
112
|
restarting.delete(route);
|
|
103
113
|
if (B.alive()) { paint(); load(); }
|
|
104
114
|
}
|
|
115
|
+
// Restart hub asks the host to restart the hub service (POST /_hub/restart-hub), then waits for the hub to answer
|
|
116
|
+
// 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.
|
|
117
|
+
let restartingHub = false;
|
|
118
|
+
const HUB_WAIT = 120;
|
|
119
|
+
async function restartHub() {
|
|
120
|
+
if (restartingHub || !(await BoardDialogs.confirm('This restarts the hub on the host so it runs the code in its files. Its pages stop answering until it’s back, then this page reloads. Restart the hub?'))) return;
|
|
121
|
+
restartingHub = true;
|
|
122
|
+
flash = null;
|
|
123
|
+
paint();
|
|
124
|
+
let out = null, before = null;
|
|
125
|
+
try {
|
|
126
|
+
const r = await fetch('/_hub/restart-hub', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: '{}' });
|
|
127
|
+
out = await r.json().catch(() => null);
|
|
128
|
+
if (!r.ok || !out) throw new Error();
|
|
129
|
+
before = r.headers.get('x-hub-code');
|
|
130
|
+
} catch {
|
|
131
|
+
restartingHub = false;
|
|
132
|
+
flash = { text: out?.error || 'Could not restart the hub. Is the host awake?', error: true };
|
|
133
|
+
if (B.alive()) paint();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (let i = 0; i < HUB_WAIT && B.alive(); i++) {
|
|
137
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
138
|
+
try {
|
|
139
|
+
const r = await fetch('/_hub/api/hub', { cache: 'no-store', headers: { accept: 'application/json' } });
|
|
140
|
+
const code = r.headers.get('x-hub-code');
|
|
141
|
+
if (r.ok && code && code !== before) return AppShell.reload();
|
|
142
|
+
} catch { /* the hub is down while it restarts */ }
|
|
143
|
+
}
|
|
144
|
+
restartingHub = false;
|
|
145
|
+
flash = { text: `The hub hasn’t come back. On the host, run ${out.command}`, error: true };
|
|
146
|
+
if (B.alive()) paint();
|
|
147
|
+
}
|
|
105
148
|
B.on(main, 'click', (e) => {
|
|
106
149
|
const opener = e.target.closest?.('[data-settings-open]');
|
|
107
150
|
if (opener) B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener);
|
|
108
|
-
|
|
109
|
-
const jump = e.target.closest?.('[data-jump]');
|
|
110
|
-
if (jump) B.$(`#${jump.dataset.jump}`).scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
151
|
+
if (e.target.closest?.('[data-restart-hub]')) restartHub();
|
|
111
152
|
const restarter = e.target.closest?.('[data-restart]');
|
|
112
153
|
if (restarter) restart(restarter.dataset.restart, restarter.dataset.title);
|
|
113
154
|
});
|
|
114
155
|
const foot = B.$('.hub-foot');
|
|
115
156
|
const paint = () => {
|
|
116
157
|
if (!last) return;
|
|
117
|
-
main.innerHTML = html(last, { flash, prompts, restarting });
|
|
158
|
+
main.innerHTML = html(last, { flash, prompts, restarting, restartingHub });
|
|
118
159
|
foot.innerHTML = footer(last);
|
|
119
160
|
};
|
|
120
161
|
const prompts = PromptAnswers.wire(B, {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// An item's notes, /<route>/notes.html?item=<wave>/<id>, a view of the app shell (shell.js)
|
|
2
|
-
// (docs/adr/
|
|
2
|
+
// (docs/adr/2026-10-29-1816-notes-replace-the-full-plan-and-cards-start-on-their-own.md). Back goes to the item's card.
|
|
3
3
|
// ?wave=<slug> shows a wave's goal, order and note instead, and back goes to the board.
|
|
4
4
|
(globalThis.AppViews ||= {}).notes = {
|
|
5
5
|
className: 'view-notes',
|