@lovek4d/kickflip 0.6.1 → 0.7.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 +60 -48
- package/SKILL.md +20 -14
- package/bin/event-lines.mjs +1 -0
- package/bin/kickflip.mjs +8 -8
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +118 -104
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -9
- package/board/hub-lines.mjs +1 -0
- package/board/onboarding.mjs +2 -2
- package/board/prototype-page.mjs +1 -1
- package/board/public/app-arch.js +3 -3
- package/board/public/app-banner.js +1 -1
- package/board/public/app-cards.js +17 -9
- package/board/public/app-header.js +1 -1
- package/board/public/app-lanes.js +39 -61
- package/board/public/app-needs.js +116 -30
- package/board/public/app-reader.js +54 -44
- package/board/public/app-threads.js +2 -2
- package/board/public/app.html +1 -1
- package/board/public/app.js +23 -13
- package/board/public/dialogs.js +1 -1
- package/board/public/hub-kit.js +1 -1
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +1 -1
- package/board/public/new-task.js +1 -1
- package/board/public/progress-review.js +4 -4
- package/board/public/prototype-kit.js +27 -16
- package/board/public/prototype-switcher.js +47 -32
- package/board/public/prototype.html +7 -4
- package/board/public/router.js +35 -7
- package/board/public/shell.js +19 -13
- package/board/public/sidebar.css +4 -4
- package/board/public/sidebar.js +2 -2
- package/board/public/style.css +74 -36
- package/board/public/ui-cards.js +56 -37
- package/board/public/ui-enums.js +10 -1
- package/board/public/ui-hub.js +1 -5
- package/board/public/ui-links.js +6 -6
- package/board/public/ui-order.js +8 -6
- package/board/public/ui-progress-bar.js +6 -7
- package/board/public/ui-questions.js +30 -13
- package/board/public/ui-sidebar.js +3 -2
- package/board/public/ui-threads.js +20 -19
- package/board/public/ui.js +15 -4
- package/board/public/view-hub-settings.js +28 -31
- package/board/public/view-hub.js +7 -9
- package/board/public/view-notes.js +2 -2
- package/board/public/view-questions.js +35 -6
- package/board/public/view-settings.js +63 -31
- package/board/render-check.mjs +10 -10
- package/board/review.mjs +1 -1
- package/board/server.mjs +18 -20
- package/board/stop.mjs +1 -1
- package/board/store.mjs +26 -25
- package/board/tickets.mjs +1 -1
- package/board/transcript.mjs +4 -2
- package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
- package/hub/awake.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/github.mjs +3 -3
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/try.mjs +6 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/tailscale.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
// Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
|
|
2
2
|
(function () {
|
|
3
3
|
const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
|
|
4
|
+
// ui-markdown.js loads after this file, so markdown is looked up when a question draws.
|
|
5
|
+
const markdown = (src, links) => globalThis.BoardUI.markdown(src, links);
|
|
4
6
|
// A short id such as A or C2 leads the label; a word id such as "inline" would repeat it.
|
|
5
7
|
const lead = (o) => (/^[A-Z][0-9]?$/i.test(o.id) ? `${o.id}. ` : '');
|
|
6
8
|
const choiceLabel = (q, id) => { const o = q.options.find((x) => x.id === id); return o ? `${q.multi ? '' : lead(o)}${o.label}` : id || ''; };
|
|
7
|
-
// suggested: the line under a suggested option
|
|
8
|
-
const option = (o, { type = 'radio', name, checked = false, numbered = true, locked = false, suggested = '' }) => `<label class="opt"><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
|
|
9
|
-
<span><span class="lbl">${numbered ? escText(lead(o)) : ''}${escText(o.label)}</span>${
|
|
9
|
+
// suggested: the orchestrator's blue line under a suggested option's label, as HTML.
|
|
10
|
+
const option = (o, { type = 'radio', name, checked = false, numbered = true, locked = false, suggested = '', links = {} }) => `<label class="opt"><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
|
|
11
|
+
<span><span class="lbl">${numbered ? escText(lead(o)) : ''}${escText(o.label)}</span>${o.recommended ? '<span class="rec">recommended</span>' : ''}${suggested ? `<div class="suggested-why">Orchestrator choice: ${suggested}</div>` : ''}<div class="det">${o.detail ? markdown(o.detail, links) : ''}</div></span></label>`;
|
|
10
12
|
// docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
|
|
11
13
|
// A question's pick on this device: its unsent draft, else its last answer, else the option the overnight triage
|
|
12
14
|
// suggested, pre-checked so the topic's Send sends it.
|
|
13
|
-
const choiceOf = (q, draft) => draft.choice
|
|
15
|
+
const choiceOf = (q, draft) => 'choice' in draft ? draft.choice : (q.answers.length ? q.answers[q.answers.length - 1].choice : q.suggested?.option ?? null);
|
|
14
16
|
// A question's unsent note: its draft, else on a reopened question the note its last answer sent, so a changed pick
|
|
15
17
|
// sends it again.
|
|
16
18
|
const noteOf = (q, draft) => draft.note ?? (q.reopened ? q.answers.at(-1)?.note || '' : '');
|
|
@@ -22,7 +24,7 @@
|
|
|
22
24
|
// phone's deck (app-needs.js) and the prototype viewer's Choose this (prototype-switcher.js) write it.
|
|
23
25
|
const getDraft = (id) => { try { return JSON.parse(localStorage.getItem('qdraft:' + id) || '{}'); } catch { return {}; } };
|
|
24
26
|
const setDraft = (id, v) => { try { localStorage.setItem('qdraft:' + id, JSON.stringify(v)); } catch {} };
|
|
25
|
-
const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(`
|
|
27
|
+
const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(q.suggested.reason ?? `you answered this on ${q.suggested.card}: "${q.suggested.answer}"`, links) : '';
|
|
26
28
|
|
|
27
29
|
const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
|
|
28
30
|
// A question's eye sends `prototype-request {key?, question, note, choice}` for that question alone, which then reads
|
|
@@ -83,7 +85,7 @@
|
|
|
83
85
|
|
|
84
86
|
function takes(q, chosen, links, locked = false) {
|
|
85
87
|
const name = 'c-' + q.id;
|
|
86
|
-
return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })}${o.diagram ? diagram(o.diagram) + practice(o.practice || [], links) : ''}</article>`).join('')}</div>`;
|
|
88
|
+
return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links), links })}${o.diagram ? diagram(o.diagram) + practice(o.practice || [], links) : ''}</article>`).join('')}</div>`;
|
|
87
89
|
}
|
|
88
90
|
// A confirm says why it is asked; its View prototypes opens the viewer on its one take (?variant=).
|
|
89
91
|
const confirmLine = (q) => q.confirm_of && q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : '';
|
|
@@ -102,10 +104,10 @@
|
|
|
102
104
|
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge('ready', '✓ Resolved', { button: true, title: 'Reopen this question', attrs: { type: 'button', 'data-reopen': q.id } })}</div>
|
|
103
105
|
<h3>${linkText(q.question, links)}</h3>
|
|
104
106
|
${q.answers.length ? '<div class="sent-line">Sent. Tap Resolved to reopen it.</div>' : ''}
|
|
105
|
-
${q.context ? `<div class="ctx">${
|
|
107
|
+
${q.context ? `<div class="ctx">${markdown(q.context, links)}</div>` : ''}
|
|
106
108
|
${questionLink(q.url)}
|
|
107
109
|
${outcome || acted ? `<div class="small muted resolved-line">${outcome}${acted}</div>` : ''}
|
|
108
|
-
${q.options.map((o) => option(o, { name: 'c-' + q.id, checked: last?.choice === o.id, locked: true })).join('')}
|
|
110
|
+
${q.options.map((o) => option(o, { name: 'c-' + q.id, checked: last?.choice === o.id, locked: true, links })).join('')}
|
|
109
111
|
${said}
|
|
110
112
|
${q.reask ? '<div class="small muted reask-line">re-ask requested</div>' : ''}
|
|
111
113
|
</section>`;
|
|
@@ -121,16 +123,16 @@
|
|
|
121
123
|
return `<section class="q${q.urgent && q.state === 'open' ? ' urgent' : ''}${locked ? ' pr-busy' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
|
|
122
124
|
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, QUESTION_STATE[q.state].label)}</div>
|
|
123
125
|
<h3>${linkText(q.question, links)}</h3>
|
|
124
|
-
${q.context ? `<div class="ctx">${
|
|
126
|
+
${q.context ? `<div class="ctx">${markdown(q.context, links)}</div>` : ''}
|
|
125
127
|
${questionLink(q.confirm_of && isPrototype(q.url) ? withParam(q.url, 'variant', q.options[0].id) : q.url)}
|
|
126
128
|
${confirmLine(q)}
|
|
127
|
-
${drawn(q) ? takes(q, chosen, links, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })).join('')}
|
|
129
|
+
${drawn(q) ? takes(q, chosen, links, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links), links })).join('')}
|
|
128
130
|
${locked ? (q.request.note ? `<div class="ans"><div class="muted small">You · ${esc(time(q.request.ts))}</div><div>${linkText(q.request.note, links)}</div></div>` : '') : noteComposer(q, noteOf(q, draft))}
|
|
129
131
|
${answerHistory(q, links)}
|
|
130
132
|
</section>`;
|
|
131
133
|
}
|
|
132
|
-
// A card's
|
|
133
|
-
const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) :
|
|
134
|
+
// A pill reads its topic's title, a card's being its ref then its title, whichever tracker it comes from; an ask (item:ask:<id>) reads the time it appeared.
|
|
135
|
+
const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) : t.title);
|
|
134
136
|
// /api/board's ownerTopics, each card's topic named from its card, which the board sends in its place while the card
|
|
135
137
|
// is on the board (board/server.mjs pageBoard). cards: the board's items.
|
|
136
138
|
function cardTopics(topics, cards) {
|
|
@@ -174,5 +176,20 @@
|
|
|
174
176
|
},
|
|
175
177
|
};
|
|
176
178
|
|
|
177
|
-
|
|
179
|
+
// A prototype viewer's Round pill and its sheet, on the full page (prototype-switcher.js) and in the board's right pane
|
|
180
|
+
// (app-needs.js). rounds: the card's prototype questions, oldest round first, as /api/board's card prototypes.rounds
|
|
181
|
+
// lists them ({n, q, url, confirm, state}). The pill names the round qid is in, out of the newest round's number; a
|
|
182
|
+
// card with a single round gets none.
|
|
183
|
+
function roundPill(rounds, qid) {
|
|
184
|
+
const at = (rounds || []).find((r) => r.q === qid);
|
|
185
|
+
if (!at || rounds.length < 2) return '';
|
|
186
|
+
return `<button type="button" class="ps-chip" data-ps-top="rounds">Round ${at.n} of ${Math.max(...rounds.map((r) => r.n))} · earlier</button>`;
|
|
187
|
+
}
|
|
188
|
+
const ROUND_WORD = { open: 'open', requested: 'prototype requested' };
|
|
189
|
+
function roundSheet({ ref, rounds, qid }) {
|
|
190
|
+
const rows = [...rounds].reverse().map((r) => `<button type="button" data-ps-round="${esc(r.url)}"><span>Round ${r.n}${r.confirm ? ' confirm' : ''}${r.q === qid ? ' · showing' : ''}</span><span>${ROUND_WORD[r.state] || 'answered'}</span></button>`);
|
|
191
|
+
return `<div class="ps-sheet" role="dialog" aria-label="Rounds"><h3>Rounds on ${esc(ref)}</h3>${rows.join('')}</div>`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
Object.assign(globalThis.BoardUI, { isPrototype, getDraft, setDraft, choiceLabel, choiceOf, noteOf, isSent, option, answerHistory, questionCard, roundChip, roundPill, roundSheet, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
|
|
178
195
|
})();
|
|
@@ -209,7 +209,8 @@
|
|
|
209
209
|
// (docs/adr/2026-11-06-1305-a-busy-host-shows-only-on-the-hub-row-and-the-logo-dot.md).
|
|
210
210
|
const busy = hubBusy(data);
|
|
211
211
|
// A board under Pause board carries its own pause (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md).
|
|
212
|
-
|
|
212
|
+
// A red fault on a board row keeps its word, and a down or degraded hub keeps the Hub row: red and orange beat the grey pause (AGENTS.md).
|
|
213
|
+
const holdWord = (s) => (paused || s.pause?.state === 'paused') && !/class="word t-down"/.test(s.sub || '') ? word('paused', 'paused') : '';
|
|
213
214
|
const siteRow = (s) => {
|
|
214
215
|
const sub = s.board && holdWord(s) ? holdWord(s) + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
|
|
215
216
|
return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub ? `<span class="sb-status">${sub}</span>` : '', ctx: contextWords(s.ctx), on: s.route === here, key: keys?.get(siteHref(s)) });
|
|
@@ -227,7 +228,7 @@
|
|
|
227
228
|
: device === 'install' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Add Claude to your Home Screen', sub: 'Notifications come only to the Home Screen app', cls: 'sb-turnon' })
|
|
228
229
|
: '';
|
|
229
230
|
const usage = sidebarUsage(data?.usage);
|
|
230
|
-
const hub = paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: 'the host is busy' } : data?.hub;
|
|
231
|
+
const hub = data?.hub?.tone === 'down' || data?.hub?.tone === 'wait' ? data.hub : paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: 'the host is busy' } : data?.hub;
|
|
231
232
|
const n = data?.notifications || {};
|
|
232
233
|
const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
|
|
233
234
|
const notify = device === 'blocked' ? word('needs', 'Notifications blocked on this device')
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// A card's thread: the orchestrator's lines (who claude, named Orchestrator),
|
|
2
|
-
// their replies. Needs ui.js, ui-links.js and ui-enums.js.
|
|
1
|
+
// A card's thread: the orchestrator's lines (who claude, named Orchestrator), the board's status lines, its builders'
|
|
2
|
+
// asks, the owner's messages and their replies. Needs ui.js, ui-links.js and ui-enums.js.
|
|
3
3
|
(function () {
|
|
4
4
|
const { esc, escText, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, thinkingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
|
|
5
5
|
const VIA = { slack: ' · Slack', github: ' · GitHub' };
|
|
6
|
-
// A link to a card on this board (#
|
|
6
|
+
// A link to a card on this board (#selectedCard=<key>) or on another board (/<route>/#selectedCard=<key>, an ask's move), or ''.
|
|
7
7
|
function cardLink(link) {
|
|
8
|
-
const key = /^(?:\/[\w-]+\/)?#item=([^&]+)/.exec(link)?.[1];
|
|
8
|
+
const key = /^(?:\/[\w-]+\/)?#(?:selectedCard|item)=([^&]+)/.exec(link)?.[1];
|
|
9
9
|
if (!key) return '';
|
|
10
10
|
let ask = key.startsWith('ask:');
|
|
11
11
|
try { ask = decodeURIComponent(key).startsWith('ask:'); } catch {}
|
|
@@ -41,7 +41,10 @@
|
|
|
41
41
|
: m.kind === 'visual' ? `<div class="msg link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
42
42
|
// A request line's mark is its words' icon, or m.mark's for a line whose words vary; a Full person's names them.
|
|
43
43
|
: m.request && m.card ? `<div class="msg request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
44
|
-
: m.request ? `<div class="msg request">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
|
|
44
|
+
: m.request ? `<div class="msg request${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
|
|
45
|
+
// docs/adr/2026-11-09-1200-the-board-writes-status-lines-and-only-the-orchestrator-writes-messages.md: the board's
|
|
46
|
+
// own line, its kind's mark and words, red for a fault.
|
|
47
|
+
: m.kind === 'hub' ? `<div class="msg request hub${m.fault ? ' fault' : ''}">${statusMark(m.mark)}${m.link ? `<a href="${esc(m.link)}">${escText(m.text)}</a>` : escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
45
48
|
// docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md: a card's move between lanes.
|
|
46
49
|
: m.kind === 'move' ? `<div class="msg request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
47
50
|
: m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
@@ -52,22 +55,20 @@
|
|
|
52
55
|
${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
|
|
53
56
|
<div class="who">${m.who === 'love' ? (m.by ? esc(m.by) : 'You') : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
|
|
54
57
|
<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)}
|
|
55
|
-
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">
|
|
58
|
+
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">View transcript</a></div>` : ''}
|
|
56
59
|
${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
|
|
57
60
|
${m.eventId ? msgFooter(m) : ''}
|
|
58
61
|
</div>`);
|
|
59
62
|
|
|
60
63
|
// One agent run (domain.mjs threadOf gives its started line, status lines and finished line the same `run`) is one
|
|
61
|
-
// closed details line at its
|
|
62
|
-
//
|
|
64
|
+
// closed details line at its first line's place: the action, then its status, finished when its newest line is a finished
|
|
65
|
+
// line and running otherwise, and nothing after;
|
|
66
|
+
// expanding it shows the run's lines and, when the run has a log, a View transcript link.
|
|
63
67
|
function foldedRun(m, links, edits) {
|
|
64
|
-
const
|
|
65
|
-
const running = !m.folded.some((f) => f.finished);
|
|
66
|
-
const newest = statuses.at(-1);
|
|
67
|
-
const tail = running ? (newest ? `running · ${escText(newest.text)}` : 'running') : 'finished';
|
|
68
|
+
const tail = m.folded.at(-1).finished ? 'finished' : 'running';
|
|
68
69
|
const log = m.folded.find((f) => f.log)?.log;
|
|
69
|
-
const
|
|
70
|
-
return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${
|
|
70
|
+
const logLine = log ? `<div class="small fold-log"><a href="log.html?file=${encodeURIComponent(log)}">View transcript</a></div>` : '';
|
|
71
|
+
return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${tail === 'finished' ? ' class="finished"' : ''}>${statusMark('status lines')}${escText(m.agent)} · ${tail}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}${logLine}</details>`;
|
|
71
72
|
}
|
|
72
73
|
function foldStatus(lines) {
|
|
73
74
|
const runs = new Map();
|
|
@@ -82,12 +83,12 @@
|
|
|
82
83
|
const STEP = 3;
|
|
83
84
|
const UNREAD_CAP = 10;
|
|
84
85
|
// Every kind of system line; each leads with a mark, and a test renders each, so a new kind needs an entry in ui.js MARK_ICONS.
|
|
85
|
-
const SYSTEM_KINDS = ['status', 'agent', 'calls', 'ask-link', 'questions', 'visual', 'command', 'move'];
|
|
86
|
-
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions', 'move']);
|
|
86
|
+
const SYSTEM_KINDS = ['status', 'agent', 'calls', 'ask-link', 'questions', 'visual', 'command', 'move', 'hub'];
|
|
87
|
+
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions', 'move', 'hub']);
|
|
87
88
|
const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
|
|
88
|
-
// Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt,
|
|
89
|
-
// orchestrator's answer to a builder's ask never is.
|
|
90
|
-
const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.kind !== 'command' && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
|
|
89
|
+
// Only Claude's and GitHub's messages and the board's fault lines are unread, as on the badge (domain.mjs othersAt,
|
|
90
|
+
// ui-cards.js unreadCount); the orchestrator's answer to a builder's ask never is.
|
|
91
|
+
const unread = (m, seenAt) => Boolean(seenAt) && (m.fault || (isMessage(m) && m.kind !== 'command' && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github'))) && String(m.ts) > seenAt;
|
|
91
92
|
// The window opens from the oldest unread message, at most UNREAD_CAP messages back; a message being edited always shows.
|
|
92
93
|
function threadWindow(lines, { seenAt = null, more = 0, upto = null }, edits) {
|
|
93
94
|
const counted = lines.flatMap((m, i) => (isMessage(m) && (!upto || String(m.ts) <= upto) ? [i] : []));
|
package/board/public/ui.js
CHANGED
|
@@ -103,8 +103,13 @@
|
|
|
103
103
|
'ask-link': mini('<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>'),
|
|
104
104
|
move: mini('<path d="M3 7h14"/><path d="m13 3 4 4-4 4"/><path d="M21 17H7"/><path d="m11 13-4 4 4 4"/>'),
|
|
105
105
|
'Implementation requested': mini('<path d="m8 6-6 6 6 6"/><path d="m16 6 6 6-6 6"/>'),
|
|
106
|
+
'Marked as staging': mini('<path d="m12 2 10 5-10 5L2 7z"/><path d="m2 12 10 5 10-5"/><path d="m2 17 10 5 10-5"/>'),
|
|
107
|
+
'Marked as ready': mini('<path d="M4 22V4"/><path d="M4 4h13l-2 4 2 4H4"/>'),
|
|
106
108
|
Held: mini('<path d="M9 5v14"/><path d="M15 5v14"/>'),
|
|
107
109
|
Resumed: mini('<path d="M7 4v16l13-8z"/>'),
|
|
110
|
+
// The board's status lines (board/hub-lines.mjs): a block's circle with a bar, a fault's warning triangle.
|
|
111
|
+
Blocked: mini('<circle cx="12" cy="12" r="9"/><path d="M5.6 5.6l12.8 12.8"/>'),
|
|
112
|
+
Fault: mini('<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/>'),
|
|
108
113
|
'Related to': LINK, 'Unrelated from': LINK, 'Follow-up': LINK, 'Follow-up of': LINK,
|
|
109
114
|
'Marked done': mini('<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>'),
|
|
110
115
|
Reply: mini('<path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 6 6v5"/>'),
|
|
@@ -122,8 +127,8 @@
|
|
|
122
127
|
// Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
|
|
123
128
|
const withdrawButton = (id) => `<button class="ghost icon" data-withdraw="${esc(id)}" aria-label="Delete" title="Delete">${TRASH}</button>`;
|
|
124
129
|
const editButton = (id) => `<button class="ghost icon" data-edit="${esc(id)}" aria-label="Edit" title="Edit">${PENCIL}</button>`;
|
|
125
|
-
// A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange
|
|
126
|
-
//
|
|
130
|
+
// A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange and disabled once
|
|
131
|
+
// bumped. A work card's is the same icon: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where
|
|
127
132
|
// it is filled orange, card-unbump {key} (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
|
|
128
133
|
// docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
|
|
129
134
|
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>`;
|
|
@@ -399,7 +404,7 @@
|
|
|
399
404
|
const copyNote = (what, savedAt) => `<p class="note copy-note">Copy of ${esc(what)} as of ${since(savedAt)}. Read only.</p>`;
|
|
400
405
|
// The buttons that send, which a board out of reach holds (style.css .offline): every one greyed and stopped, keeping its
|
|
401
406
|
// label and size. Reading controls, folds, links, Edit and drafts stay live.
|
|
402
|
-
const SENDS = `form button[type="submit"], .actions button, button[data-confirm], button[data-deck-pick], button[data-review-answer], button[data-prompt-send], button[data-prompt-choice], button[data-quick], button[data-card-bump], button[data-bump], button[data-withdraw], button[data-
|
|
407
|
+
const SENDS = `form button[type="submit"], .actions button, button[data-confirm], button[data-deck-pick], button[data-review-answer], button[data-prompt-send], button[data-prompt-choice], button[data-quick], button[data-card-bump], button[data-bump], button[data-withdraw], button[data-grill], button[data-proto], button[data-visual], button[data-plan], button[data-main-pull], button[data-arch-run], button[data-try], button[data-reopen]`;
|
|
403
408
|
// While held(), a tap on one of root's sending buttons, and any form's submit there, Enter's included, goes nowhere, and onHeld
|
|
404
409
|
// tells the tap it is waiting. on is the view's B.on.
|
|
405
410
|
function holdSends(on, root, held, onHeld) {
|
|
@@ -445,8 +450,14 @@
|
|
|
445
450
|
}
|
|
446
451
|
}
|
|
447
452
|
|
|
453
|
+
// Whether a click dismisses a modal: it landed on the backdrop element and the press that made it began there too, so a
|
|
454
|
+
// drag that starts inside (selecting text) and ends on the backdrop leaves the modal open.
|
|
455
|
+
let pressedOn = null;
|
|
456
|
+
if (typeof document !== 'undefined') document.addEventListener('pointerdown', (e) => { pressedOn = e.target; }, true);
|
|
457
|
+
const onBackdrop = (ev, backdrop) => ev.target === backdrop && pressedOn === backdrop;
|
|
458
|
+
|
|
448
459
|
globalThis.BoardUI = {
|
|
449
|
-
LINK_ARROW, escText,
|
|
460
|
+
LINK_ARROW, escText, onBackdrop,
|
|
450
461
|
esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, WAITING, RESTARTING, restartNotice, downOf, downLanding, copyNote, SENDS, holdSends,
|
|
451
462
|
attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, visualButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, thinkingLine, isPaused, PAUSED, BADGE_TONES, badge,
|
|
452
463
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// HubSettings:
|
|
1
|
+
// HubSettings: Keep awake, Linear and Slack sit inline at the root of the Hub page ([keep awake, Linear and Slack sit inline](docs/adr/2026-11-09-2152-keep-awake-linear-and-slack-sit-inline-on-the-hub-page.md)), then
|
|
2
|
+
// summary rows open centred settings modals ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)), using
|
|
2
3
|
// settingsView's words from GET /_hub/api/settings. Pause hub, first, posts to /_hub/settings/hub-pause. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
|
|
3
|
-
// hours' times and hold to /_hub/settings/quiet, and Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key and Slack's token do through linear-key.js
|
|
4
|
-
// a key's save to land. The rules editor saves through POST /_hub/settings/rules
|
|
4
|
+
// hours' times and hold to /_hub/settings/quiet, and Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key and Slack's token do through linear-key.js. 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 () {
|
|
7
7
|
// The last data, which a return to the view paints at once.
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
action: '/_hub/settings/hub-pause', name: 'pause', choices: p.choices, on: p.on, button: 'Resume' });
|
|
16
16
|
const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
|
|
17
17
|
choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
// A checkbox row like Push notifications, grey while off and orange while on, as the machine working
|
|
19
|
+
// (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md). Unchecked posts nothing, which the hub reads as off.
|
|
20
|
+
const awakeRow = (a) => `<form class="row field" method="post" action="/_hub/settings/keep-awake">${HubUI.tile('coffee', a.on ? 'var(--color-notice)' : 'var(--color-quiet)')}<label class="body"><span class="text"><span class="title">Keep awake</span></span><input type="checkbox" id="awake" name="keep_awake" value="on" class="toggle" data-submit${a.on ? ' checked' : ''}></label></form>`;
|
|
21
21
|
|
|
22
22
|
// The on/off, snooze and weekend rows (docs/adr/2026-10-29-1639-notifications-can-be-off-snoozed-or-off-on-weekends.md).
|
|
23
23
|
function notificationRows(d) {
|
|
@@ -40,12 +40,14 @@
|
|
|
40
40
|
+ `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
const sections = [['pause', 'Pause hub'], ['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['
|
|
43
|
+
const sections = [['pause', 'Pause hub'], ['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
44
44
|
const sectionOf = (hash) => {
|
|
45
45
|
const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
|
|
46
46
|
return sections.some(([section]) => section === id) ? id : null;
|
|
47
47
|
};
|
|
48
|
-
const markup = () =>
|
|
48
|
+
const markup = () => `<div class="hub-inline"><div id="keep-awake"></div><div id="linear" class="lk-inline"></div><div id="slack" class="lk-inline"></div></div>
|
|
49
|
+
<h2 class="label" id="hubSettingsLabel">Hub settings</h2>
|
|
50
|
+
${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('')}
|
|
49
51
|
<dialog class="settings-modal hub-modal" id="hubSettingsModal" aria-labelledby="hubSettingsTitle"><div class="settings-head"><div><h2 id="hubSettingsTitle" tabindex="-1"></h2><p class="muted small">Hub settings</p></div><button type="button" class="ghost" data-settings-dismiss aria-label="Close hub settings">✕</button></div><div class="set settings-body"><div class="hs-flash"></div><div class="setting-content"></div></div><div class="settings-footer"><button type="button" data-settings-dismiss>Done</button></div></dialog>`;
|
|
50
52
|
|
|
51
53
|
globalThis.HubSettings = {
|
|
@@ -61,7 +63,7 @@
|
|
|
61
63
|
let flash = null;
|
|
62
64
|
let rules = null;
|
|
63
65
|
let rulesJSON = '';
|
|
64
|
-
|
|
66
|
+
const tokenSlots = {};
|
|
65
67
|
const pickers = HubKit.durationPickers(B);
|
|
66
68
|
|
|
67
69
|
function paint() {
|
|
@@ -69,35 +71,22 @@
|
|
|
69
71
|
const { row, tile, deviceRow } = HubUI;
|
|
70
72
|
top.innerHTML = HubUI.flash(flash);
|
|
71
73
|
const scrollTop = B.$('.settings-body').scrollTop;
|
|
74
|
+
paintInline();
|
|
72
75
|
if (active === 'pause') {
|
|
73
76
|
content.innerHTML = `<div class="group">${pauseRow(last.pause)}</div><p class="note">Running steps finish. No new builders, planners or pulls start on any board until then, as when usage reaches the pause line.</p>`;
|
|
74
77
|
} else if (active === 'notifications') {
|
|
75
78
|
content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}</div>`;
|
|
76
79
|
} else if (active === 'quiet') {
|
|
77
80
|
content.innerHTML = quietRows(last.quiet);
|
|
78
|
-
} else if (active === 'keep-awake') {
|
|
79
|
-
content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
|
|
80
|
-
: `<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>`;
|
|
81
|
-
} else if (active === 'linear' || active === 'slack') {
|
|
82
|
-
if (tokenSlot) tokenSlot.set(last[active]);
|
|
83
|
-
else {
|
|
84
|
-
tokenSlot = LinearKey.mount(content, { kind: active, state: last[active],
|
|
85
|
-
saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); },
|
|
86
|
-
busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
|
|
87
|
-
}
|
|
88
81
|
} else if (active === 'closed') {
|
|
89
82
|
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>`;
|
|
90
83
|
}
|
|
91
84
|
pickers.painted();
|
|
92
85
|
B.$('.settings-body').scrollTop = scrollTop;
|
|
93
|
-
B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
|
|
94
86
|
const summaries = {
|
|
95
87
|
pause: last.pause.summary,
|
|
96
88
|
notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
|
|
97
89
|
quiet: last.quiet.summary,
|
|
98
|
-
'keep-awake': last.awake.summary,
|
|
99
|
-
linear: last.linear?.summary || 'No key',
|
|
100
|
-
slack: last.slack?.summary || 'No token',
|
|
101
90
|
rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
|
|
102
91
|
closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
|
|
103
92
|
};
|
|
@@ -127,6 +116,19 @@
|
|
|
127
116
|
}
|
|
128
117
|
}
|
|
129
118
|
|
|
119
|
+
// Keep awake's row and note, and Linear's and Slack's token slots, drawn into the page itself.
|
|
120
|
+
function paintInline() {
|
|
121
|
+
B.$('#keep-awake').innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
|
|
122
|
+
: `<div class="group">${awakeRow(last.awake)}</div><p class="note">Runs <code>caffeinate -s</code> on the host so it doesn’t sleep while boards work, only while it is on AC power. On battery it holds nothing. The screen can still turn off.</p>`;
|
|
123
|
+
for (const kind of ['linear', 'slack']) {
|
|
124
|
+
if (tokenSlots[kind]) tokenSlots[kind].set(last[kind]);
|
|
125
|
+
else {
|
|
126
|
+
tokenSlots[kind] = LinearKey.mount(B.$(`#${kind}`), { kind, state: last[kind],
|
|
127
|
+
saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); } });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
130
132
|
// hub-kit.js device() titles its row 'This device' only where this device can take pushes.
|
|
131
133
|
function deviceSummary() {
|
|
132
134
|
const d = device.state('');
|
|
@@ -138,7 +140,6 @@
|
|
|
138
140
|
function closeModal(restore = true) {
|
|
139
141
|
unfitModal?.(); unfitModal = null;
|
|
140
142
|
modal.close();
|
|
141
|
-
tokenSlot?.destroy(); tokenSlot = null;
|
|
142
143
|
content.innerHTML = '';
|
|
143
144
|
rules = null;
|
|
144
145
|
active = null;
|
|
@@ -171,16 +172,13 @@
|
|
|
171
172
|
history.replaceState(history.state, '', settingsURL);
|
|
172
173
|
return true;
|
|
173
174
|
};
|
|
174
|
-
const dismiss = () => {
|
|
175
|
-
if (!tokenSlot?.busy()) { B.openSettings({ kind: 'hub' }); return; }
|
|
176
|
-
tokenSlot.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
|
|
177
|
-
};
|
|
175
|
+
const dismiss = () => { B.openSettings({ kind: 'hub' }); };
|
|
178
176
|
B.on(modal, 'cancel', (e) => { e.preventDefault(); dismiss(); });
|
|
179
177
|
B.on(root, 'click', (e) => {
|
|
180
178
|
const opener = e.target.closest?.('[data-settings-open]');
|
|
181
179
|
if (opener) { B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener); return; }
|
|
182
180
|
if (e.target.closest?.('[data-settings-dismiss]')) { dismiss(); return; }
|
|
183
|
-
if (e
|
|
181
|
+
if (BoardUI.onBackdrop(e, modal)) {
|
|
184
182
|
const r = modal.getBoundingClientRect();
|
|
185
183
|
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) dismiss();
|
|
186
184
|
}
|
|
@@ -189,7 +187,7 @@
|
|
|
189
187
|
if (!(await B.openSettings(AppRouter.viewOf(location.href))) && B.alive()) history.replaceState(history.state, '', settingsURL);
|
|
190
188
|
});
|
|
191
189
|
let reopening = null;
|
|
192
|
-
B.atUnmount(() => closeModal(false));
|
|
190
|
+
B.atUnmount(() => { closeModal(false); for (const slot of Object.values(tokenSlots)) slot.destroy(); });
|
|
193
191
|
const device = HubKit.device(B, { paint, changed: () => ctx.sidebarChanged?.() });
|
|
194
192
|
device.ready.then(() => { if (B.alive()) paint(); });
|
|
195
193
|
B.on(root, 'click', async (e) => {
|
|
@@ -216,7 +214,6 @@
|
|
|
216
214
|
else on.checked = true;
|
|
217
215
|
});
|
|
218
216
|
const forms = HubKit.forms(B, {
|
|
219
|
-
accepts: (action) => action !== '/_hub/push/flush',
|
|
220
217
|
answer(out) {
|
|
221
218
|
last = out;
|
|
222
219
|
flash = null;
|
package/board/public/view-hub.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
|
-
//
|
|
3
|
+
// then Keep awake, Linear and Slack inline, 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
4
|
// the Hub row Restart hub while the hub runs older code than its files. Health comes from GET /_hub/api/hub.
|
|
5
5
|
// Its data refreshes every 30 seconds while it shows (hub-kit.js live).
|
|
6
6
|
(function () {
|
|
@@ -62,20 +62,18 @@
|
|
|
62
62
|
const restartHubButton = (busy) => `<button type="button" class="btn quiet restart-btn" id="restart-hub" data-restart-hub${busy ? BoardUI.BUSY : ''}>Restart hub</button>`;
|
|
63
63
|
|
|
64
64
|
function html(d, { flash, prompts, restarting, restartingHub }) {
|
|
65
|
-
const { row, tile
|
|
65
|
+
const { row, tile } = HubUI;
|
|
66
66
|
const usage = HubUI.usageCard(d.usage, d.codex, d.pause, d.claude);
|
|
67
67
|
// 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.
|
|
68
68
|
const word = d.hub.restart
|
|
69
69
|
? `<button type="button" class="st ${esc(d.hub.tone)}" data-restart-hub${restartingHub ? BoardUI.BUSY : ''}>${esc(d.hub.word)}</button>`
|
|
70
70
|
: `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span>`;
|
|
71
71
|
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>
|
|
72
|
-
<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
|
|
72
|
+
<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></span></span>` : ''}`, wrap: true,
|
|
73
73
|
trail: d.hub.restart ? restartHubButton(restartingHub) : '' })}
|
|
74
74
|
${HubUI.machineRow(d.pause.machine)}
|
|
75
75
|
${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip <route></code>.' }) : ''}</div>
|
|
76
|
-
${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
|
|
77
|
-
<h2 class="label">Polling status</h2>
|
|
78
|
-
<div class="group">${nowRow(d.polling, tile('clock', d.polling.count ? 'var(--color-notice)' : 'var(--color-quiet)'))}</div>`;
|
|
76
|
+
${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>`;
|
|
79
77
|
}
|
|
80
78
|
|
|
81
79
|
const footer = (d) => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip <route></code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v${esc(d.version)}</span></footer>`;
|
|
@@ -90,7 +88,7 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
90
88
|
className: 'hub-view view-hub',
|
|
91
89
|
title: () => 'Hub',
|
|
92
90
|
markup: () => `${BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png' })}
|
|
93
|
-
<main class="has-top"><div class="hub-status"></div><section class="hub-settings"
|
|
91
|
+
<main class="has-top"><div class="hub-status"></div><section class="hub-settings">${HubSettings.markup()}</section><div class="hub-foot"></div></main>`,
|
|
94
92
|
|
|
95
93
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
96
94
|
const main = B.$('.hub-status');
|
|
@@ -180,14 +178,14 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
180
178
|
after: () => load(),
|
|
181
179
|
});
|
|
182
180
|
const forms = HubKit.forms(B, {
|
|
183
|
-
accepts: (action, form) =>
|
|
181
|
+
accepts: (action, form) => ['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action) || Boolean(form?.closest?.('.hold-banner')),
|
|
184
182
|
answer(out, action) {
|
|
185
183
|
if (['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
|
|
186
184
|
last = out; flash = null; paint();
|
|
187
185
|
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)}.`
|
|
188
186
|
: out.pause.near?.going ? `Keep going: agents start in parallel until ${BoardUI.usageClock(out.pause.near.resets_at)}.`
|
|
189
187
|
: out.pause.near ? 'Stopped: agents start one at a time again.' : 'Stopped: new work waits again.');
|
|
190
|
-
} else
|
|
188
|
+
} else { flash = null; load(); settings.B.refreshSettings?.(); }
|
|
191
189
|
ctx.sidebarChanged?.();
|
|
192
190
|
},
|
|
193
191
|
fail(message) { flash = { text: message, error: true }; paint(); },
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// An item's notes, /<route>/notes.html?item=<
|
|
1
|
+
// An item's notes, /<route>/notes.html?item=<file>/<id>, a view of the app shell (shell.js)
|
|
2
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',
|
|
6
6
|
title: () => 'Notes',
|
|
7
|
-
markup: ({ item, wave }) => `${BoardUI.header({ lead: 'back', title: 'Notes', titleId: 'ref', backHref: wave ? './' : `./#
|
|
7
|
+
markup: ({ item, wave }) => `${BoardUI.header({ lead: 'back', title: 'Notes', titleId: 'ref', backHref: wave ? './' : `./#selectedCard=${encodeURIComponent(item || '')}`, controls: false })}
|
|
8
8
|
<main>
|
|
9
9
|
<article class="plan" id="plan"><p class="muted">Loading the notes…</p></article>
|
|
10
10
|
</main>`,
|