@lovek4d/kickflip 0.5.3 → 0.6.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 +82 -46
- package/SKILL.md +21 -15
- package/bin/doctor.mjs +5 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +12 -12
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +179 -92
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -5
- package/board/onboarding.mjs +11 -0
- package/board/prototype-base.mjs +1 -0
- package/board/prototype-page.mjs +1 -0
- package/board/public/app-arch.js +1 -1
- package/board/public/app-cards.js +40 -47
- package/board/public/app-header.js +6 -7
- package/board/public/app-lanes.js +19 -45
- package/board/public/app-needs.js +36 -31
- package/board/public/app-snapshot.js +43 -70
- package/board/public/app-threads.js +42 -46
- package/board/public/app.html +9 -4
- package/board/public/app.js +78 -32
- package/board/public/hub-kit.js +62 -2
- package/board/public/hub.css +2 -0
- package/board/public/new-task.js +8 -6
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +109 -4
- package/board/public/prototype-switcher.js +130 -31
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +65 -24
- package/board/public/sidebar.css +6 -0
- package/board/public/sidebar.js +19 -69
- package/board/public/style.css +116 -73
- package/board/public/ui-cards.js +78 -57
- package/board/public/ui-enums.js +2 -2
- package/board/public/ui-header.js +23 -16
- package/board/public/ui-hub.js +19 -5
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +1 -1
- package/board/public/ui-page-header.js +19 -7
- package/board/public/ui-patch.js +7 -33
- package/board/public/ui-progress-bar.js +28 -0
- package/board/public/ui-questions.js +25 -17
- package/board/public/ui-sidebar.js +31 -23
- package/board/public/ui-snapshot.js +44 -53
- package/board/public/ui-threads.js +57 -28
- package/board/public/ui.js +94 -18
- package/board/public/view-hub-settings.js +12 -58
- package/board/public/view-hub.js +5 -2
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +90 -68
- package/board/public/view-settings.js +133 -15
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +20 -19
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -18
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -2
- package/board/worktree.mjs +22 -21
- package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
- package/hooks-plugin/hooks/usage-gate.mjs +20 -3
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +2 -2
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/review-send.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -1
- package/hub/slack.mjs +2 -2
- package/hub/try.mjs +6 -6
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/card-prs.mjs +1 -1
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/tailscale.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A card's thread: the orchestrator's lines (who claude, named Orchestrator), its builders' asks, the owner's messages and
|
|
2
2
|
// their replies. Needs ui.js, ui-links.js and ui-enums.js.
|
|
3
3
|
(function () {
|
|
4
|
-
const { esc, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton,
|
|
4
|
+
const { esc, escText, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, thinkingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
|
|
5
5
|
const VIA = { slack: ' · Slack', github: ' · GitHub' };
|
|
6
6
|
// A link to a card on this board (#item=<key>) or on another board (/<route>/#item=<key>, an ask's move), or ''.
|
|
7
7
|
function cardLink(link) {
|
|
@@ -15,52 +15,79 @@
|
|
|
15
15
|
const stateOf = (m) => m.state || (m.reply ? 'replied' : 'waiting');
|
|
16
16
|
// A message's footer: the tap it came from (m.status, such as "answers submitted"), then, to its right, Claude's
|
|
17
17
|
// pick-up while it waits (docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
|
|
18
|
-
const PICK_UP = { working:
|
|
18
|
+
const PICK_UP = { working: thinkingLine, waiting: () => 'Waiting for Claude…' };
|
|
19
19
|
const msgFooter = (m) => {
|
|
20
|
-
const parts = [m.status && `<span class="foot-status">${statusMark(m.status)}${esc(m.status)}</span>`, PICK_UP[stateOf(m)]].filter(Boolean);
|
|
20
|
+
const parts = [m.status && `<span class="foot-status">${statusMark(m.status)}${esc(m.status)}</span>`, PICK_UP[stateOf(m)]?.()].filter(Boolean);
|
|
21
21
|
return parts.length ? `<div class="reply muted small">${parts.join('')}</div>` : '';
|
|
22
22
|
};
|
|
23
23
|
// Each reply becomes Claude's own message at the time it was written, quoting the owner's message
|
|
24
24
|
// when other messages came between them. A reply with no time follows its message.
|
|
25
25
|
const withReplies = (msgs) => {
|
|
26
|
+
// A folded submit's answers each reply, quoting their own block.
|
|
26
27
|
const out = msgs.flatMap((m, i) => [{ m, at: String(m.ts), i, part: 0 },
|
|
27
|
-
...(m.eventId &&
|
|
28
|
+
...(m.answers || [m]).filter((a) => a.eventId && a.reply).map((a) => ({ m: { ts: a.reply.at || m.ts, who: 'claude', kind: 'reply', reply: a.reply }, of: m, quoted: m.answers ? a.text : m.text, at: String(a.reply.at || m.ts), i, part: 1 }))]);
|
|
28
29
|
out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
|
|
29
|
-
return out.map(({ m, of }, n) => (of ? { ...m, quote: out[n - 1]?.m === of ? '' :
|
|
30
|
+
return out.map(({ m, of, quoted }, n) => (of ? { ...m, quote: out[n - 1]?.m === of && !(of.answers?.length > 1) ? '' : quoted || '' } : m));
|
|
30
31
|
};
|
|
31
32
|
// A message being edited shows its edit box in place of its text; app-asks.js handles the form, and attach.js the
|
|
32
33
|
// files it adds to the message, under composer id edit:<event id>.
|
|
33
34
|
const editForm = (id, text) => `<form class="edit-form" data-edit-form="${esc(id)}" data-composer="edit:${esc(id)}">${dropZone(`<textarea rows="3">${esc(text)}</textarea>`)}
|
|
34
35
|
<div class="pending-files" data-files></div>
|
|
35
36
|
<div class="send-actions"><button type="button" data-edit-cancel>Cancel</button>${sendButton('Save', 0)}</div></form>`;
|
|
36
|
-
|
|
37
|
-
const line = (m, links, edits, batched = false) => (m.kind === 'reply'
|
|
37
|
+
const line = (m, links, edits) => (m.kind === 'reply'
|
|
38
38
|
? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
|
|
39
|
-
: m.kind === 'ask-link' ? `<div class="msg link"
|
|
40
|
-
|
|
41
|
-
: m.kind === '
|
|
39
|
+
: m.kind === 'ask-link' ? `<div class="msg link">${statusMark('ask-link')}<a href="${esc(m.link)}">${escText(m.text)} →</a></div>`
|
|
40
|
+
// docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md: a visual's page opens full page.
|
|
41
|
+
: m.kind === 'visual' ? `<div class="msg link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
42
|
+
// A request line's mark is its words' icon, or m.mark's for a line whose words vary; a Full person's names them.
|
|
43
|
+
: m.request && m.card ? `<div class="msg request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
44
|
+
: m.request ? `<div class="msg request">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
|
|
45
|
+
// docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md: a card's move between lanes.
|
|
46
|
+
: m.kind === 'move' ? `<div class="msg request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
47
|
+
: m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
48
|
+
: m.folded ? foldedRun(m, links, edits)
|
|
42
49
|
: m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
43
|
-
: m.kind === 'calls' ? `<details class="msg calls"><summary>${statusMark('judgment calls')}${
|
|
44
|
-
: m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${
|
|
50
|
+
: m.kind === 'calls' ? `<details class="msg calls"><summary>${statusMark('judgment calls')}${escText(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
|
|
51
|
+
: m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
|
|
45
52
|
${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
|
|
46
|
-
<div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
|
|
53
|
+
<div class="who">${m.who === 'love' ? (m.by ? esc(m.by) : 'You') : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
|
|
47
54
|
<div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
|
|
48
55
|
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log</a></div>` : ''}
|
|
49
56
|
${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
|
|
50
|
-
${m.eventId
|
|
57
|
+
${m.eventId ? msgFooter(m) : ''}
|
|
51
58
|
</div>`);
|
|
52
59
|
|
|
60
|
+
// One agent run (domain.mjs threadOf gives its started line, status lines and finished line the same `run`) is one
|
|
61
|
+
// closed details line at its started line's place: the agent, running with its newest status text or finished, and
|
|
62
|
+
// no line count, and ending with a log link beside the summary when the run has a log; expanding it shows the run's lines.
|
|
63
|
+
function foldedRun(m, links, edits) {
|
|
64
|
+
const statuses = m.folded.filter((f) => f.kind === 'status');
|
|
65
|
+
const running = !m.folded.some((f) => f.finished);
|
|
66
|
+
const newest = statuses.at(-1);
|
|
67
|
+
const tail = running ? (newest ? `running · ${escText(newest.text)}` : 'running') : 'finished';
|
|
68
|
+
const log = m.folded.find((f) => f.log)?.log;
|
|
69
|
+
const logLink = log ? ` <a class="fold-log" href="log.html?file=${encodeURIComponent(log)}">log</a>` : '';
|
|
70
|
+
return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${running ? '' : ' class="finished"'}>${statusMark('status lines')}${escText(m.agent)} · ${tail}${logLink}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}</details>`;
|
|
71
|
+
}
|
|
72
|
+
function foldStatus(lines) {
|
|
73
|
+
const runs = new Map();
|
|
74
|
+
for (const m of lines) if (m.run && (m.kind === 'status' || m.kind === 'agent')) runs.set(m.run, [...(runs.get(m.run) || []), m]);
|
|
75
|
+
return lines.flatMap((m) => {
|
|
76
|
+
const run = m.run && (m.kind === 'status' || m.kind === 'agent') ? runs.get(m.run) : null;
|
|
77
|
+
if (!run) return [m];
|
|
78
|
+
return m === run[0] ? [{ ...m, folded: run }] : [];
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
53
82
|
const STEP = 3;
|
|
54
83
|
const UNREAD_CAP = 10;
|
|
55
|
-
|
|
84
|
+
// Every kind of system line; each leads with a mark, and a test renders each, so a new kind needs an entry in ui.js MARK_ICONS.
|
|
85
|
+
const SYSTEM_KINDS = ['status', 'agent', 'calls', 'ask-link', 'questions', 'visual', 'command', 'move'];
|
|
86
|
+
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions', 'move']);
|
|
56
87
|
const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
|
|
57
88
|
// Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount); the
|
|
58
89
|
// orchestrator's answer to a builder's ask never is.
|
|
59
|
-
const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
|
|
60
|
-
const batchedAt = (lines, i) => {
|
|
61
|
-
const next = lines.slice(i + 1).find(isMessage);
|
|
62
|
-
return lines[i].kind === 'owner-answer' && next?.kind === 'owner-answer' && stateOf(next) === stateOf(lines[i]);
|
|
63
|
-
};
|
|
90
|
+
const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.kind !== 'command' && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
|
|
64
91
|
// The window opens from the oldest unread message, at most UNREAD_CAP messages back; a message being edited always shows.
|
|
65
92
|
function threadWindow(lines, { seenAt = null, more = 0, upto = null }, edits) {
|
|
66
93
|
const counted = lines.flatMap((m, i) => (isMessage(m) && (!upto || String(m.ts) <= upto) ? [i] : []));
|
|
@@ -79,21 +106,23 @@
|
|
|
79
106
|
// draws the open card's window, so lines after `upto` add below without hiding older ones; null draws every line.
|
|
80
107
|
function threadMessages(msgs, links = {}, edits = new Map(), view = null) {
|
|
81
108
|
if (!msgs.length) return '<div class="muted small">No messages here yet.</div>';
|
|
82
|
-
const lines = withReplies(msgs);
|
|
109
|
+
const lines = foldStatus(withReplies(msgs));
|
|
83
110
|
const { start, hidden } = view ? threadWindow(lines, view, edits) : { start: 0, hidden: 0 };
|
|
84
111
|
const shown = lines.slice(start);
|
|
85
112
|
if (start && shown[0].kind === 'reply' && !shown[0].quote) shown[0] = { ...shown[0], quote: lines[start - 1].text || '' };
|
|
86
|
-
return (hidden ? moreButton(hidden) : '') + shown.map((m
|
|
113
|
+
return (hidden ? moreButton(hidden) : '') + shown.map((m) => line(m, links, edits)).join('');
|
|
87
114
|
}
|
|
88
115
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
116
|
+
// docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md
|
|
117
|
+
// An In Review card's Done, the second of its thread box's pair after Reply, on ⌥↵. It confirms and sends ask-done or
|
|
118
|
+
// card-done (app-cards.js); the board applies it before the session follows up
|
|
119
|
+
// (docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md).
|
|
91
120
|
// Orange while it clears an ask's Action needed, primary while a reopened card waits only on it
|
|
92
121
|
// (docs/adr/2026-11-03-0137-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
|
|
122
|
+
const doneTone = (it) => (it.resolvedBy === BUTTON.done ? 'resolves' : it.awaitsDone ? 'closes' : '');
|
|
93
123
|
function doneButton(it) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return `<button type="button"${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}">Done</button>`;
|
|
124
|
+
const tone = doneTone(it);
|
|
125
|
+
return sendButton('Done', 1, `${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}"`);
|
|
97
126
|
}
|
|
98
127
|
|
|
99
128
|
// docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
|
|
@@ -105,5 +134,5 @@
|
|
|
105
134
|
return [{ ts: fresh.at(-1), who: 'claude', kind: 'questions', text: `${fresh.length} new question${fresh.length === 1 ? '' : 's'}`, link: `questions.html#${encodeURIComponent(questions.first)}` }];
|
|
106
135
|
}
|
|
107
136
|
|
|
108
|
-
Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton, newQuestionsLine });
|
|
137
|
+
Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton, doneTone, newQuestionsLine, SYSTEM_KINDS });
|
|
109
138
|
})();
|
package/board/public/ui.js
CHANGED
|
@@ -53,15 +53,17 @@
|
|
|
53
53
|
const CLIP = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
|
|
54
54
|
const attachButton = () => `<label class="attach" title="Attach files"><input type="file" multiple data-attach aria-label="Attach files">${CLIP}</label>`;
|
|
55
55
|
// A composer's text box with its attach button inside at the bottom right, which attach.js outlines with Drop to
|
|
56
|
-
// attach while files are dragged over the composer.
|
|
57
|
-
|
|
56
|
+
// attach while files are dragged over the composer. attach: false leaves the button out, for a composer that puts it
|
|
57
|
+
// on its row (app-threads.js).
|
|
58
|
+
const dropZone = (textarea, { attach = true } = {}) => `<div class="dz${attach ? '' : ' bare'}">${textarea}${attach ? attachButton() : ''}<div class="dz-hint" aria-hidden="true">Drop to attach</div></div>`;
|
|
58
59
|
// docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md
|
|
59
60
|
// A composer's send buttons: the one with nth 0 presses on ⌘↵ and the one with nth 1, where there is one, on ⌥↵,
|
|
60
61
|
// wherever each sits in the row (Ctrl+↵ and
|
|
61
62
|
// Alt+↵ off a Mac), each with its key as a small gray hint that style.css shows only where there is a keyboard and room.
|
|
63
|
+
// tone is its class: primary for the nth 0 one unless given, as resolves on the Send or Reply that clears Action needed.
|
|
62
64
|
const MAC = /Mac|iPhone|iPad/.test(globalThis.navigator?.platform || globalThis.navigator?.userAgent || '');
|
|
63
65
|
const SEND_KEYS = MAC ? ['⌘↵', '⌥↵'] : ['Ctrl+↵', 'Alt+↵'];
|
|
64
|
-
const sendButton = (label, nth, attrs = '') => `<button type="submit"${
|
|
66
|
+
const sendButton = (label, nth, attrs = '', tone = nth === 0 ? 'primary' : '') => `<button type="submit"${tone ? ` class="${tone}"` : ''} data-nth="${nth}"${attrs}>${esc(label)}<span class="key-hint" aria-hidden="true">${SEND_KEYS[nth]}</span></button>`;
|
|
65
67
|
// The send button an Enter keydown in form's box presses, or null.
|
|
66
68
|
const keyedSend = (ev, form) => {
|
|
67
69
|
if (ev.key !== 'Enter' || ev.isComposing) return null;
|
|
@@ -71,27 +73,46 @@
|
|
|
71
73
|
const icon = (body, size = 18, attrs = '') => `<svg${attrs} width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
72
74
|
// An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
|
|
73
75
|
const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
|
|
76
|
+
// Escaped text with each ↗ (and a trailing U+FE0E) drawn as LINK_ARROW; for owner-facing text, never an attribute.
|
|
77
|
+
const escText = (s) => esc(s).replace(/\u2197\uFE0E?/g, LINK_ARROW);
|
|
74
78
|
const EYE = icon('<path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/>');
|
|
75
79
|
const TRASH = icon('<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>');
|
|
76
80
|
const FIRE = icon('<path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9z"/>');
|
|
77
|
-
const
|
|
81
|
+
const IMAGE_PATHS = '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>';
|
|
78
82
|
// Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md): a
|
|
79
83
|
// question's eye sends at once; a thread's eye is a toggle that Send carries (app-threads.js).
|
|
80
84
|
const protoButton = (id, { label = 'Ask for prototypes' } = {}) => `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}">${EYE}</button>`;
|
|
81
85
|
// The thread's fire, the eye's twin for a grill.
|
|
82
86
|
const grillButton = (id) => `<button type="button" class="attach proto-ask" data-grill="${esc(id)}" aria-label="Ask for a grill" title="Ask for a grill">${FIRE}</button>`;
|
|
83
|
-
//
|
|
84
|
-
|
|
87
|
+
// The thread's image, asking for a visual: a page an agent draws to answer in the thread (app-threads.js;
|
|
88
|
+
// docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md).
|
|
89
|
+
const visualButton = (id) => `<button type="button" class="attach proto-ask" data-visual="${esc(id)}" aria-label="Ask for a visual" title="Ask for a visual">${icon(IMAGE_PATHS)}</button>`;
|
|
85
90
|
// docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
|
|
86
91
|
// A thread status line's leading mark, its icon or dot centred in one fixed box so every line's text starts at the
|
|
87
|
-
// same place:
|
|
92
|
+
// same place: a command line's icon by its words (domain.mjs COMMANDS: the request's eye, fire or image, Implement's
|
|
93
|
+
// code brackets, Hold's pause, Resume's play, Done's circled check), Reply's arrow, answers' check, a reopened
|
|
94
|
+
// question's turn-back arrow, new questions' plain question mark, a builder's dot.
|
|
88
95
|
const mini = (body, cls = '') => `<svg class="mark-icon${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
96
|
+
const LINK = mini('<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/>');
|
|
89
97
|
const MARK_ICONS = {
|
|
90
|
-
'
|
|
91
|
-
'
|
|
92
|
-
'
|
|
98
|
+
'Prototype requested': mini('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
|
|
99
|
+
'Grill requested': mini('<path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9z"/>'),
|
|
100
|
+
'Visual requested': mini(IMAGE_PATHS),
|
|
101
|
+
'Re-ask requested': mini('<path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/><path d="M3 21v-5h5"/>'),
|
|
102
|
+
visual: mini(IMAGE_PATHS),
|
|
103
|
+
'ask-link': mini('<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>'),
|
|
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
|
+
'Implementation requested': mini('<path d="m8 6-6 6 6 6"/><path d="m16 6 6 6-6 6"/>'),
|
|
106
|
+
Held: mini('<path d="M9 5v14"/><path d="M15 5v14"/>'),
|
|
107
|
+
Resumed: mini('<path d="M7 4v16l13-8z"/>'),
|
|
108
|
+
'Related to': LINK, 'Unrelated from': LINK, 'Follow-up': LINK, 'Follow-up of': LINK,
|
|
109
|
+
'Marked done': mini('<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>'),
|
|
110
|
+
Reply: mini('<path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 6 6v5"/>'),
|
|
111
|
+
Implement: mini('<path d="m8 6-6 6 6 6"/><path d="m16 6 6 6-6 6"/>'),
|
|
93
112
|
'answers submitted': mini('<path d="M20 6 9 17l-5-5"/>'),
|
|
113
|
+
'question reopened': mini('<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>'),
|
|
94
114
|
'judgment calls': mini('<path d="m9 6 6 6-6 6"/>'),
|
|
115
|
+
'status lines': mini('<path d="m9 6 6 6-6 6"/>'),
|
|
95
116
|
questions: mini('<path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 .9-1 1.7M12 17h.01"/>', ' big'),
|
|
96
117
|
};
|
|
97
118
|
const mark = (inner = '') => `<span class="mark" aria-hidden="true">${inner}</span>`;
|
|
@@ -110,6 +131,12 @@
|
|
|
110
131
|
? `<button class="ghost icon bumped" data-card-bump="${esc(key)}" data-bumped aria-pressed="true" aria-label="Unbump" title="Bumped. Tap to unbump">${BUMP}</button>`
|
|
111
132
|
: `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
|
|
112
133
|
const workingLine = '<span class="thinking"><span class="working-dot" aria-hidden="true"></span>thinking</span>';
|
|
134
|
+
// Every paused status, whatever its place, is this one class (style.css .paused); BoardUI.usagePause is set by the
|
|
135
|
+
// board view (app.js) to read the hub's usage pause.
|
|
136
|
+
const PAUSED = '<span class="paused">paused</span>';
|
|
137
|
+
const isPaused = () => BoardUI.usagePause?.()?.state === 'paused';
|
|
138
|
+
// The orchestrator's "thinking" while its usage allows, else paused.
|
|
139
|
+
const thinkingLine = () => (isPaused() ? PAUSED : workingLine);
|
|
113
140
|
// html is markup the caller has escaped; href, title and attrs values are escaped here.
|
|
114
141
|
const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready', 'merged']);
|
|
115
142
|
function badge(tone, html, { href, title, button = false, attrs = {} } = {}) {
|
|
@@ -131,11 +158,11 @@
|
|
|
131
158
|
// under el it was, so a card that moves lanes keeps its open thread. A textarea keeps what was typed in it, and an
|
|
132
159
|
// element matching opts.keep stays as it is, for markup another part paints. The focused field keeps its focus when
|
|
133
160
|
// its card moves (focusSafe). setHTML skips an unchanged rebuild, and reopens each <details> the reader left open,
|
|
134
|
-
// matched by its owning [data-key] and summary text. Open state is tracked page-wide, so content filled in after a
|
|
161
|
+
// matched by its owning [data-key] and summary text (a card itself by its key alone, its summary text changing as it ages). Open state is tracked page-wide, so content filled in after a
|
|
135
162
|
// parent rebuild reopens too.
|
|
136
163
|
const lastHtml = new WeakMap();
|
|
137
164
|
const openDetails = new Set();
|
|
138
|
-
const detailsKey = (d) => `${(d.closest('[data-key]') || {}).dataset?.key || ''}|${(d.querySelector(':scope > summary')?.textContent || '').trim()}`;
|
|
165
|
+
const detailsKey = (d) => d.matches('[data-key]') ? d.dataset.key : `${(d.closest('[data-key]') || {}).dataset?.key || ''}|${(d.querySelector(':scope > summary')?.textContent || '').trim()}`;
|
|
139
166
|
globalThis.document?.addEventListener('toggle', (ev) => {
|
|
140
167
|
if (ev.target.tagName !== 'DETAILS') return;
|
|
141
168
|
if (ev.target.open) openDetails.add(detailsKey(ev.target)); else openDetails.delete(detailsKey(ev.target));
|
|
@@ -342,11 +369,57 @@
|
|
|
342
369
|
},
|
|
343
370
|
};
|
|
344
371
|
|
|
372
|
+
// Why a board request failed, from what the hub marks on its answer (hub/hub.mjs sendDown, hub/front-door.mjs refuse)
|
|
373
|
+
// (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md). answer is the response, or null when
|
|
374
|
+
// nothing answered or the request timed out; online is navigator.onLine. → {state, line, hint, retries}: the landing's
|
|
375
|
+
// two lines, and whether the board can come back by itself, so the page keeps retrying.
|
|
376
|
+
const BACK = 'This page comes back by itself.';
|
|
377
|
+
const DOWN = {
|
|
378
|
+
restarting: { line: 'Board restarting' },
|
|
379
|
+
stopped: { line: 'Board stopped', hint: 'Nothing answers on its port. Its Claude session has to start it.' },
|
|
380
|
+
removed: { line: 'This board is gone', hint: 'The hub no longer lists it.', retries: false },
|
|
381
|
+
access: { line: 'You no longer have access', hint: 'Ask its owner to share it again.', retries: false },
|
|
382
|
+
erroring: { line: 'Board restarting', hint: 'It answers with errors. Retrying for 30 seconds.' },
|
|
383
|
+
unreachable: { line: 'Board offline', hint: 'The hub, your connection or the host is down. Retrying.' },
|
|
384
|
+
};
|
|
385
|
+
const RESTART_WHY = { code: 'Its code changed.', starting: 'It’s starting.', crashed: 'It stopped, and the hub is starting it again.' };
|
|
386
|
+
function downOf(answer, online = navigator.onLine !== false) {
|
|
387
|
+
const why = online && answer ? answer.headers.get('x-kickflip-down') : null;
|
|
388
|
+
const state = !online || !answer ? 'unreachable'
|
|
389
|
+
: RESTART_WHY[why] ? 'restarting'
|
|
390
|
+
: DOWN[why] ? why
|
|
391
|
+
: answer.headers.get('x-app-shell') ? 'access'
|
|
392
|
+
: why || answer.headers.get('x-kickflip-hub') ? 'erroring' : 'unreachable';
|
|
393
|
+
return { state, retries: true, ...DOWN[state], ...(state === 'restarting' ? { hint: `${RESTART_WHY[why]} ${BACK}` } : {}) };
|
|
394
|
+
}
|
|
395
|
+
// A view's place when it has no copy to show: the kickflip mark and down's two lines, as the no-access page has them.
|
|
396
|
+
const downLanding = (down) => `<div class="down-landing" data-down="${down.state}"><img class="down-landing-mark" src="/_hub/kickflip.png" width="70" height="60" alt="">
|
|
397
|
+
<p class="down-landing-line">${esc(down.line)}</p><p class="down-landing-hint">${esc(down.hint)}</p></div>`;
|
|
398
|
+
// The line over a side page painted from its copy while the board can't be reached.
|
|
399
|
+
const copyNote = (what, savedAt) => `<p class="note copy-note">Copy of ${esc(what)} as of ${since(savedAt)}. Read only.</p>`;
|
|
400
|
+
// The buttons that send, which a board out of reach holds (style.css .offline): every one greyed and stopped, keeping its
|
|
401
|
+
// 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-band-move], button[data-grill], button[data-proto], button[data-visual], button[data-plan], button[data-main-pull], button[data-arch-run], button[data-try], button[data-reopen]`;
|
|
403
|
+
// 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
|
+
// tells the tap it is waiting. on is the view's B.on.
|
|
405
|
+
function holdSends(on, root, held, onHeld) {
|
|
406
|
+
const stop = (ev) => {
|
|
407
|
+
if (!held() || (ev.type === 'click' && !ev.target.closest?.(SENDS))) return;
|
|
408
|
+
ev.preventDefault();
|
|
409
|
+
ev.stopImmediatePropagation();
|
|
410
|
+
onHeld();
|
|
411
|
+
};
|
|
412
|
+
on(root, 'click', stop, true);
|
|
413
|
+
on(root, 'submit', stop, true);
|
|
414
|
+
}
|
|
415
|
+
|
|
345
416
|
// While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
|
|
346
|
-
// on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
|
|
347
|
-
// then throws once about limit ms have passed. Any other answer resolves
|
|
417
|
+
// on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs, down) and retries with a growing backoff,
|
|
418
|
+
// then throws once about limit ms have passed, the error carrying down (downOf). Any other answer resolves
|
|
419
|
+
// {ok, status, body}, a 404's {error} included.
|
|
348
420
|
// tag is the version of the board the page holds: an unchanged board answers 304, which resolves with body null
|
|
349
421
|
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
422
|
+
const WAITING = 'Waiting for the board to reconnect';
|
|
350
423
|
const RESTARTING = 'Board restarting…';
|
|
351
424
|
const restartNotice = () => `<span class="muted small restarting">${RESTARTING}</span>`;
|
|
352
425
|
async function fetchJSON(url, { onWait = () => {}, limit = 30000, tag = null, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
@@ -354,15 +427,18 @@
|
|
|
354
427
|
let delay = 500;
|
|
355
428
|
for (;;) {
|
|
356
429
|
let failure;
|
|
430
|
+
let answer = null;
|
|
357
431
|
try {
|
|
358
432
|
const r = await get(url, { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) });
|
|
359
433
|
if (r.status === 304) return { ok: true, status: 304, body: null };
|
|
360
434
|
const body = await r.text().then((t) => JSON.parse(t)).catch(() => undefined);
|
|
361
435
|
if (body !== undefined && r.status < 500) return { ok: r.ok, status: r.status, body };
|
|
436
|
+
answer = r;
|
|
362
437
|
failure = new Error(r.status >= 500 ? `the board answered ${r.status}` : 'the board did not answer with JSON');
|
|
363
438
|
} catch { failure = new Error('Offline. Is the host awake?'); }
|
|
439
|
+
failure.down = downOf(answer);
|
|
364
440
|
if (waited >= limit) throw failure;
|
|
365
|
-
onWait(waited);
|
|
441
|
+
onWait(waited, failure.down);
|
|
366
442
|
await sleep(delay);
|
|
367
443
|
waited += delay;
|
|
368
444
|
delay = Math.min(delay * 2, 4000);
|
|
@@ -370,8 +446,8 @@
|
|
|
370
446
|
}
|
|
371
447
|
|
|
372
448
|
globalThis.BoardUI = {
|
|
373
|
-
LINK_ARROW,
|
|
374
|
-
esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, RESTARTING, restartNotice,
|
|
375
|
-
attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton,
|
|
449
|
+
LINK_ARROW, escText,
|
|
450
|
+
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
|
+
attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, visualButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, thinkingLine, isPaused, PAUSED, BADGE_TONES, badge,
|
|
376
452
|
};
|
|
377
453
|
})();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// HubSettings: summary rows below Hub’s health and status open centred settings modals ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)), using
|
|
2
|
-
// settingsView's words from GET /_hub/api/settings. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
|
|
2
|
+
// settingsView's words from GET /_hub/api/settings. Pause hub, first, posts to /_hub/settings/hub-pause. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
|
|
3
3
|
// hours' times and hold to /_hub/settings/quiet, and Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key and Slack's token do through linear-key.js; Done waits for
|
|
4
4
|
// a key's save to land. The rules editor saves through POST /_hub/settings/rules
|
|
5
5
|
// and marks [data-dirty] while an edit is unsaved, so the shell asks before leaving then. The data refreshes every 30 seconds while nothing is being edited.
|
|
@@ -9,28 +9,10 @@
|
|
|
9
9
|
const { esc } = HubUI;
|
|
10
10
|
const ACTION = '/_hub/settings/notifications';
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
};
|
|
17
|
-
function customBounds(input, reset = false) {
|
|
18
|
-
const now = Date.now();
|
|
19
|
-
input.min = localInput(new Date(now + 60_000));
|
|
20
|
-
input.max = localInput(new Date(now + 30 * 86_400_000));
|
|
21
|
-
if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// The one duration picker Snooze and Keep awake share (hub/duration.mjs): chips that post `name` to `action` at once,
|
|
25
|
-
// then Custom, whose date and time posts as `name` too. While its setting is on, `on` replaces it with when it ends
|
|
26
|
-
// and a button that posts `name`=off.
|
|
27
|
-
function durationPicker({ id, title, icon, action, name, choices, on, button }) {
|
|
28
|
-
if (on) return `<div class="row dur-on">${icon}<span class="body"><span class="text title">${esc(on.title)}</span><form method="post" action="${action}"><button class="btn quiet" name="${name}" value="off">${button}</button></form></span></div>`;
|
|
29
|
-
if (!choices) return '';
|
|
30
|
-
const chips = choices.map((c) => `<button class="btn quiet" name="${name}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
|
|
31
|
-
return `<div class="block" role="group" aria-labelledby="${id}Title"><div class="dur-head">${icon}<span class="title" id="${id}Title">${title}</span></div><div class="dur-chips"><form class="dur-choices" method="post" action="${action}">${chips}</form><form class="dur-custom" method="post" action="${action}"><input type="hidden" name="${name}"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-dur-at required aria-label="Custom ${title.toLowerCase()} until a date and time"></form></div></div>`;
|
|
32
|
-
}
|
|
33
|
-
|
|
12
|
+
const { durationPicker } = HubKit;
|
|
13
|
+
// Pause hub (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md), gray as every paused status.
|
|
14
|
+
const pauseRow = (p) => durationPicker({ id: 'pauseHub', title: 'Pause hub', icon: HubUI.tile('pause', 'var(--color-quiet)'),
|
|
15
|
+
action: '/_hub/settings/hub-pause', name: 'pause', choices: p.choices, on: p.on, button: 'Resume' });
|
|
34
16
|
const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
|
|
35
17
|
choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
|
|
36
18
|
// Grey while off and orange while on, as the machine working (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md).
|
|
@@ -58,7 +40,7 @@
|
|
|
58
40
|
+ `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
|
|
59
41
|
}
|
|
60
42
|
|
|
61
|
-
const sections = [['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['slack', 'Slack'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
43
|
+
const sections = [['pause', 'Pause hub'], ['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['slack', 'Slack'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
62
44
|
const sectionOf = (hash) => {
|
|
63
45
|
const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
|
|
64
46
|
return sections.some(([section]) => section === id) ? id : null;
|
|
@@ -80,13 +62,16 @@
|
|
|
80
62
|
let rules = null;
|
|
81
63
|
let rulesJSON = '';
|
|
82
64
|
let tokenSlot = null;
|
|
65
|
+
const pickers = HubKit.durationPickers(B);
|
|
83
66
|
|
|
84
67
|
function paint() {
|
|
85
68
|
if (!last) return;
|
|
86
69
|
const { row, tile, deviceRow } = HubUI;
|
|
87
70
|
top.innerHTML = HubUI.flash(flash);
|
|
88
71
|
const scrollTop = B.$('.settings-body').scrollTop;
|
|
89
|
-
if (active === '
|
|
72
|
+
if (active === 'pause') {
|
|
73
|
+
content.innerHTML = `<div class="group">${pauseRow(last.pause)}</div><p class="note">Running steps finish. No new builders, planners or pulls start on any board until then, as when usage reaches the pause line.</p>`;
|
|
74
|
+
} else if (active === 'notifications') {
|
|
90
75
|
content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}</div>`;
|
|
91
76
|
} else if (active === 'quiet') {
|
|
92
77
|
content.innerHTML = quietRows(last.quiet);
|
|
@@ -103,11 +88,11 @@
|
|
|
103
88
|
} else if (active === 'closed') {
|
|
104
89
|
content.innerHTML = `<div class="group">${(last.closed || []).length ? last.closed.map((b) => `<div class="row"><span class="body"><span class="text"><span class="title">${esc(b.title)}</span><span class="sub wrap">/${esc(b.route)}</span></span><button class="btn" type="button" data-reopen="${esc(b.route)}"${reopening === b.route ? BoardUI.BUSY : reopening ? ' disabled' : ''}>Reopen</button></span></div>`).join('') : '<p class="note">No closed boards.</p>'}</div>`;
|
|
105
90
|
}
|
|
106
|
-
|
|
107
|
-
if (input) customBounds(input, true);
|
|
91
|
+
pickers.painted();
|
|
108
92
|
B.$('.settings-body').scrollTop = scrollTop;
|
|
109
93
|
B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
|
|
110
94
|
const summaries = {
|
|
95
|
+
pause: last.pause.summary,
|
|
111
96
|
notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
|
|
112
97
|
quiet: last.quiet.summary,
|
|
113
98
|
'keep-awake': last.awake.summary,
|
|
@@ -207,37 +192,6 @@
|
|
|
207
192
|
B.atUnmount(() => closeModal(false));
|
|
208
193
|
const device = HubKit.device(B, { paint, changed: () => ctx.sidebarChanged?.() });
|
|
209
194
|
device.ready.then(() => { if (B.alive()) paint(); });
|
|
210
|
-
let openPicker = null;
|
|
211
|
-
B.on(root, 'click', (e) => {
|
|
212
|
-
const input = e.target.closest?.('[data-dur-at]');
|
|
213
|
-
if (!input) return;
|
|
214
|
-
openPicker = input;
|
|
215
|
-
customBounds(input, input.value === input.defaultValue);
|
|
216
|
-
try { input.showPicker(); } catch { /* iOS opens its wheel on the tap itself. */ }
|
|
217
|
-
});
|
|
218
|
-
// A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
|
|
219
|
-
B.on(root, 'pointerdown', (e) => {
|
|
220
|
-
const input = e.target.closest?.('.dur-choices button') && B.$('[data-dur-at]');
|
|
221
|
-
if (!input) return;
|
|
222
|
-
openPicker = null;
|
|
223
|
-
input.value = input.defaultValue;
|
|
224
|
-
});
|
|
225
|
-
// Closing an opened picker accepts its suggested time; an unchanged field reached by Tab does nothing.
|
|
226
|
-
const saveCustom = (input) => {
|
|
227
|
-
if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
|
|
228
|
-
const at = new Date(input.value);
|
|
229
|
-
if (Number.isNaN(at.getTime())) return;
|
|
230
|
-
input.form.querySelector('input[type="hidden"]').value = at.toISOString();
|
|
231
|
-
input.form.requestSubmit();
|
|
232
|
-
openPicker = null;
|
|
233
|
-
input.defaultValue = input.value;
|
|
234
|
-
};
|
|
235
|
-
B.on(root, 'change', (e) => {
|
|
236
|
-
if (e.target.matches?.('[data-dur-at]') && document.activeElement !== e.target) saveCustom(e.target);
|
|
237
|
-
});
|
|
238
|
-
B.on(root, 'focusout', (e) => {
|
|
239
|
-
if (e.target.matches?.('[data-dur-at]')) saveCustom(e.target);
|
|
240
|
-
});
|
|
241
195
|
B.on(root, 'click', async (e) => {
|
|
242
196
|
const button = e.target.closest?.('[data-reopen]');
|
|
243
197
|
if (!button || reopening) return;
|
package/board/public/view-hub.js
CHANGED
|
@@ -71,6 +71,7 @@
|
|
|
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
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> ${esc(d.awake)}</span></span>` : ''}`, wrap: true,
|
|
73
73
|
trail: d.hub.restart ? restartHubButton(restartingHub) : '' })}
|
|
74
|
+
${HubUI.machineRow(d.pause.machine)}
|
|
74
75
|
${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip <route></code>.' }) : ''}</div>
|
|
75
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>
|
|
76
77
|
<h2 class="label">Polling status</h2>
|
|
@@ -88,7 +89,7 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
88
89
|
(globalThis.AppViews ||= {}).hub = {
|
|
89
90
|
className: 'hub-view view-hub',
|
|
90
91
|
title: () => 'Hub',
|
|
91
|
-
markup: () => `${BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png'
|
|
92
|
+
markup: () => `${BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png' })}
|
|
92
93
|
<main class="has-top"><div class="hub-status"></div><section class="hub-settings" aria-labelledby="hubSettingsLabel"><h2 class="label" id="hubSettingsLabel">Hub settings</h2>${HubSettings.markup()}</section><div class="hub-foot"></div></main>`,
|
|
93
94
|
|
|
94
95
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
@@ -183,7 +184,9 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
183
184
|
answer(out, action) {
|
|
184
185
|
if (['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
|
|
185
186
|
last = out; flash = null; paint();
|
|
186
|
-
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)}.`
|
|
187
|
+
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
|
+
: out.pause.near?.going ? `Keep going: agents start in parallel until ${BoardUI.usageClock(out.pause.near.resets_at)}.`
|
|
189
|
+
: out.pause.near ? 'Stopped: agents start one at a time again.' : 'Stopped: new work waits again.');
|
|
187
190
|
} else if (action === '/_hub/push/flush') { flash = { text: out.message, error: !out.ok }; load(); } else { flash = null; load(); settings.B.refreshSettings?.(); }
|
|
188
191
|
ctx.sidebarChanged?.();
|
|
189
192
|
},
|
|
@@ -11,22 +11,42 @@
|
|
|
11
11
|
|
|
12
12
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, async (B) => {
|
|
13
13
|
const { $, site } = B;
|
|
14
|
-
const { esc, markdown, linkContext, fetchJSON,
|
|
14
|
+
const { esc, markdown, linkContext, fetchJSON, downLanding, copyNote, pageCopy } = BoardUI;
|
|
15
15
|
const key = ctx.view.item || '';
|
|
16
16
|
const wave = ctx.view.wave;
|
|
17
17
|
const el = $('#plan');
|
|
18
18
|
const get = (url, opts = {}) => fetchJSON(url, { ...opts, sleep: B.sleep });
|
|
19
|
+
const paint = (body, items, note = '') => {
|
|
20
|
+
$('#ref').textContent = `${wave ? body.title : body.ref} notes`;
|
|
21
|
+
document.title = $('#ref').textContent;
|
|
22
|
+
el.innerHTML = note + markdown(body.markdown, linkContext(items, key, { base: './' }));
|
|
23
|
+
};
|
|
24
|
+
// While the board can't be reached, the notes' last answer here shows under its copy line, or with none the landing
|
|
25
|
+
// for why (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
|
|
26
|
+
const url = site.url(`api/notes?${wave ? `wave=${encodeURIComponent(wave)}` : `item=${encodeURIComponent(key)}`}`);
|
|
27
|
+
let copy = null;
|
|
28
|
+
let live = false;
|
|
29
|
+
let copied = false;
|
|
30
|
+
const waiting = (down) => {
|
|
31
|
+
copy ||= pageCopy.read(url);
|
|
32
|
+
copy.then((c) => {
|
|
33
|
+
if (!B.alive() || live || copied) return;
|
|
34
|
+
if (!c) { el.innerHTML = downLanding(down); return; }
|
|
35
|
+
copied = true;
|
|
36
|
+
paint(c.body, [], copyNote('the notes', c.savedAt));
|
|
37
|
+
});
|
|
38
|
+
};
|
|
19
39
|
try {
|
|
20
40
|
const board = get(site.url('api/board')).then((r) => r.body).catch(() => ({}));
|
|
21
|
-
const
|
|
22
|
-
const r = await get(site.url(`api/notes?${query}`), { onWait: () => { el.innerHTML = restartNotice(); } });
|
|
41
|
+
const r = await get(url, { onWait: (ms, down) => waiting(down) });
|
|
23
42
|
if (!B.alive()) return;
|
|
24
43
|
if (!r.ok) throw new Error(r.body.error || `notes ${r.status}`);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
live = true;
|
|
45
|
+
paint(r.body, (await board).items || []);
|
|
46
|
+
pageCopy.save(url, r.body);
|
|
28
47
|
} catch (e) {
|
|
29
|
-
|
|
48
|
+
if (e.down) waiting(e.down);
|
|
49
|
+
else el.innerHTML = `<p class="muted">Could not load these notes: ${esc(e.message)}</p>`;
|
|
30
50
|
}
|
|
31
51
|
}),
|
|
32
52
|
};
|