@lovek4d/kickflip 0.1.0 → 0.4.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 +239 -104
- package/README.md +54 -4
- package/SKILL.md +63 -42
- package/bin/doctor.mjs +4 -3
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +103 -40
- package/bin/statusline.mjs +4 -3
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +5 -0
- package/board/auto-review.mjs +2 -0
- package/board/bin/agent.mjs +1 -1
- package/board/bin/board.mjs +371 -82
- package/board/bin/builder-stop-hook.mjs +4 -3
- package/board/domain.mjs +8 -3
- package/board/finish.mjs +3 -0
- package/board/headless.mjs +3 -0
- package/board/on-host.mjs +1 -0
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +66 -28
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +354 -77
- package/board/public/app-core.js +40 -8
- package/board/public/app-header.js +33 -8
- package/board/public/app-lanes.js +238 -34
- package/board/public/app-needs.js +2 -0
- package/board/public/app-snapshot.js +45 -11
- package/board/public/app-threads.js +20 -21
- package/board/public/app.html +6 -2
- package/board/public/app.js +114 -33
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +161 -118
- package/board/public/hub-kit.js +27 -7
- package/board/public/hub.css +26 -33
- package/board/public/linear-key.js +143 -0
- package/board/public/log.html +48 -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 +14 -10
- package/board/public/rules-editor.js +4 -5
- package/board/public/shell.js +21 -7
- package/board/public/sidebar.css +51 -11
- package/board/public/sidebar.js +124 -19
- package/board/public/style.css +244 -56
- package/board/public/toasts.js +5 -3
- package/board/public/ui-cards.js +236 -66
- package/board/public/ui-enums.js +5 -4
- package/board/public/ui-header.js +23 -14
- package/board/public/ui-hub.js +34 -16
- package/board/public/ui-links.js +18 -10
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +21 -0
- package/board/public/ui-page-header.js +13 -8
- 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 +35 -11
- package/board/public/ui-sidebar.js +83 -21
- package/board/public/ui-snapshot.js +30 -13
- package/board/public/ui-threads.js +38 -23
- package/board/public/ui.js +96 -16
- package/board/public/view-frame.js +1 -1
- package/board/public/view-hub-settings.js +67 -44
- package/board/public/view-hub.js +105 -13
- package/board/public/view-notes.js +8 -5
- package/board/public/view-questions.js +123 -52
- package/board/public/view-settings.js +235 -62
- package/board/review.mjs +1 -0
- package/board/server.mjs +18 -7
- package/board/store.mjs +15 -6
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +20 -13
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/duration.mjs +1 -0
- package/hub/github.mjs +3 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -10
- 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/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -0
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -2
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/shell.mjs +2 -1
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +12 -12
- package/hub/sw.js +2 -2
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +8 -3
- package/hub/upgrade.mjs +1 -0
- 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 +9 -6
- package/shared/fake-gh.mjs +105 -0
- package/shared/repos.mjs +1 -1
- package/shared/service.mjs +16 -16
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -1
- package/starter/public/claude.js +3 -2
- package/bin/web-ts.mjs +0 -2
- package/board/public/view-polling.js +0 -87
|
@@ -1,14 +1,15 @@
|
|
|
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
|
-
//
|
|
4
|
-
//
|
|
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
|
+
// a key's save to land. The rules editor saves through POST /_hub/settings/rules
|
|
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.
|
|
5
6
|
(function () {
|
|
6
7
|
// The last data, which a return to the view paints at once.
|
|
7
8
|
let last = null;
|
|
8
9
|
const { esc } = HubUI;
|
|
9
10
|
const ACTION = '/_hub/settings/notifications';
|
|
10
11
|
|
|
11
|
-
// datetime-local speaks the device's clock; the hidden
|
|
12
|
+
// datetime-local speaks the device's clock; the picker's hidden field carries its UTC ISO value to the hub.
|
|
12
13
|
const localInput = (d) => {
|
|
13
14
|
const pad = (n) => String(n).padStart(2, '0');
|
|
14
15
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
@@ -20,27 +21,46 @@
|
|
|
20
21
|
if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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>`;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
|
|
35
|
+
choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
|
|
36
|
+
// Grey while off and orange while on, as the machine working (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md).
|
|
37
|
+
const awakeRow = (a) => durationPicker({ id: 'awake', title: 'Keep awake', icon: HubUI.tile('coffee', a.on ? 'var(--color-notice)' : 'var(--color-quiet)'),
|
|
38
|
+
action: '/_hub/settings/keep-awake', name: 'keep_awake', choices: a.choices, on: a.on, button: 'Turn off' });
|
|
39
|
+
|
|
40
|
+
// The on/off, snooze and weekend rows (docs/adr/2026-10-29-1639-notifications-can-be-off-snoozed-or-off-on-weekends.md).
|
|
32
41
|
function notificationRows(d) {
|
|
33
42
|
const { tile } = HubUI;
|
|
34
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>`;
|
|
35
44
|
const onRow = toggle({ icon: tile('power', d.on.on ? '#22c55e' : '#6b7280'), set: 'on', title: 'Push notifications', checked: d.on.on,
|
|
36
45
|
attrs: ` id="notifyOn" data-confirm="${esc(d.on.confirm)}"` });
|
|
37
|
-
const weekendRow = toggle({ icon: tile('calendar', '
|
|
46
|
+
const weekendRow = toggle({ icon: tile('calendar', 'var(--color-quiet)'), set: 'weekends', title: 'Off on weekends', checked: d.weekends.on, attrs: ' data-submit' });
|
|
38
47
|
return `${onRow}\n${snoozeRow(d)}\n${weekendRow}`;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
|
|
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']];
|
|
42
62
|
const sectionOf = (hash) => {
|
|
43
|
-
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);
|
|
44
64
|
return sections.some(([section]) => section === id) ? id : null;
|
|
45
65
|
};
|
|
46
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('')}
|
|
@@ -56,10 +76,10 @@
|
|
|
56
76
|
let settingsURL = '/_hub/';
|
|
57
77
|
let returnFocus = null;
|
|
58
78
|
let unfitModal = null;
|
|
59
|
-
let polling = null;
|
|
60
79
|
let flash = null;
|
|
61
80
|
let rules = null;
|
|
62
81
|
let rulesJSON = '';
|
|
82
|
+
let linearKey = null;
|
|
63
83
|
|
|
64
84
|
function paint() {
|
|
65
85
|
if (!last) return;
|
|
@@ -68,21 +88,29 @@
|
|
|
68
88
|
const scrollTop = B.$('.settings-body').scrollTop;
|
|
69
89
|
if (active === 'notifications') {
|
|
70
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>`;
|
|
71
|
-
|
|
72
|
-
|
|
91
|
+
} else if (active === 'quiet') {
|
|
92
|
+
content.innerHTML = quietRows(last.quiet);
|
|
93
|
+
} else if (active === 'keep-awake') {
|
|
94
|
+
content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
|
|
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>`;
|
|
73
96
|
} else if (active === 'linear') {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
97
|
+
if (linearKey) linearKey.set(last.linear);
|
|
98
|
+
else {
|
|
99
|
+
linearKey = LinearKey.mount(content, { linear: last.linear,
|
|
100
|
+
saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); },
|
|
101
|
+
busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
|
|
102
|
+
}
|
|
79
103
|
} else if (active === 'closed') {
|
|
80
|
-
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>`;
|
|
81
105
|
}
|
|
106
|
+
const input = B.$('[data-dur-at]');
|
|
107
|
+
if (input) customBounds(input, true);
|
|
82
108
|
B.$('.settings-body').scrollTop = scrollTop;
|
|
109
|
+
B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
|
|
83
110
|
const summaries = {
|
|
84
|
-
notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.
|
|
85
|
-
|
|
111
|
+
notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
|
|
112
|
+
quiet: last.quiet.summary,
|
|
113
|
+
'keep-awake': last.awake.summary,
|
|
86
114
|
linear: last.linear?.summary || 'No key',
|
|
87
115
|
rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
|
|
88
116
|
closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
|
|
@@ -122,9 +150,9 @@
|
|
|
122
150
|
}
|
|
123
151
|
|
|
124
152
|
function closeModal(restore = true) {
|
|
125
|
-
polling?.unmount(); polling = null;
|
|
126
153
|
unfitModal?.(); unfitModal = null;
|
|
127
154
|
modal.close();
|
|
155
|
+
linearKey?.destroy(); linearKey = null;
|
|
128
156
|
content.innerHTML = '';
|
|
129
157
|
rules = null;
|
|
130
158
|
active = null;
|
|
@@ -151,17 +179,16 @@
|
|
|
151
179
|
modal.showModal();
|
|
152
180
|
B.$('.settings-body').scrollTop = 0;
|
|
153
181
|
B.$('#hubSettingsTitle').focus({ preventScroll: true });
|
|
154
|
-
if (id === 'polling') {
|
|
155
|
-
polling = HubPolling.create(content, siteRoot, { ...ctx, view, changed: () => B.refreshSettings?.() });
|
|
156
|
-
polling.mount();
|
|
157
|
-
}
|
|
158
182
|
}
|
|
159
|
-
}
|
|
160
|
-
settingsURL = AppRouter.urlOf({ kind: 'hub', ...(id ? { hash: `#${id}` } : {})
|
|
183
|
+
}
|
|
184
|
+
settingsURL = AppRouter.urlOf({ kind: 'hub', ...(id ? { hash: `#${id}` } : {}) });
|
|
161
185
|
history.replaceState(history.state, '', settingsURL);
|
|
162
186
|
return true;
|
|
163
187
|
};
|
|
164
|
-
const dismiss = () =>
|
|
188
|
+
const dismiss = () => {
|
|
189
|
+
if (!linearKey?.busy()) { B.openSettings({ kind: 'hub' }); return; }
|
|
190
|
+
linearKey.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
|
|
191
|
+
};
|
|
165
192
|
B.on(modal, 'cancel', (e) => { e.preventDefault(); dismiss(); });
|
|
166
193
|
B.on(root, 'click', (e) => {
|
|
167
194
|
const opener = e.target.closest?.('[data-settings-open]');
|
|
@@ -181,7 +208,7 @@
|
|
|
181
208
|
device.ready.then(() => { if (B.alive()) paint(); });
|
|
182
209
|
let openPicker = null;
|
|
183
210
|
B.on(root, 'click', (e) => {
|
|
184
|
-
const input = e.target.closest?.('[data-
|
|
211
|
+
const input = e.target.closest?.('[data-dur-at]');
|
|
185
212
|
if (!input) return;
|
|
186
213
|
openPicker = input;
|
|
187
214
|
customBounds(input, input.value === input.defaultValue);
|
|
@@ -189,7 +216,7 @@
|
|
|
189
216
|
});
|
|
190
217
|
// A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
|
|
191
218
|
B.on(root, 'pointerdown', (e) => {
|
|
192
|
-
const input = e.target.closest?.('.
|
|
219
|
+
const input = e.target.closest?.('.dur-choices button') && B.$('[data-dur-at]');
|
|
193
220
|
if (!input) return;
|
|
194
221
|
openPicker = null;
|
|
195
222
|
input.value = input.defaultValue;
|
|
@@ -199,16 +226,16 @@
|
|
|
199
226
|
if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
|
|
200
227
|
const at = new Date(input.value);
|
|
201
228
|
if (Number.isNaN(at.getTime())) return;
|
|
202
|
-
input.form.querySelector('[
|
|
229
|
+
input.form.querySelector('input[type="hidden"]').value = at.toISOString();
|
|
203
230
|
input.form.requestSubmit();
|
|
204
231
|
openPicker = null;
|
|
205
232
|
input.defaultValue = input.value;
|
|
206
233
|
};
|
|
207
234
|
B.on(root, 'change', (e) => {
|
|
208
|
-
if (e.target.matches?.('[data-
|
|
235
|
+
if (e.target.matches?.('[data-dur-at]') && document.activeElement !== e.target) saveCustom(e.target);
|
|
209
236
|
});
|
|
210
237
|
B.on(root, 'focusout', (e) => {
|
|
211
|
-
if (e.target.matches?.('[data-
|
|
238
|
+
if (e.target.matches?.('[data-dur-at]')) saveCustom(e.target);
|
|
212
239
|
});
|
|
213
240
|
B.on(root, 'click', async (e) => {
|
|
214
241
|
const button = e.target.closest?.('[data-reopen]');
|
|
@@ -227,10 +254,6 @@
|
|
|
227
254
|
reopening = null;
|
|
228
255
|
if (B.alive()) paint();
|
|
229
256
|
});
|
|
230
|
-
B.on(root, 'click', async (e) => {
|
|
231
|
-
const remove = e.target.closest?.('[data-linear-remove]');
|
|
232
|
-
if (remove && await BoardDialogs.confirm('Remove the Linear key? Boards on Linear can’t create issues or move tickets until you add one again.', { ok: 'Remove' }) && B.alive()) remove.closest('form').requestSubmit();
|
|
233
|
-
});
|
|
234
257
|
B.on(root, 'change', async (e) => {
|
|
235
258
|
const on = e.target;
|
|
236
259
|
if (on.id !== 'notifyOn') return;
|
|
@@ -238,7 +261,7 @@
|
|
|
238
261
|
else on.checked = true;
|
|
239
262
|
});
|
|
240
263
|
const forms = HubKit.forms(B, {
|
|
241
|
-
accepts: (action) => action !== '/_hub/push/flush'
|
|
264
|
+
accepts: (action) => action !== '/_hub/push/flush',
|
|
242
265
|
answer(out) {
|
|
243
266
|
last = out;
|
|
244
267
|
flash = null;
|
package/board/public/view-hub.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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 Codex share, Needs you for the pending
|
|
2
|
-
// prompts of sessions that own no board,
|
|
3
|
-
// polling status, then hub settings summary rows (
|
|
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
|
+
// 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 ([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,30 +12,65 @@
|
|
|
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
|
-
|
|
15
|
+
const RESTARTING = '<span class="st wait">restarting</span> · stopping its session, then starting it again';
|
|
16
|
+
|
|
17
|
+
// A board with Restart (hub/restart-board.mjs restartable) keeps its link and chevron around the button, since a
|
|
18
|
+
// button can't sit inside a link. Restart waits (ui.js BUSY) until its POST answers, once the board is back up.
|
|
19
|
+
function restartRow(s, sub, busy) {
|
|
20
|
+
const href = esc(s.href);
|
|
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>`
|
|
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>`
|
|
23
|
+
+ `<a class="chev" href="${href}" tabindex="-1" aria-hidden="true">›</a></div>`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// restarting: the routes whose Restart is out.
|
|
27
|
+
function sitesHtml(groups, restarting) {
|
|
15
28
|
const { row, CHEVRON } = HubUI;
|
|
16
29
|
const site = (s) => {
|
|
30
|
+
const busy = restarting.has(s.route);
|
|
17
31
|
const lines = s.lines.length ? `<span class="links">${s.lines.map((l) => `<span><b>${esc(l.link)}</b> ${esc(l.detail)}</span>`).join('')}</span>` : '';
|
|
18
|
-
|
|
32
|
+
const sub = busy ? RESTARTING : `<span class="st ${esc(s.tone)}">${esc(s.word)}</span>${lines}`;
|
|
33
|
+
return (s.restart ? restartRow(s, sub, busy) : row({ icon: s.icon, title: esc(s.title), sub, wrap: true, trail: CHEVRON, href: s.href })) + rememberedHtml(s.remembered);
|
|
19
34
|
};
|
|
20
35
|
return groups.map((g) => `<h2 class="label">${esc(g.label)}</h2>\n<div class="group">${g.sites.map(site).join('')}</div>\n`).join('');
|
|
21
36
|
}
|
|
22
37
|
|
|
38
|
+
function integrationsHtml(integrations) {
|
|
39
|
+
const { row, tile } = HubUI;
|
|
40
|
+
const fixHtml = (fix) => fix.command ? `<code>${esc(fix.command)}</code>` : fix.settings
|
|
41
|
+
? `${esc(fix.text)} <button type="button" class="link" data-settings-open="${esc(fix.settings)}">${esc(fix.label)}</button>.` : esc(fix.text);
|
|
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,
|
|
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('');
|
|
45
|
+
return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
|
|
46
|
+
<p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
|
|
23
50
|
// A pending prompt from a session that owns no board waits here (prompts.js).
|
|
24
51
|
function promptsHtml(list, prompts) {
|
|
25
52
|
const shown = list.filter(prompts.shows);
|
|
26
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>` : '';
|
|
27
54
|
}
|
|
28
55
|
|
|
29
|
-
|
|
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 }) {
|
|
30
61
|
const { row, tile, nowRow } = HubUI;
|
|
31
|
-
const usage = HubUI.usageCard(d.usage, d.codex, d.pause);
|
|
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>`;
|
|
32
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>
|
|
33
|
-
<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) : '' })}
|
|
34
70
|
${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip <route></code>.' }) : ''}</div>
|
|
35
|
-
${sitesHtml(d.groups)}${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, events (its session and a watcher run, or the hub can wake it), daily wakes
|
|
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>
|
|
36
72
|
<h2 class="label">Polling status</h2>
|
|
37
|
-
<div class="group">${nowRow(d.polling, tile('clock', '
|
|
73
|
+
<div class="group">${nowRow(d.polling, tile('clock', d.polling.count ? 'var(--color-notice)' : 'var(--color-quiet)'))}</div>`;
|
|
38
74
|
}
|
|
39
75
|
|
|
40
76
|
const footer = (d) => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip <route></code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span></footer>`;
|
|
@@ -59,11 +95,67 @@ ${sitesHtml(d.groups)}${d.remembered?.length ? `<h2 class="label">Sessions witho
|
|
|
59
95
|
B.atUnmount(() => settings.unmount());
|
|
60
96
|
B.on(B.$('#refresh'), 'click', () => settings.B.refreshSettings?.());
|
|
61
97
|
let flash = null;
|
|
98
|
+
const restarting = new Set();
|
|
62
99
|
B.openSettings = (...args) => settings.B.openSettings(...args);
|
|
100
|
+
async function restart(route, title) {
|
|
101
|
+
if (restarting.has(route) || !(await BoardDialogs.confirm(`This stops ${title}’s session mid-task, along with any implementer it started, then restarts its server and resumes the session. Its cards and threads stay. For a stuck board. Restart ${title}?`))) return;
|
|
102
|
+
restarting.add(route);
|
|
103
|
+
flash = null;
|
|
104
|
+
paint();
|
|
105
|
+
let out = null;
|
|
106
|
+
try {
|
|
107
|
+
const r = await fetch('/_hub/restart-board', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ route }) });
|
|
108
|
+
out = await r.json().catch(() => null);
|
|
109
|
+
if (!r.ok || !out) throw new Error();
|
|
110
|
+
if (B.alive()) B.toast(`${out.title} is back up, and its session is listening.`);
|
|
111
|
+
} catch { flash = { text: out?.error || `Could not restart ${title}. Is the host awake?`, error: true }; }
|
|
112
|
+
restarting.delete(route);
|
|
113
|
+
if (B.alive()) { paint(); load(); }
|
|
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
|
+
}
|
|
148
|
+
B.on(main, 'click', (e) => {
|
|
149
|
+
const opener = e.target.closest?.('[data-settings-open]');
|
|
150
|
+
if (opener) B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener);
|
|
151
|
+
if (e.target.closest?.('[data-restart-hub]')) restartHub();
|
|
152
|
+
const restarter = e.target.closest?.('[data-restart]');
|
|
153
|
+
if (restarter) restart(restarter.dataset.restart, restarter.dataset.title);
|
|
154
|
+
});
|
|
63
155
|
const foot = B.$('.hub-foot');
|
|
64
156
|
const paint = () => {
|
|
65
157
|
if (!last) return;
|
|
66
|
-
main.innerHTML = html(last, { flash, prompts });
|
|
158
|
+
main.innerHTML = html(last, { flash, prompts, restarting, restartingHub });
|
|
67
159
|
foot.innerHTML = footer(last);
|
|
68
160
|
};
|
|
69
161
|
const prompts = PromptAnswers.wire(B, {
|
|
@@ -73,9 +165,9 @@ ${sitesHtml(d.groups)}${d.remembered?.length ? `<h2 class="label">Sessions witho
|
|
|
73
165
|
after: () => load(),
|
|
74
166
|
});
|
|
75
167
|
const forms = HubKit.forms(B, {
|
|
76
|
-
accepts: (action, form) => action === '/_hub/push/flush' || ['/_hub/settings/codex', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action) || Boolean(form?.closest?.('.hold-banner')),
|
|
168
|
+
accepts: (action, form) => action === '/_hub/push/flush' || ['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action) || Boolean(form?.closest?.('.hold-banner')),
|
|
77
169
|
answer(out, action) {
|
|
78
|
-
if (['/_hub/settings/codex', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
|
|
170
|
+
if (['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
|
|
79
171
|
last = out; flash = null; paint();
|
|
80
172
|
if (action.endsWith('/keep-going')) B.toast(out.pause.state === 'going' ? `Keep going: work runs past the buffer until ${BoardUI.usageClock(out.pause.resets_at)}.` : 'Stopped: new work waits again.');
|
|
81
173
|
} else if (action === '/_hub/push/flush') { flash = { text: out.message, error: !out.ok }; load(); } else { flash = null; load(); settings.B.refreshSettings?.(); }
|
|
@@ -1,9 +1,10 @@
|
|
|
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
|
+
// ?wave=<slug> shows a wave's goal, order and note instead, and back goes to the board.
|
|
3
4
|
(globalThis.AppViews ||= {}).notes = {
|
|
4
5
|
className: 'view-notes',
|
|
5
6
|
title: () => 'Notes',
|
|
6
|
-
markup: ({ item }) => `${BoardUI.header({ lead: 'back', title: 'Notes', titleId: 'ref', backHref: `./#item=${encodeURIComponent(item || '')}`, controls: false })}
|
|
7
|
+
markup: ({ item, wave }) => `${BoardUI.header({ lead: 'back', title: 'Notes', titleId: 'ref', backHref: wave ? './' : `./#item=${encodeURIComponent(item || '')}`, controls: false })}
|
|
7
8
|
<main>
|
|
8
9
|
<article class="plan" id="plan"><p class="muted">Loading the notes…</p></article>
|
|
9
10
|
</main>`,
|
|
@@ -12,15 +13,17 @@
|
|
|
12
13
|
const { $, site } = B;
|
|
13
14
|
const { esc, markdown, linkContext, fetchJSON, restartNotice } = BoardUI;
|
|
14
15
|
const key = ctx.view.item || '';
|
|
16
|
+
const wave = ctx.view.wave;
|
|
15
17
|
const el = $('#plan');
|
|
16
18
|
const get = (url, opts = {}) => fetchJSON(url, { ...opts, sleep: B.sleep });
|
|
17
19
|
try {
|
|
18
20
|
const board = get(site.url('api/board')).then((r) => r.body).catch(() => ({}));
|
|
19
|
-
const
|
|
21
|
+
const query = wave ? `wave=${encodeURIComponent(wave)}` : `item=${encodeURIComponent(key)}`;
|
|
22
|
+
const r = await get(site.url(`api/notes?${query}`), { onWait: () => { el.innerHTML = restartNotice(); } });
|
|
20
23
|
if (!B.alive()) return;
|
|
21
24
|
if (!r.ok) throw new Error(r.body.error || `notes ${r.status}`);
|
|
22
|
-
$('#ref').textContent = `${r.body.ref} notes`;
|
|
23
|
-
document.title =
|
|
25
|
+
$('#ref').textContent = `${wave ? r.body.title : r.body.ref} notes`;
|
|
26
|
+
document.title = $('#ref').textContent;
|
|
24
27
|
el.innerHTML = markdown(r.body.markdown, linkContext((await board).items || [], key, { base: './' }));
|
|
25
28
|
} catch (e) {
|
|
26
29
|
el.innerHTML = `<p class="muted">Could not load these notes: ${esc(e.message)}</p>`;
|