@lovek4d/kickflip 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +177 -78
- package/README.md +46 -5
- package/SKILL.md +58 -37
- package/bin/doctor.mjs +4 -3
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +87 -32
- package/bin/statusline.mjs +4 -3
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +5 -0
- package/board/bin/agent.mjs +1 -1
- package/board/bin/board.mjs +241 -69
- package/board/bin/builder-stop-hook.mjs +4 -3
- package/board/domain.mjs +5 -3
- package/board/on-host.mjs +1 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +33 -14
- package/board/public/app-cards.js +242 -49
- package/board/public/app-core.js +39 -5
- package/board/public/app-header.js +24 -6
- package/board/public/app-lanes.js +76 -26
- package/board/public/app-needs.js +1 -0
- package/board/public/app-snapshot.js +45 -11
- package/board/public/app-threads.js +13 -11
- package/board/public/app.html +4 -1
- package/board/public/app.js +131 -20
- package/board/public/dialogs.js +114 -116
- package/board/public/hub-kit.js +19 -2
- package/board/public/hub.css +24 -18
- package/board/public/linear-key.js +141 -0
- package/board/public/router.js +6 -3
- package/board/public/rules-editor.js +1 -1
- package/board/public/shell.js +1 -1
- package/board/public/sidebar.css +31 -11
- package/board/public/sidebar.js +88 -16
- package/board/public/style.css +141 -46
- package/board/public/toasts.js +6 -4
- package/board/public/ui-cards.js +102 -37
- package/board/public/ui-header.js +17 -8
- package/board/public/ui-hub.js +30 -12
- package/board/public/ui-links.js +18 -10
- package/board/public/ui-order.js +18 -0
- package/board/public/ui-page-header.js +4 -2
- package/board/public/ui-questions.js +37 -16
- package/board/public/ui-sidebar.js +49 -11
- package/board/public/ui-snapshot.js +15 -9
- package/board/public/ui-threads.js +25 -16
- package/board/public/ui.js +23 -11
- package/board/public/view-hub-settings.js +46 -30
- package/board/public/view-hub.js +64 -13
- package/board/public/view-notes.js +7 -4
- package/board/public/view-polling.js +3 -3
- package/board/public/view-questions.js +42 -22
- package/board/public/view-settings.js +90 -35
- package/board/review.mjs +1 -0
- package/board/server.mjs +12 -7
- package/board/store.mjs +14 -6
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +14 -13
- package/docs/hub-session.md +3 -3
- 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 +4 -5
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -10
- package/hub/linear.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/pr-report.mjs +1 -0
- 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 +1 -1
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +4 -3
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/colors.css +7 -5
- package/shared/fake-gh.mjs +54 -0
- 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
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// HubSettings: summary rows below Hub’s health and status open centred settings modals (ADR 0075), using
|
|
2
|
-
// settingsView's words from GET /_hub/api/settings. Each toggle and snooze posts to /_hub/settings/notifications and
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// settingsView's words from GET /_hub/api/settings. Each toggle and snooze posts to /_hub/settings/notifications, and
|
|
3
|
+
// 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,25 +21,33 @@
|
|
|
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
|
|
|
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/0096-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
|
+
|
|
31
40
|
// The on/off, snooze and weekend rows (docs/adr/0032-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
|
-
const sections = [['notifications', 'Notifications'], ['polling', 'Polling'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
50
|
+
const sections = [['notifications', 'Notifications'], ['polling', 'Polling'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
42
51
|
const sectionOf = (hash) => {
|
|
43
52
|
const id = { push: 'notifications', 'closed-boards': 'closed' }[hash?.slice(1)] || hash?.slice(1);
|
|
44
53
|
return sections.some(([section]) => section === id) ? id : null;
|
|
@@ -60,6 +69,7 @@
|
|
|
60
69
|
let flash = null;
|
|
61
70
|
let rules = null;
|
|
62
71
|
let rulesJSON = '';
|
|
72
|
+
let linearKey = null;
|
|
63
73
|
|
|
64
74
|
function paint() {
|
|
65
75
|
if (!last) return;
|
|
@@ -68,21 +78,27 @@
|
|
|
68
78
|
const scrollTop = B.$('.settings-body').scrollTop;
|
|
69
79
|
if (active === 'notifications') {
|
|
70
80
|
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
|
-
|
|
81
|
+
} else if (active === 'keep-awake') {
|
|
82
|
+
content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
|
|
83
|
+
: `<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
84
|
} else if (active === 'linear') {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
if (linearKey) linearKey.set(last.linear);
|
|
86
|
+
else {
|
|
87
|
+
linearKey = LinearKey.mount(content, { linear: last.linear,
|
|
88
|
+
saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); },
|
|
89
|
+
busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
|
|
90
|
+
}
|
|
79
91
|
} else if (active === 'closed') {
|
|
80
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 ? ' disabled' : ''}>${reopening === b.route ? 'Reopening…' : 'Reopen'}</button></span></div>`).join('') : '<p class="note">No closed boards.</p>'}</div>`;
|
|
81
93
|
}
|
|
94
|
+
const input = B.$('[data-dur-at]');
|
|
95
|
+
if (input) customBounds(input, true);
|
|
82
96
|
B.$('.settings-body').scrollTop = scrollTop;
|
|
97
|
+
B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
|
|
83
98
|
const summaries = {
|
|
84
|
-
notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.
|
|
99
|
+
notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
|
|
85
100
|
polling: last.polling,
|
|
101
|
+
'keep-awake': last.awake.summary,
|
|
86
102
|
linear: last.linear?.summary || 'No key',
|
|
87
103
|
rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
|
|
88
104
|
closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
|
|
@@ -125,6 +141,7 @@
|
|
|
125
141
|
polling?.unmount(); polling = null;
|
|
126
142
|
unfitModal?.(); unfitModal = null;
|
|
127
143
|
modal.close();
|
|
144
|
+
linearKey?.destroy(); linearKey = null;
|
|
128
145
|
content.innerHTML = '';
|
|
129
146
|
rules = null;
|
|
130
147
|
active = null;
|
|
@@ -161,7 +178,10 @@
|
|
|
161
178
|
history.replaceState(history.state, '', settingsURL);
|
|
162
179
|
return true;
|
|
163
180
|
};
|
|
164
|
-
const dismiss = () =>
|
|
181
|
+
const dismiss = () => {
|
|
182
|
+
if (!linearKey?.busy()) { B.openSettings({ kind: 'hub' }); return; }
|
|
183
|
+
linearKey.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
|
|
184
|
+
};
|
|
165
185
|
B.on(modal, 'cancel', (e) => { e.preventDefault(); dismiss(); });
|
|
166
186
|
B.on(root, 'click', (e) => {
|
|
167
187
|
const opener = e.target.closest?.('[data-settings-open]');
|
|
@@ -181,7 +201,7 @@
|
|
|
181
201
|
device.ready.then(() => { if (B.alive()) paint(); });
|
|
182
202
|
let openPicker = null;
|
|
183
203
|
B.on(root, 'click', (e) => {
|
|
184
|
-
const input = e.target.closest?.('[data-
|
|
204
|
+
const input = e.target.closest?.('[data-dur-at]');
|
|
185
205
|
if (!input) return;
|
|
186
206
|
openPicker = input;
|
|
187
207
|
customBounds(input, input.value === input.defaultValue);
|
|
@@ -189,7 +209,7 @@
|
|
|
189
209
|
});
|
|
190
210
|
// A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
|
|
191
211
|
B.on(root, 'pointerdown', (e) => {
|
|
192
|
-
const input = e.target.closest?.('.
|
|
212
|
+
const input = e.target.closest?.('.dur-choices button') && B.$('[data-dur-at]');
|
|
193
213
|
if (!input) return;
|
|
194
214
|
openPicker = null;
|
|
195
215
|
input.value = input.defaultValue;
|
|
@@ -199,16 +219,16 @@
|
|
|
199
219
|
if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
|
|
200
220
|
const at = new Date(input.value);
|
|
201
221
|
if (Number.isNaN(at.getTime())) return;
|
|
202
|
-
input.form.querySelector('[
|
|
222
|
+
input.form.querySelector('input[type="hidden"]').value = at.toISOString();
|
|
203
223
|
input.form.requestSubmit();
|
|
204
224
|
openPicker = null;
|
|
205
225
|
input.defaultValue = input.value;
|
|
206
226
|
};
|
|
207
227
|
B.on(root, 'change', (e) => {
|
|
208
|
-
if (e.target.matches?.('[data-
|
|
228
|
+
if (e.target.matches?.('[data-dur-at]') && document.activeElement !== e.target) saveCustom(e.target);
|
|
209
229
|
});
|
|
210
230
|
B.on(root, 'focusout', (e) => {
|
|
211
|
-
if (e.target.matches?.('[data-
|
|
231
|
+
if (e.target.matches?.('[data-dur-at]')) saveCustom(e.target);
|
|
212
232
|
});
|
|
213
233
|
B.on(root, 'click', async (e) => {
|
|
214
234
|
const button = e.target.closest?.('[data-reopen]');
|
|
@@ -227,10 +247,6 @@
|
|
|
227
247
|
reopening = null;
|
|
228
248
|
if (B.alive()) paint();
|
|
229
249
|
});
|
|
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
250
|
B.on(root, 'change', async (e) => {
|
|
235
251
|
const on = e.target;
|
|
236
252
|
if (on.id !== 'notifyOn') return;
|
package/board/public/view-hub.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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 (ADR 0075). Health comes from GET /_hub/api/hub.
|
|
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 (ADR 0075). A board's row can carry Restart (hub/restart-board.mjs). Health comes from GET /_hub/api/hub.
|
|
4
4
|
// Its data refreshes every 30 seconds while it shows (hub-kit.js live).
|
|
5
5
|
(function () {
|
|
6
6
|
// The last data, which a return to the view paints at once.
|
|
@@ -11,30 +11,56 @@
|
|
|
11
11
|
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
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
const RESTARTING = '<span class="st wait">Restarting</span> · stopping its session, then starting it again';
|
|
15
|
+
|
|
16
|
+
// 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
|
+
function restartRow(s, sub, busy) {
|
|
19
|
+
const href = esc(s.href);
|
|
20
|
+
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 ? ' disabled aria-busy="true"' : ''}>${busy ? '<span class="try-spin" aria-hidden="true"></span>Restarting…' : 'Restart'}</button>`
|
|
22
|
+
+ `<a class="chev" href="${href}" tabindex="-1" aria-hidden="true">›</a></div>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// restarting: the routes whose Restart is out.
|
|
26
|
+
function sitesHtml(groups, restarting) {
|
|
15
27
|
const { row, CHEVRON } = HubUI;
|
|
16
28
|
const site = (s) => {
|
|
29
|
+
const busy = restarting.has(s.route);
|
|
17
30
|
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
|
-
|
|
31
|
+
const sub = busy ? RESTARTING : `<span class="st ${esc(s.tone)}">${esc(s.word)}</span>${lines}`;
|
|
32
|
+
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
33
|
};
|
|
20
34
|
return groups.map((g) => `<h2 class="label">${esc(g.label)}</h2>\n<div class="group">${g.sites.map(site).join('')}</div>\n`).join('');
|
|
21
35
|
}
|
|
22
36
|
|
|
37
|
+
function integrationsHtml(integrations) {
|
|
38
|
+
const { row, tile } = HubUI;
|
|
39
|
+
const fixHtml = (fix) => fix.command ? `<code>${esc(fix.command)}</code>` : fix.settings
|
|
40
|
+
? `${esc(fix.text)} <button type="button" class="link" data-settings-open="${esc(fix.settings)}">${esc(fix.label)}</button>.` : esc(fix.text);
|
|
41
|
+
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">Ready</span> · ${esc(it.detail)}`
|
|
43
|
+
: `<span class="st wait">Not ready</span> · ${esc(it.missing)}<span class="links"><span class="rd-fix">${fixHtml(it.fix)}</span><span class="rd-waits">${esc(it.waits)}</span></span>` })).join('');
|
|
44
|
+
return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
|
|
45
|
+
<p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
48
|
+
|
|
23
49
|
// A pending prompt from a session that owns no board waits here (prompts.js).
|
|
24
50
|
function promptsHtml(list, prompts) {
|
|
25
51
|
const shown = list.filter(prompts.shows);
|
|
26
52
|
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
53
|
}
|
|
28
54
|
|
|
29
|
-
function html(d, { flash, prompts }) {
|
|
55
|
+
function html(d, { flash, prompts, restarting }) {
|
|
30
56
|
const { row, tile, nowRow } = HubUI;
|
|
31
|
-
const usage = HubUI.usageCard(d.usage, d.codex, d.pause);
|
|
57
|
+
const usage = HubUI.usageCard(d.usage, d.codex, d.pause, d.claude);
|
|
32
58
|
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: `<
|
|
59
|
+
<div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `<button type="button" class="st ${esc(d.hub.tone)}" data-jump="integrations">${esc(d.hub.word)}</button> ·${esc(d.hub.detail)}${d.awake ? `<span class="links"><span><span class="awake-on">Keeping awake</span> ${esc(d.awake)}</span></span>` : ''}`, wrap: true })}
|
|
34
60
|
${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
|
|
61
|
+
${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
62
|
<h2 class="label">Polling status</h2>
|
|
37
|
-
<div class="group">${nowRow(d.polling, tile('clock', '
|
|
63
|
+
<div class="group">${nowRow(d.polling, tile('clock', d.polling.count ? 'var(--color-notice)' : 'var(--color-quiet)'))}</div>`;
|
|
38
64
|
}
|
|
39
65
|
|
|
40
66
|
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 +85,36 @@ ${sitesHtml(d.groups)}${d.remembered?.length ? `<h2 class="label">Sessions witho
|
|
|
59
85
|
B.atUnmount(() => settings.unmount());
|
|
60
86
|
B.on(B.$('#refresh'), 'click', () => settings.B.refreshSettings?.());
|
|
61
87
|
let flash = null;
|
|
88
|
+
const restarting = new Set();
|
|
62
89
|
B.openSettings = (...args) => settings.B.openSettings(...args);
|
|
90
|
+
async function restart(route, title) {
|
|
91
|
+
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;
|
|
92
|
+
restarting.add(route);
|
|
93
|
+
flash = null;
|
|
94
|
+
paint();
|
|
95
|
+
let out = null;
|
|
96
|
+
try {
|
|
97
|
+
const r = await fetch('/_hub/restart-board', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ route }) });
|
|
98
|
+
out = await r.json().catch(() => null);
|
|
99
|
+
if (!r.ok || !out) throw new Error();
|
|
100
|
+
if (B.alive()) B.toast(`${out.title} is back up, and its session is listening.`);
|
|
101
|
+
} catch { flash = { text: out?.error || `Could not restart ${title}. Is the host awake?`, error: true }; }
|
|
102
|
+
restarting.delete(route);
|
|
103
|
+
if (B.alive()) { paint(); load(); }
|
|
104
|
+
}
|
|
105
|
+
B.on(main, 'click', (e) => {
|
|
106
|
+
const opener = e.target.closest?.('[data-settings-open]');
|
|
107
|
+
if (opener) B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener);
|
|
108
|
+
// The Hub's status word jumps to what's behind it: Integrations, then each board's links below them.
|
|
109
|
+
const jump = e.target.closest?.('[data-jump]');
|
|
110
|
+
if (jump) B.$(`#${jump.dataset.jump}`).scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
111
|
+
const restarter = e.target.closest?.('[data-restart]');
|
|
112
|
+
if (restarter) restart(restarter.dataset.restart, restarter.dataset.title);
|
|
113
|
+
});
|
|
63
114
|
const foot = B.$('.hub-foot');
|
|
64
115
|
const paint = () => {
|
|
65
116
|
if (!last) return;
|
|
66
|
-
main.innerHTML = html(last, { flash, prompts });
|
|
117
|
+
main.innerHTML = html(last, { flash, prompts, restarting });
|
|
67
118
|
foot.innerHTML = footer(last);
|
|
68
119
|
};
|
|
69
120
|
const prompts = PromptAnswers.wire(B, {
|
|
@@ -73,9 +124,9 @@ ${sitesHtml(d.groups)}${d.remembered?.length ? `<h2 class="label">Sessions witho
|
|
|
73
124
|
after: () => load(),
|
|
74
125
|
});
|
|
75
126
|
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')),
|
|
127
|
+
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
128
|
answer(out, action) {
|
|
78
|
-
if (['/_hub/settings/codex', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
|
|
129
|
+
if (['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
|
|
79
130
|
last = out; flash = null; paint();
|
|
80
131
|
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
132
|
} 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
2
|
// (docs/adr/0053-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>`;
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
+ ` aria-label="${esc(g.aria)}"${g.w === sel ? ' aria-current="true"' : ''}>${g.label ? `<span>${esc(g.label)}</span>` : ''}</a>`).join('');
|
|
15
15
|
const pins = day.pins.map((p) => `<span class="pin" style="left:${p.left}" title="${esc(p.title)}" aria-hidden="true">▼</span>`).join('');
|
|
16
16
|
const ticks = ['12am', '6am', '12pm', '6pm', '12am'].map((t, i) => `<span style="left:${i * 25}%">${t}</span>`).join('');
|
|
17
|
-
const legend = ['<span><i style="background:var(--
|
|
18
|
-
day.held && '<span><i style="background:repeating-linear-gradient(135deg,var(--
|
|
17
|
+
const legend = ['<span><i style="background:var(--muted)"></i>darker checks more often</span>',
|
|
18
|
+
day.held && '<span><i style="background:repeating-linear-gradient(135deg,var(--muted) 0 3px,var(--chip) 3px 5px)"></i>pushes held</span>',
|
|
19
19
|
day.wakes && '<span style="color:var(--color-notice)">▼ wake</span>',
|
|
20
|
-
'<span><i style="background:var(--
|
|
20
|
+
'<span><i style="background:var(--text);width:2px"></i>now</span>'].filter(Boolean).join('');
|
|
21
21
|
return `<div class="day">${pins}<span class="now" style="left:${day.now}" aria-hidden="true"></span><div class="bar">${segs}</div><div class="ticks" aria-hidden="true">${ticks}</div></div><div class="legend">${legend}</div>`;
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// open question goes back to the board, where the Sent line shows as a toast (BoardUI.sentNotice). An open question
|
|
6
6
|
// with `urgent: true` in owner.json shows alone first, and its Send follows the same rule for its topic. A question
|
|
7
7
|
// whose options are prototype takes frames each take with ?take= (docs/adr/0048-a-questions-takes-show-inline.md).
|
|
8
|
+
// Each question's eye sends `prototype-request {key?, question, note, choice}` for that question instead of an answer,
|
|
9
|
+
// with its note box as the note and its pick so far as the choice (docs/adr/0126-each-question-has-its-own-prototype-eye.md).
|
|
8
10
|
// fits: each framed take's measured size, kept across repaints and visits so a take redraws at its size at once.
|
|
9
11
|
(globalThis.AppViews ||= {}).questions = ((fits) => ({
|
|
10
12
|
className: 'view-questions',
|
|
@@ -16,7 +18,7 @@
|
|
|
16
18
|
|
|
17
19
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
18
20
|
const { $, site } = B;
|
|
19
|
-
const { esc, choiceLabel, questionCard, takes, linkContext, badge, topicName, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
|
|
21
|
+
const { esc, choiceLabel, questionCard, roundChip, takes, linkContext, badge, topicName, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
|
|
20
22
|
// prototype-switcher.js reads a draft's note and clears the draft once it sends that question's answer.
|
|
21
23
|
const draftKey = (id) => 'qdraft:' + id;
|
|
22
24
|
const getDraft = (id) => { try { return JSON.parse(localStorage.getItem(draftKey(id)) || '{}'); } catch { return {}; } };
|
|
@@ -52,13 +54,14 @@
|
|
|
52
54
|
return `<div class="topics" role="group" aria-label="Topics">${list.map((t) =>
|
|
53
55
|
`<button data-topic="${esc(t.id)}" aria-pressed="${t.id === topicId}" title="${esc(t.title)}">${esc(topicName(t))}<span class="n">${t.state === 'resolved' ? 'resolved' : t.open}</span></button>`).join('')}</div>`;
|
|
54
56
|
}
|
|
55
|
-
const topicHead = (t) => t ? `<div class="topichead muted small">${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</div>` : '';
|
|
57
|
+
const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
|
|
56
58
|
|
|
57
59
|
// Only questions changed here since their last answer, or with files attached, are sent.
|
|
58
60
|
const filesOf = (q) => BoardAttach.files('q:' + q.id, site.root);
|
|
59
61
|
const uploading = (qs) => qs.some((q) => BoardAttach.busy('q:' + q.id, site.root));
|
|
60
62
|
function pending(qs) {
|
|
61
63
|
return qs.filter((q) => {
|
|
64
|
+
if (q.state === 'requested') return false;
|
|
62
65
|
const d = getDraft(q.id);
|
|
63
66
|
if (filesOf(q).length) return true;
|
|
64
67
|
if (!('choice' in d) && !d.note) return false;
|
|
@@ -199,7 +202,7 @@
|
|
|
199
202
|
function waitingTopics() {
|
|
200
203
|
const list = topicRow(topics, topicId).waiting;
|
|
201
204
|
return list.length ? `<details class="done"><summary>Waiting on Claude · ${list.length}</summary>${list.map((t) =>
|
|
202
|
-
`<div class="trow"><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button><span class="muted small">${t.answered ? 'answered' : 'Claude replied'}</span></div>`).join('')}</details>` : '';
|
|
205
|
+
`<div class="trow"><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button><span class="muted small">${t.requested ? 'prototype requested' : t.answered ? 'answered' : 'Claude replied'}</span></div>`).join('')}</details>` : '';
|
|
203
206
|
}
|
|
204
207
|
|
|
205
208
|
function resolvedTopics() {
|
|
@@ -208,6 +211,20 @@
|
|
|
208
211
|
`<div><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button></div>`).join('')}</details>` : '';
|
|
209
212
|
}
|
|
210
213
|
|
|
214
|
+
async function askPrototype(q) {
|
|
215
|
+
const st = $('#qstatus');
|
|
216
|
+
const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
|
|
217
|
+
st.textContent = 'Sending…';
|
|
218
|
+
const d = getDraft(q.id);
|
|
219
|
+
const last = q.answers[q.answers.length - 1];
|
|
220
|
+
try { await site.send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d.note || '').trim(), choice: d.choice ?? (last ? last.choice : null) }); }
|
|
221
|
+
catch { st.textContent = 'Could not send. Is the host awake?'; return; }
|
|
222
|
+
setDraft(q.id, {});
|
|
223
|
+
sentLine = 'Prototype please.';
|
|
224
|
+
await loading;
|
|
225
|
+
if (await load(false)) { shown = sigNow(); render(); }
|
|
226
|
+
}
|
|
227
|
+
|
|
211
228
|
function updateBar() {
|
|
212
229
|
const qs = visible();
|
|
213
230
|
const n = pending(qs).length;
|
|
@@ -226,7 +243,7 @@
|
|
|
226
243
|
topicId = t ? t.id : null;
|
|
227
244
|
}
|
|
228
245
|
|
|
229
|
-
const sigNow = () => JSON.stringify([soloId, topicId, topics.map((t) => [t.id, t.state, t.open, t.answered]), visible().map((q) => [q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply))])]);
|
|
246
|
+
const sigNow = () => JSON.stringify([soloId, topicId, topics.map((t) => [t.id, t.state, t.open, t.answered, t.round]), visible().map((q) => [q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply))])]);
|
|
230
247
|
// One load at a time: a load waiting out a board restart would otherwise overlap the next poll's. A load asked for
|
|
231
248
|
// while one runs shares it. → true when the board's answer landed in this view.
|
|
232
249
|
let loading = null;
|
|
@@ -290,6 +307,8 @@
|
|
|
290
307
|
|
|
291
308
|
B.on(root, 'click', (ev) => {
|
|
292
309
|
if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
|
|
310
|
+
const eye = ev.target.closest && ev.target.closest('[data-proto]');
|
|
311
|
+
if (eye) { if (!eye.disabled) askPrototype(byId(eye.closest('section.q').dataset.q)); return; }
|
|
293
312
|
const b = ev.target.closest && ev.target.closest('[data-topic]');
|
|
294
313
|
if (b) { soloId = null; topicId = b.dataset.topic; sentLine = ''; shown = ''; render(); scrollTo(0, 0); }
|
|
295
314
|
});
|
|
@@ -344,24 +363,25 @@
|
|
|
344
363
|
const from = soloId ? topicOf(soloId)?.id : topicId;
|
|
345
364
|
const pills = topicRow(topics, from).pills;
|
|
346
365
|
const name = topicName(topics.find((t) => t.id === from) || { title: '' });
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
366
|
+
// docs/adr/0163-send-records-every-ready-answer-in-one-request.md: one request, recorded all or none.
|
|
367
|
+
const answers = list.map((q) => {
|
|
368
|
+
const d = getDraft(q.id);
|
|
369
|
+
const last = q.answers[q.answers.length - 1];
|
|
370
|
+
const choice = d.choice ?? (last ? last.choice : null);
|
|
371
|
+
const files = filesOf(q);
|
|
372
|
+
return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d.note || '').trim(), ...(files.length ? { files } : {}) };
|
|
373
|
+
});
|
|
374
|
+
try { await site.send('owner-answers', { answers }); }
|
|
375
|
+
catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
|
|
376
|
+
for (const q of list) {
|
|
377
|
+
setDraft(q.id, {});
|
|
378
|
+
BoardAttach.clear('q:' + q.id, site.root);
|
|
379
|
+
}
|
|
380
|
+
const what = `Sent ${list.length} answer${list.length === 1 ? '' : 's'}${name ? ` to ${name}` : ''}.`;
|
|
381
|
+
// A load that started before these answers went out would read them as still open.
|
|
382
|
+
await loading;
|
|
383
|
+
if (await load(false)) moveOn(from, pills, what);
|
|
384
|
+
else if (B.alive()) { sentLine = what; $('#qstatus').textContent = what; $('#qstatus').classList.add('sent'); }
|
|
365
385
|
});
|
|
366
386
|
// Each composer painted raises a bubbling attach-change (attach.js).
|
|
367
387
|
B.on(root, 'attach-change', updateBar);
|