@lovek4d/kickflip 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +239 -104
- package/README.md +54 -4
- package/SKILL.md +63 -42
- package/bin/doctor.mjs +4 -3
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +103 -40
- package/bin/statusline.mjs +4 -3
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +5 -0
- package/board/auto-review.mjs +2 -0
- package/board/bin/agent.mjs +1 -1
- package/board/bin/board.mjs +371 -82
- package/board/bin/builder-stop-hook.mjs +4 -3
- package/board/domain.mjs +8 -3
- package/board/finish.mjs +3 -0
- package/board/headless.mjs +3 -0
- package/board/on-host.mjs +1 -0
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +66 -28
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +354 -77
- package/board/public/app-core.js +40 -8
- package/board/public/app-header.js +33 -8
- package/board/public/app-lanes.js +238 -34
- package/board/public/app-needs.js +2 -0
- package/board/public/app-snapshot.js +45 -11
- package/board/public/app-threads.js +20 -21
- package/board/public/app.html +6 -2
- package/board/public/app.js +114 -33
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +161 -118
- package/board/public/hub-kit.js +27 -7
- package/board/public/hub.css +26 -33
- package/board/public/linear-key.js +143 -0
- package/board/public/log.html +48 -0
- package/board/public/prompts.js +9 -8
- package/board/public/prototype-kit.js +6 -6
- package/board/public/prototype-switcher.js +13 -5
- package/board/public/prototype.html +1 -0
- package/board/public/router.js +14 -10
- package/board/public/rules-editor.js +4 -5
- package/board/public/shell.js +21 -7
- package/board/public/sidebar.css +51 -11
- package/board/public/sidebar.js +124 -19
- package/board/public/style.css +244 -56
- package/board/public/toasts.js +5 -3
- package/board/public/ui-cards.js +236 -66
- package/board/public/ui-enums.js +5 -4
- package/board/public/ui-header.js +23 -14
- package/board/public/ui-hub.js +34 -16
- package/board/public/ui-links.js +18 -10
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +21 -0
- package/board/public/ui-page-header.js +13 -8
- package/board/public/ui-patch.js +53 -0
- package/board/public/ui-phase-bars.js +17 -0
- package/board/public/ui-prompts.js +8 -8
- package/board/public/ui-questions.js +35 -11
- package/board/public/ui-sidebar.js +83 -21
- package/board/public/ui-snapshot.js +30 -13
- package/board/public/ui-threads.js +38 -23
- package/board/public/ui.js +96 -16
- package/board/public/view-frame.js +1 -1
- package/board/public/view-hub-settings.js +67 -44
- package/board/public/view-hub.js +105 -13
- package/board/public/view-notes.js +8 -5
- package/board/public/view-questions.js +123 -52
- package/board/public/view-settings.js +235 -62
- package/board/review.mjs +1 -0
- package/board/server.mjs +18 -7
- package/board/store.mjs +15 -6
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +20 -13
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/duration.mjs +1 -0
- package/hub/github.mjs +3 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -10
- package/hub/incoming.mjs +2 -0
- package/hub/linear.mjs +2 -2
- package/hub/live.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -0
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -2
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/shell.mjs +2 -1
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +12 -12
- package/hub/sw.js +2 -2
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +8 -3
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +5 -1
- package/shared/card-prs.mjs +1 -0
- package/shared/colors.css +9 -6
- package/shared/fake-gh.mjs +105 -0
- package/shared/repos.mjs +1 -1
- package/shared/service.mjs +16 -16
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -1
- package/starter/public/claude.js +3 -2
- package/bin/web-ts.mjs +0 -2
- package/board/public/view-polling.js +0 -87
package/board/public/ui-cards.js
CHANGED
|
@@ -1,23 +1,33 @@
|
|
|
1
|
-
// Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js and ui-
|
|
1
|
+
// Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js, ui-enums.js and ui-phase-bars.js.
|
|
2
2
|
(function () {
|
|
3
|
-
const { esc, ago,
|
|
3
|
+
const { esc, ago, clock, cardTime, etaLine, workingLine, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, LINK_ARROW, phaseBars: bars } = globalThis.BoardUI;
|
|
4
4
|
const EXTERNAL = { target: '_blank', rel: 'noopener' };
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
// The PR badge is the one place a PR's state shows: its mark and tone, marked like the reviewer and CI chips, and its
|
|
6
|
+
// title (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). A card with several PRs shows a
|
|
7
|
+
// badge for each, in its order (docs/adr/2026-11-03-1904-a-card-holds-several-prs-across-repos.md).
|
|
8
|
+
const PR_LOOK = {
|
|
9
|
+
[PR_STATE.open]: { tone: 'quiet', mark: ' ✓', label: 'Open' },
|
|
10
|
+
[PR_STATE.draft]: { tone: 'notice', mark: ' ●', label: 'Draft' },
|
|
11
|
+
[PR_STATE.merged]: { tone: 'merged', mark: ' ✓', label: 'Merged' },
|
|
12
|
+
[PR_STATE.closed]: { tone: 'danger', mark: ' ✗', label: 'Closed' },
|
|
13
|
+
};
|
|
14
|
+
// An open PR reads green only while it waits on a reviewer alone: CI passed and no reviewer asked for changes.
|
|
15
|
+
const waitsOnReviewer = (pr) => pr.checks?.state === 'pass' && pr.reviewDecision !== 'CHANGES_REQUESTED';
|
|
16
|
+
const prChips = (it) => (it.prs || []).map(prChip).join('');
|
|
17
|
+
// A PR's short name, its repo's name and number: kickflip#6.
|
|
18
|
+
const prName = (pr) => `${(pr.url.match(/github\.com\/[^/]+\/([^/]+)\//) || [])[1] || ''}#${pr.number}`;
|
|
19
|
+
function prChip(pr) {
|
|
20
|
+
const base = PR_LOOK[pr.status] || { tone: 'quiet', mark: '', label: '' };
|
|
21
|
+
const look = pr.status === PR_STATE.open && waitsOnReviewer(pr) ? { ...base, tone: 'ready', label: 'Ready for review' } : base;
|
|
11
22
|
const CI = { pass: { tone: 'ready', mark: '✓' }, fail: { tone: 'danger', mark: '✗' }, pending: { tone: 'notice', mark: '●' } }[pr.checks?.state] || { tone: 'quiet', mark: '?' };
|
|
12
23
|
const ci = pr.checks && pr.state === 'OPEN' ? badge(CI.tone, `CI ${CI.mark}`, { title: `CI ${pr.checks.state}` }) : '';
|
|
13
|
-
const repo = (pr.url.match(/github\.com\/[^/]+\/([^/]+)\//) || [])[1] || '';
|
|
14
24
|
const codex = pr.state === 'OPEN' && pr.codexOpen ? badge('notice', `codex ${esc(pr.codexOpen)}`, { title: `${pr.codexOpen} Codex finding${pr.codexOpen === 1 ? '' : 's'} not answered yet` }) : '';
|
|
15
|
-
const name =
|
|
16
|
-
const label =
|
|
17
|
-
return `${badge(
|
|
25
|
+
const name = prName(pr);
|
|
26
|
+
const label = look.label ? { title: look.label, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${look.label}` } } : { attrs: EXTERNAL };
|
|
27
|
+
return `${badge(look.tone, `${esc(name)}${look.mark}`, { href: pr.url, ...label })}${ci}${codex}`;
|
|
18
28
|
}
|
|
19
|
-
const linearChips = (it) => (it.linear || []).map((l) => badge('
|
|
20
|
-
const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge(
|
|
29
|
+
const linearChips = (it) => (it.linear || []).map((l) => badge('quiet', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
|
|
30
|
+
const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge('quiet', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === 'internal' ? ' · this board’s own ticket' : it.ticket.source === 'github' ? ' on GitHub' : ' in Linear'}`, attrs: it.ticket.source === 'internal' ? {} : EXTERNAL });
|
|
21
31
|
const REVIEW = {
|
|
22
32
|
approved: { tone: 'ready', mark: '✓', label: 'approved' },
|
|
23
33
|
changes: { tone: 'danger', mark: '✗', label: 'asked for changes' },
|
|
@@ -33,36 +43,44 @@
|
|
|
33
43
|
return (it.reviewers || []).map((r) => {
|
|
34
44
|
const rv = globalThis.BoardUI.record(it.review, r);
|
|
35
45
|
const s = REVIEW[rv?.state || 'unsent'];
|
|
36
|
-
|
|
46
|
+
const age = reviewAge(rv).trim();
|
|
47
|
+
return badge(s.tone, `${esc(rv?.github || r)}${age ? ` (${age})` : ''} ${s.mark}`, { title: `${r}${rv?.github ? ` (${rv.github})` : ''}: ${s.label}` });
|
|
37
48
|
}).join('');
|
|
38
49
|
}
|
|
39
50
|
const pendingQuestions = (it) => (it.questions && it.questions.state !== 'resolved' ? it.questions.open + it.questions.answered + it.questions.replied : 0);
|
|
40
|
-
// docs/adr/
|
|
41
|
-
|
|
51
|
+
// docs/adr/2026-10-29-1639-the-lane-follows-who-acts-next.md
|
|
52
|
+
// Action needed, last of the time line's orange words, linking to what the owner acts on
|
|
53
|
+
// (docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
|
|
54
|
+
function actionWords(it) {
|
|
42
55
|
if (!it.action) return '';
|
|
43
|
-
const external = it.action.kind === ACTION_KIND.ready ? EXTERNAL
|
|
44
|
-
return
|
|
56
|
+
const external = it.action.kind === ACTION_KIND.ready ? Object.entries(EXTERNAL).map(([k, v]) => ` ${k}="${esc(v)}"`).join('') : '';
|
|
57
|
+
return `<a class="card-action" href="${esc(it.action.url)}" title="${esc(it.action.label)}" aria-label="${esc(`Action needed: ${it.action.label}`)}"${external}>Action needed</a>`;
|
|
58
|
+
}
|
|
59
|
+
// Each unfinished dep, linking to its card: "blocked by #53, #54".
|
|
60
|
+
function blockedWords(it) {
|
|
61
|
+
const deps = it.blockedBy || [];
|
|
62
|
+
return deps.map((d, i) => `<a class="card-blocked" href="#item=${encodeURIComponent(d.key)}">${i ? '' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
|
|
45
63
|
}
|
|
46
64
|
const tryWords = (kind) => kind === 'app' ? {
|
|
47
|
-
go: 'Build', going: '
|
|
48
|
-
none: 'No changes to build: this card’s branch matches main.',
|
|
65
|
+
go: 'Build', going: 'building…', done: 'Install', failed: 'build failed',
|
|
49
66
|
current: 'Nothing new to build: Install already has this card’s latest push.',
|
|
50
67
|
busy: 'Building now. The badge turns back into Install when the new build is ready.',
|
|
51
|
-
autoSub: '
|
|
68
|
+
autoSub: 'The hub builds each card after each push, and main after each merge.',
|
|
52
69
|
saved: (on) => on ? 'Saved. Each push builds its card.' : 'Saved. Cards wait for your Build.',
|
|
53
70
|
} : {
|
|
54
|
-
go: 'Serve', going: '
|
|
55
|
-
none: 'No changes to serve: this card’s branch matches main.',
|
|
71
|
+
go: 'Serve', going: 'serving…', done: 'Visit', failed: 'serve failed',
|
|
56
72
|
current: 'Nothing new to serve: Visit already shows this card’s latest push.',
|
|
57
73
|
busy: 'Serving now. The badge turns back into Visit when the new server is up.',
|
|
58
|
-
autoSub: '
|
|
74
|
+
autoSub: 'The hub serves each card after each push, and main after each merge.',
|
|
59
75
|
saved: (on) => on ? 'Saved. Each push serves its card.' : 'Saved. Cards wait for your Serve.',
|
|
60
76
|
};
|
|
61
77
|
const spinner = '<span class="try-spin" aria-hidden="true"></span>';
|
|
78
|
+
const tryURL = (run) => run.kind === 'web' ? run.port ? `https://${location.hostname}:${run.port}/` : null : run.url;
|
|
62
79
|
function tryOf(it) {
|
|
63
80
|
if (it.lane === 'done') return null;
|
|
64
81
|
const t = it.try || (it.install ? { kind: 'app', state: 'ready', stale: it.install.older, sha: it.install.sha, url: it.install.url } : null);
|
|
65
|
-
|
|
82
|
+
const run = t ? { ...t, url: tryURL(t) } : null;
|
|
83
|
+
return it.builder?.kind === BUILDER_KIND.install ? { ...run, kind: run?.kind || 'app', state: 'running' } : run;
|
|
66
84
|
}
|
|
67
85
|
function installState(it) {
|
|
68
86
|
const t = tryOf(it);
|
|
@@ -87,67 +105,219 @@
|
|
|
87
105
|
const tag = s.href ? 'a' : 'span';
|
|
88
106
|
return `<${tag} class="install tone-${s.tone}"${attrs}>${s.html}</${tag}>`;
|
|
89
107
|
}
|
|
108
|
+
// A card's Serve / Build, only while the board's Auto-build is off and its branch has changes
|
|
109
|
+
// (docs/adr/2026-11-02-1806-only-the-owner-or-auto-build-starts-a-card-run.md).
|
|
90
110
|
function tryButton(it) {
|
|
91
111
|
const t = tryOf(it);
|
|
92
|
-
if (it.kind !== 'work' || !t?.script) return '';
|
|
112
|
+
if (it.kind !== 'work' || !t?.script || t.autoBuild || !t.changes) return '';
|
|
93
113
|
const words = tryWords(t.kind);
|
|
94
|
-
const reason =
|
|
114
|
+
const reason = t.state === 'running' ? words.busy : t.state === 'ready' && !t.stale ? words.current : '';
|
|
95
115
|
return `<button type="button" data-try="${esc(it.key)}"${reason ? ` aria-disabled="true" title="${esc(reason)}" data-tip="${esc(reason)}"` : ''}>${t.state === 'failed' && !reason ? 'Retry' : words.go}</button>`;
|
|
96
116
|
}
|
|
97
117
|
const BRANCH = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
|
|
98
|
-
|
|
118
|
+
// Main's refresh icon on a local board (docs/adr/2026-11-03-1914-a-local-boards-main-pulls-from-origin-on-its-own.md),
|
|
119
|
+
// the header's refresh icon as a ghost button right of the timestamp: it spins while a pull runs or a tap waits on the
|
|
120
|
+
// server, and turns red when the last pull failed, with the reason as a tooltip on hover or a popover a tap opens
|
|
121
|
+
// (`open`). Visit is left alone: it changes only once Main rebuilds.
|
|
122
|
+
function mainPull(pull, { sending = false, open = false } = {}) {
|
|
123
|
+
if (!pull) return '';
|
|
124
|
+
const busy = pull.running || sending;
|
|
125
|
+
const failed = !busy && pull.error;
|
|
126
|
+
const label = busy ? 'Pulling main' : failed ? pull.error : 'Pull main now';
|
|
127
|
+
const why = failed ? `<span class="main-pull-why${open ? ' open' : ''}" role="tooltip">${esc(pull.error)}</span>` : '';
|
|
128
|
+
return `<span class="main-pull"><button class="ghost gh-refresh${busy ? ' spinning' : ''}${failed ? ' failed' : ''}" type="button" data-main-pull aria-label="${esc(label)}"${busy ? ' aria-disabled="true"' : ''}>${BoardUI.refreshIcon(14)}</button>${why}</span>`;
|
|
129
|
+
}
|
|
130
|
+
function mainBar(main, pulling) {
|
|
99
131
|
if (!main) return '';
|
|
100
132
|
const words = tryWords(main.kind);
|
|
133
|
+
const url = tryURL(main);
|
|
101
134
|
const chip = main.state === 'running' ? badge('notice', spinner + esc(words.going), { attrs: { role: 'status' } })
|
|
102
|
-
: main.state === 'failed' ? badge('danger', esc(words.failed), {
|
|
103
|
-
: main.state === 'ready' &&
|
|
104
|
-
return `<div class="tb-main" role="region" aria-label="Main’s version">${BRANCH}<div class="tb-main-text">${esc(main.slug)} · <strong>${esc(main.branch)}</strong>@<code>${esc(main.sha)}</code> · ${esc(ago(main.at))}</div><div class="meta">${chip}</div></div>`;
|
|
135
|
+
: main.state === 'failed' ? badge('danger', esc(words.failed), { button: true, attrs: { type: 'button', 'data-main-log': '', 'aria-haspopup': 'dialog' }, title: 'Read main’s build log and retry this tip.' })
|
|
136
|
+
: main.state === 'ready' && url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
|
|
137
|
+
return `<div class="tb-main" role="region" aria-label="Main’s version">${BRANCH}<div class="tb-main-text">${esc(main.slug)} · <strong>${esc(main.branch)}</strong>@<code>${esc(main.sha)}</code> · ${esc(ago(main.at))}${mainPull(main.pull, pulling)}</div><div class="meta">${chip}</div></div>`;
|
|
105
138
|
}
|
|
106
|
-
// The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge,
|
|
107
|
-
//
|
|
108
|
-
|
|
139
|
+
// The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, waiting while the
|
|
140
|
+
// server records it (app-cards.js), and waits (ui.js BUSY) while the session merges it
|
|
141
|
+
// (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md).
|
|
142
|
+
function mergeButton(it) {
|
|
109
143
|
if (it?.action?.kind !== ACTION_KIND.merge && !it?.merging) return '';
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
144
|
+
return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${it.merging ? BoardUI.BUSY : ''}>Merge</button>`;
|
|
145
|
+
}
|
|
146
|
+
// The open card's Implement, rightmost in its action row, on every unstarted Todo work card, its questions waiting or
|
|
147
|
+
// not: it confirms, then
|
|
148
|
+
// sends card-build, waiting while the board server starts the implementer (app-cards.js). Build and Serve
|
|
149
|
+
// mean the package only (docs/adr/2026-11-02-1745-a-todo-card-that-nothing-blocks-has-build.md,
|
|
150
|
+
// docs/adr/2026-11-03-0219-a-build-tap-starts-the-build-in-the-board-server.md, docs/adr/2026-11-03-1549-a-todo-cards-button-reads-implement.md).
|
|
151
|
+
// Implement is plain until the card is ready, then builder orange (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
|
|
152
|
+
function buildButton(it) {
|
|
153
|
+
if (it.kind !== 'work' || it.lane !== 'todo' || it.queued) return '';
|
|
154
|
+
return `<button class="build${it.ready ? ' ready' : ''}" data-confirm="card-build" data-key="${esc(it.key)}">Implement</button>`;
|
|
155
|
+
}
|
|
156
|
+
// A builder reads "preheating" until its first status line, from the Implement tap or the session's dispatch on.
|
|
157
|
+
const builderLine = (b) => [b.progress || 'preheating', b.at && ago(b.at)].filter(Boolean).join(' · ');
|
|
158
|
+
// Five bars at the end of an In Progress card's time row: reached phases filled, a running one marked. The phases are
|
|
159
|
+
// store.mjs PHASES (docs/adr/2026-11-03-0128-a-builder-status-line-is-a-phase-and-a-short-detail.md,
|
|
160
|
+
// docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
|
|
161
|
+
const BUILD_PHASES = ['reading', 'building', 'testing', 'checking', 'landing'];
|
|
162
|
+
const stepWords = (phase) => `step ${BUILD_PHASES.indexOf(phase) + 1} of ${BUILD_PHASES.length}`;
|
|
163
|
+
const ALL_DONE = `all ${BUILD_PHASES.length} steps done`;
|
|
164
|
+
// What a card's bars show, or null off In Progress and on a card only a planner works on: how many phases it reached,
|
|
165
|
+
// whether the last of them is running, whether it dims because the card waits on something other than a builder (its
|
|
166
|
+
// questions, a blocker or queue, a reopening) or its build is stale, and its label. An install is no build: its card keeps the phase its
|
|
167
|
+
// build reached, undimmed
|
|
168
|
+
// (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md).
|
|
169
|
+
function phaseStrip(it) {
|
|
170
|
+
if (it.lane !== 'progress' || it.builder?.planner) return null;
|
|
171
|
+
const all = BUILD_PHASES.length;
|
|
172
|
+
if (it.waitsOn === 'checking') return { reached: all, running: false, dim: false, label: `Orchestrator review, ${ALL_DONE}` };
|
|
173
|
+
const asking = it.action?.kind === ACTION_KIND.questions || it.action?.kind === ACTION_KIND.pick;
|
|
174
|
+
const answers = asking ? ', waiting on your answers' : '';
|
|
175
|
+
const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
|
|
176
|
+
if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
|
|
177
|
+
const reached = BUILD_PHASES.indexOf(it.builder.phase) + 1;
|
|
178
|
+
return { reached, running: reached > 0, dim: asking || Boolean(it.builder.stalled), label: `${reached ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Preheating, no step yet'}${answers}` };
|
|
179
|
+
}
|
|
180
|
+
const dim = !it.builder || asking;
|
|
181
|
+
if (it.reopened) return { reached: all, running: false, dim, label: `Reopened, ${ALL_DONE}${answers}` };
|
|
182
|
+
const reached = BUILD_PHASES.indexOf(it.reachedPhase) + 1;
|
|
183
|
+
const label = reached ? `Stopped at ${it.reachedPhase}, ${stepWords(it.reachedPhase)}` : it.queued ? 'Queued, no step yet' : 'No step yet';
|
|
184
|
+
return { reached, running: false, dim, label: `${label}${answers}` };
|
|
185
|
+
}
|
|
186
|
+
// What the bars do when a card's reached count goes from prev to next across redraws, null or undefined meaning no
|
|
187
|
+
// strip (docs/adr/2026-11-03-1850-the-phase-bars-animate-as-a-build-starts-advances-and-ends.md).
|
|
188
|
+
function phaseMotion(prev, next) {
|
|
189
|
+
const had = typeof prev === 'number';
|
|
190
|
+
const has = typeof next === 'number';
|
|
191
|
+
if (had !== has) return has ? 'enter' : 'leave';
|
|
192
|
+
return has && next > prev ? 'fill' : null;
|
|
193
|
+
}
|
|
194
|
+
const phaseBars = (strip) => bars(strip, BUILD_PHASES);
|
|
195
|
+
// A stale build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
|
|
196
|
+
function builderStatus(it) {
|
|
197
|
+
const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}`;
|
|
198
|
+
const words = `<span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}`;
|
|
199
|
+
if (!it.builder.stalled || it.lane === 'done') return `<div class="${classes}">${words}</div>`;
|
|
200
|
+
return `<button type="button" class="${classes} stalled" data-confirm="nudge" data-key="${esc(it.key)}" title="No recent progress. Nudge Claude to check this implementer">${words}</button>`;
|
|
201
|
+
}
|
|
118
202
|
const unreadCount = (othersAt, seenAt) => (seenAt ? (othersAt || []).filter((t) => String(t) > seenAt).length : 0);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
203
|
+
// When a hold ends, as a card reads it: the time today, the weekday within six days, else the date
|
|
204
|
+
// (docs/adr/2026-11-02-1727-a-card-can-be-held-until-a-time.md).
|
|
205
|
+
function heldUntilWords(iso, now = new Date()) {
|
|
206
|
+
const d = new Date(iso);
|
|
207
|
+
const midnight = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
208
|
+
const days = Math.round((midnight(d) - midnight(now)) / 86400000);
|
|
209
|
+
if (days === 0) return clock(d);
|
|
210
|
+
if (days > 0 && days < 7) return `${d.toLocaleDateString([], { weekday: 'short' })} ${clock(d)}`;
|
|
211
|
+
return `${d.toLocaleDateString([], { month: 'numeric', day: 'numeric' })}, ${clock(d)}`;
|
|
212
|
+
}
|
|
213
|
+
function holdWords(it) {
|
|
214
|
+
if (!it.held) return '';
|
|
215
|
+
const d = it.heldUntil && new Date(it.heldUntil);
|
|
216
|
+
const title = !d ? 'Held until you unhold it.'
|
|
217
|
+
: `Held until ${d.toLocaleDateString([], { weekday: 'short', month: 'numeric', day: 'numeric' })}, ${clock(d)}. ${it.lane === 'todo' ? 'It starts on its own then, once nothing else blocks it.' : 'Claude picks it up again then.'}`;
|
|
218
|
+
return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
|
|
219
|
+
}
|
|
220
|
+
// A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed
|
|
221
|
+
// or ready, then an In Progress card's phase bars, then its new questions and unread messages, questions first, which app-cards.js
|
|
222
|
+
// takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
|
|
223
|
+
// docs/adr/2026-11-03-0516-a-new-card-is-ready-grilling-or-prototyping.md, docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
|
|
224
|
+
const SEP = '<span class="sep"> · </span>';
|
|
225
|
+
const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
|
|
226
|
+
function timeLine(it, unread, questions, bars) {
|
|
227
|
+
const iso = it.sortAt;
|
|
228
|
+
const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it), it.ready && '<span class="card-ready">ready</span>'].filter(Boolean);
|
|
229
|
+
const news = [newWords(questions, 'question'), newWords(unread, 'message')].filter(Boolean).join(' · ');
|
|
230
|
+
const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
|
|
231
|
+
const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
|
|
232
|
+
if (!parts.length && !count && !tail) return '';
|
|
233
|
+
return `<div class="line6 sub card-time">${parts.join(SEP)}${tail}${count}</div>`;
|
|
234
|
+
}
|
|
235
|
+
// The Hold sheet's picker (app-cards.js): /api/board's holdChoices, the chips hub/duration.mjs shares with Snooze and
|
|
236
|
+
// Keep awake, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
|
|
237
|
+
// durationPicker's markup also uses. Each chip's length (ms, null for Forever) is worded as when it would end if picked
|
|
238
|
+
// at `now`, here rather than on the server, so the board's version doesn't change with the clock
|
|
239
|
+
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
240
|
+
const holdUntil = (c, now) => (c.ms === null ? 'until you unhold it' : `until ${heldUntilWords(new Date(now.getTime() + c.ms), now)}`);
|
|
241
|
+
function holdPicker(key, choices, now = new Date()) {
|
|
242
|
+
const chips = choices.map((c) => `<button type="button" class="btn quiet" data-hold-for="${esc(key)}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${holdUntil(c, now)}`)}">${esc(c.label)}</button>`).join('');
|
|
243
|
+
return `<div class="hub-view"><div class="dur-chips" role="group" aria-labelledby="holdTitle"><div class="dur-choices">${chips}</div>`
|
|
244
|
+
+ `<div class="dur-custom"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-hold-at="${esc(key)}" required aria-label="Custom hold until a date and time"></div></div></div>`;
|
|
245
|
+
}
|
|
124
246
|
// A New card's bump, edit and delete, and its line from its ask state (domain.mjs askItem;
|
|
125
|
-
// docs/adr/
|
|
247
|
+
// docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). top is whether it is the latest bump.
|
|
126
248
|
const askActions = (it, top) => `<div class="ask-actions">${bumpButton(it, top)}${it.ask.canEdit ? editButton(it.id) : ''}${withdrawButton(it.id)}</div>`;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
249
|
+
// A card waiting on its owner step has the owner's Done where the bump sits: a check icon that confirms, then sends
|
|
250
|
+
// card-done (app-cards.js; docs/adr/2026-11-04-0158-a-card-whose-code-landed-stays-in-review-while-an-owner-step-remains.md).
|
|
251
|
+
const CHECK = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
|
|
252
|
+
const ownerDoneButton = (it) => `<button type="button" class="ghost icon owner-done" data-confirm="finish" data-key="${esc(it.key)}" aria-label="Done" title="${esc(`Done: ${it.ownerStep}`)}">${CHECK}</button>`;
|
|
253
|
+
const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
|
|
254
|
+
: it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
|
|
255
|
+
const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && workingLine].filter(Boolean).join(' · ');
|
|
256
|
+
// In a wave band (app-lanes.js), step is the number of the card's phase in its wave, shown inside its id chip, and actions replace
|
|
257
|
+
// its bump with the band's ⋯ menu.
|
|
258
|
+
function workLines(it, { unread = 0, newQuestions = 0, top = false, step = 0, actions = null } = {}) {
|
|
259
|
+
const ticket = it.ticket ? ticketChip(it) : it.ticketSource === 'linear' ? linearChips(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : linearChips(it);
|
|
260
|
+
// A card from an ask reads New until its session sets ready, grilling or prototyping (domain.mjs unsorted).
|
|
261
|
+
const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
|
|
262
|
+
const ids = it.kind === 'ask' ? askChip(it) : it.unsorted ? `${badge('notice', 'New')} ${chip}` : chip;
|
|
263
|
+
const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
|
|
264
|
+
const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
|
|
136
265
|
: it.waitsOn === 'checking' ? '<div class="line3 sub agent"><span class="working-dot" aria-hidden="true"></span>Orchestrator review</div>'
|
|
137
|
-
: it.builder ?
|
|
266
|
+
: it.builder ? builderStatus(it)
|
|
267
|
+
: it.grilling || it.prototyping ? `<div class="line3 sub agent planner"><span class="working-dot" aria-hidden="true"></span>${it.grilling ? 'preheating' : 'prototyping'}</div>`
|
|
268
|
+
: it.refusal ? `<div class="line3 sub refused">${esc(it.refusal)}</div>`
|
|
138
269
|
: it.working ? `<div class="line3 sub agent">${workingLine}</div>`
|
|
139
|
-
: it.next ? `<div class="line3 sub next">Next: ${esc(it.next)}</div>` : '';
|
|
270
|
+
: it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held || it.queued ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
|
|
140
271
|
const eta = it.eta && it.builder ? `<div class="line3 sub eta">${esc(etaLine(it.eta))}</div>` : '';
|
|
141
|
-
return `${cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
|
|
272
|
+
return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
|
|
142
273
|
${status}${eta}
|
|
143
274
|
${chips ? `<div class="line2 meta">${chips}</div>` : ''}
|
|
144
|
-
${
|
|
275
|
+
${timeLine(it, unread, newQuestions, phaseBars(phaseStrip(it)))}`;
|
|
145
276
|
}
|
|
146
|
-
const
|
|
277
|
+
const WAVE_ICON = '<svg class="wave-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 9c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/><path d="M2 16c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/></svg>';
|
|
278
|
+
const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
|
|
147
279
|
function planButton(items) {
|
|
148
|
-
const keys = items.filter((i) => i.kind === '
|
|
149
|
-
return keys.length > 1 ? `<button class="
|
|
280
|
+
const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blockedBy?.length).map((i) => i.key);
|
|
281
|
+
return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}">Plan waves</button>` : '';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// docs/adr/2026-11-03-1731-each-board-reviews-incoming-prs-that-touch-its-work.md
|
|
285
|
+
// The Auto Review lane, its needs you in the sidebar's needs-you red: one card per incoming PR (board/auto-review.mjs reviewsOf), needs you, then reviewing, then
|
|
286
|
+
// posted. A tap opens the review text and the PR's links, and a review held for the owner offers Post it and Skip,
|
|
287
|
+
// whose tap waits while the server records it (app-lanes.js). Older posted reviews are one line each under Posted earlier.
|
|
288
|
+
const REVIEW_WORDS = { needs: 'needs you', reviewing: 'reviewing', posted: 'posted' };
|
|
289
|
+
const REVIEW_DOTS = { needs: 'rv-needs', reviewing: 'progress', posted: 'done' };
|
|
290
|
+
function reviewDetail(c) {
|
|
291
|
+
const text = c.text ? `<div class="rv-review">${c.state === 'needs' ? '<p class="muted small">Draft, not posted.</p>' : ''}${BoardUI.markdown(c.text)}</div>`
|
|
292
|
+
: '<p class="muted small">Nothing posted yet. Right before posting, Claude reads the PR’s reviews again and adds only new points.</p>';
|
|
293
|
+
const links = `<div class="rv-links"><a href="${esc(c.url)}" target="_blank" rel="noopener">PR #${c.number} on GitHub ${LINK_ARROW}</a>${c.reviewUrl ? `<a href="${esc(c.reviewUrl)}" target="_blank" rel="noopener">The review on GitHub ${LINK_ARROW}</a>` : ''}</div>`;
|
|
294
|
+
const acts = c.state === 'needs' ? `<div class="rv-acts"><button type="button" data-review-answer="post" data-review="${c.number}">Post it</button><button type="button" class="ghost" data-review-answer="skip" data-review="${c.number}">Skip</button></div>` : '';
|
|
295
|
+
return `<div class="rv-detail">${text}${links}${acts}</div>`;
|
|
296
|
+
}
|
|
297
|
+
const reviewChipsOf = (c) => [badge('quiet', `from ${esc(c.author)}`), ...c.touches.map((t) => badge(c.state === 'needs' ? 'danger' : 'quiet', `touches ${esc(t.ref)}`)), c.asked ? badge('normal', 'asked you') : ''].join(' ');
|
|
298
|
+
const reviewWhen = (c) => `${c.state === 'reviewing' ? 'started ' : ''}${ago(c.at)}`;
|
|
299
|
+
function reviewCard(c, open) {
|
|
300
|
+
return `<details class="rv-card" data-review="${c.number}"${open ? ' open' : ''}><summary><span class="status ${REVIEW_DOTS[c.state]}"></span><div class="title-wrap">
|
|
301
|
+
<div class="line1 title">${badge('normal', `PR #${c.number}`)} ${esc(c.title)}</div>
|
|
302
|
+
<div class="line3 sub${c.state === 'reviewing' ? ' agent' : ''}">${c.state === 'reviewing' ? '<span class="working-dot" aria-hidden="true"></span>' : ''}<span class="rv-word s-${c.state}">${REVIEW_WORDS[c.state]}</span> · ${esc(reviewWhen(c))}</div>
|
|
303
|
+
<div class="line2 meta">${reviewChipsOf(c)}</div></div></summary>${reviewDetail(c)}</details>`;
|
|
304
|
+
}
|
|
305
|
+
const reviewLine = (c, open) => `<details class="rv-line" data-review="${c.number}"${open ? ' open' : ''}><summary><div class="title">${badge('normal', `PR #${c.number}`)} ${esc(c.title)}</div>
|
|
306
|
+
<div class="sub">${esc([c.author, c.state === 'posted' ? `posted ${ago(c.at)}` : 'not posted', c.open ? '' : 'closed'].filter(Boolean).join(' · '))}</div></summary>${reviewDetail(c)}</details>`;
|
|
307
|
+
// reviews: the built board's reviews; open: {lane, earlier, cards: Set of open PR numbers}.
|
|
308
|
+
function reviewLane(reviews, { open = {} } = {}) {
|
|
309
|
+
if (!reviews?.shown) return '';
|
|
310
|
+
const { cards, earlier } = reviews;
|
|
311
|
+
const needs = cards.filter((c) => c.state === 'needs').length;
|
|
312
|
+
const shown = (n) => open.cards?.has(n);
|
|
313
|
+
const fold = earlier.length ? `<details class="fold" data-fold="reviews-earlier"${open.earlier ? ' open' : ''}><summary><span class="lane-name">Posted earlier</span><span class="count">${earlier.length}</span></summary>
|
|
314
|
+
<div class="fold-list">${earlier.map((c) => reviewLine(c, shown(c.number))).join('')}</div></details>` : '';
|
|
315
|
+
const waiting = reviews.waiting ? `${reviews.waiting} incoming PR${reviews.waiting === 1 ? '' : 's'} wait${reviews.waiting === 1 ? 's' : ''} until the board is idle.`
|
|
316
|
+
: `No incoming PR touches this board’s work yet.${reviews.at ? ` Checked ${esc(reviews.repo)} ${ago(reviews.at)}.` : ''}`;
|
|
317
|
+
const list = cards.length ? cards.map((c) => reviewCard(c, shown(c.number))).join('') : `<div class="empty">${waiting}</div>`;
|
|
318
|
+
return `<details class="lane" data-lane="reviews"${open.lane === false ? '' : ' open'}><summary><span class="lane-name">Auto Review</span><span class="count">${cards.length}</span>${needs ? `<span class="lane-github"><span class="rv-late">${needs} needs you</span></span>` : ''}</summary>
|
|
319
|
+
<div class="list">${list}${fold}</div></details>`;
|
|
150
320
|
}
|
|
151
321
|
|
|
152
|
-
Object.assign(globalThis.BoardUI, { prChips, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions,
|
|
322
|
+
Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, buildButton, builderLine, BUILD_PHASES, phaseStrip, phaseMotion, unreadCount, heldUntilWords, holdPicker, workLines, askChip, askActions, askLine, planButton, WAVE_ICON, reviewLane });
|
|
153
323
|
})();
|
package/board/public/ui-enums.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// A card's button names, which board/domain.mjs sets as its actionButton and resolvedBy, the kinds of its owner
|
|
2
|
-
// action,
|
|
3
|
-
//
|
|
4
|
-
// this file in a vm context (docs/adr/
|
|
2
|
+
// action, the builder kinds the page reads, and the state of a card's PR, which domain.mjs sets as pr.status and its PR
|
|
3
|
+
// badge shows (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). Each value is its name,
|
|
4
|
+
// since cards carry it in the board's JSON. Needs nothing else: board/domain.mjs runs this file in a vm context (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
|
|
5
5
|
(function () {
|
|
6
6
|
const BUTTON = Object.freeze({ grill: 'grill', openQuestions: 'openQuestions', markDone: 'markDone', github: 'github', merge: 'merge' });
|
|
7
7
|
const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done' });
|
|
8
8
|
const BUILDER_KIND = Object.freeze({ install: 'install' });
|
|
9
|
-
Object.
|
|
9
|
+
const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
|
|
10
|
+
Object.assign(globalThis.BoardUI ||= {}, { BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE });
|
|
10
11
|
})();
|
|
@@ -2,36 +2,45 @@
|
|
|
2
2
|
(function () {
|
|
3
3
|
const { since, esc } = globalThis.BoardUI;
|
|
4
4
|
// The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh), and watcher is
|
|
5
|
-
// /api/board's checks.watcher (docs/adr/
|
|
5
|
+
// /api/board's checks.watcher (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md): a grey asleep while
|
|
6
|
+
// the hub can wake the session, waking… once a wake is under way (waking: the page asked for one and the board hasn't
|
|
7
|
+
// said so yet), and a red not listening when nothing will wake it
|
|
8
|
+
// (docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md). link is 'online',
|
|
6
9
|
// 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it).
|
|
7
|
-
const statusLine = (at, { link = 'online', watcher, pause } = {}) => (pause?.state === 'paused' ? `<span class="late">
|
|
10
|
+
const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => (pause?.state === 'paused' ? `<span class="late">paused until ${esc(BoardUI.usageClock(pause.resets_at))}</span>`
|
|
8
11
|
: link === 'offline' ? `<span class="link-state offline" title="Offline: can't reach the host">${at ? shortSince(at) : 'offline'}</span>`
|
|
9
|
-
: link === 'reconnecting' ? '<span class="link-state">
|
|
10
|
-
: watcher === '
|
|
12
|
+
: link === 'reconnecting' ? '<span class="link-state">reconnecting…</span>'
|
|
13
|
+
: watcher === 'waking' || (waking && watcher === 'asleep') ? '<span class="claude asleep" title="Waking: the hub is resuming its session">waking…</span>'
|
|
14
|
+
: watcher === 'asleep' ? '<span class="claude asleep" title="Asleep: its session idled out. Refresh, or any tap on the page, wakes it">asleep</span>'
|
|
15
|
+
: watcher === 'stopped' ? '<span class="claude down" title="Claude not listening">not listening</span>'
|
|
11
16
|
: at ? `<span class="fresh" title="Updated ${since(at)}: the last change from the host, or your last refresh">${shortSince(at)}</span>` : '');
|
|
12
17
|
// What the page shows, for telling whether a change signal brought anything new; generatedAt changes on every load.
|
|
13
18
|
const freshSig = (board, threads, openKeys) => JSON.stringify([{ ...board, generatedAt: null }, [...openKeys].sort().map((k) => [k, threads.get(k) ?? null])]);
|
|
14
19
|
// fresh is {at, sig}: the header's time and what the page showed then. via is 'refresh' (a ↻ tap), 'signal' (the
|
|
15
20
|
// stream's change signal) or 'poll' (any other load). A manual refresh always moves the time, a change signal only when it brought
|
|
16
21
|
// something the time hasn't counted, a poll never, and any load moves it while none has landed (sig null)
|
|
17
|
-
// (docs/adr/
|
|
22
|
+
// (docs/adr/2026-10-29-1639-the-board-reloads-on-a-change-signal.md).
|
|
18
23
|
const nextFresh = (fresh, { via, at, sig }) => (!fresh?.sig || via === 'refresh' || (via === 'signal' && sig !== fresh.sig) ? { at, sig } : fresh);
|
|
19
24
|
const shortSince = (iso) => {
|
|
20
25
|
const s = (Date.now() - Date.parse(iso)) / 1000;
|
|
21
26
|
return s < 60 ? 'now' : s < 3600 ? Math.round(s / 60) + ' min' : s < 86400 ? Math.round(s / 3600) + ' h' : Math.round(s / 86400) + ' d';
|
|
22
27
|
};
|
|
23
|
-
// GitHub's logo guidelines forbid altering the mark, so it stays filled rather than a line icon (docs/adr/
|
|
28
|
+
// GitHub's logo guidelines forbid altering the mark, so it stays filled rather than a line icon (docs/adr/2026-10-29-1639-icons-are-sf-symbol-style-line-icons.md).
|
|
24
29
|
const GITHUB_MARK = '<svg class="gh-mark" width="13" height="13" viewBox="0 0 16 16" fill="currentColor" role="img" aria-label="GitHub"><title>GitHub</title><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>';
|
|
25
30
|
// The GitHub mark opens the Hub links sheet, with a red dot while any link is broken past its grace period
|
|
26
|
-
// (docs/adr/
|
|
31
|
+
// (docs/adr/2026-10-29-1639-hub-links-turn-red-only-after-a-grace-period.md). links is /api/board's (hub/boards.mjs linksOf), null
|
|
27
32
|
// when the hub doesn't list this board. A link without state, from a board server still running older code, reads from ok.
|
|
28
33
|
const linkState = (l) => l.state || (l.ok ? 'ok' : 'broken');
|
|
29
34
|
const brokenLinks = (links) => (links || []).filter((l) => linkState(l) === 'broken');
|
|
30
|
-
const linksMark = (links) => {
|
|
35
|
+
const linksMark = (links, mark = GITHUB_MARK) => {
|
|
31
36
|
const broken = brokenLinks(links).length;
|
|
32
37
|
const label = `Hub links${broken ? `: ${broken} not working` : ''}`;
|
|
33
|
-
return `<button class="ghost gh-links" type="button" data-hub-links aria-haspopup="dialog" aria-label="${label}" title="${label}">${
|
|
38
|
+
return `<button class="ghost gh-links" type="button" data-hub-links aria-haspopup="dialog" aria-label="${label}" title="${label}">${mark}${broken ? '<span class="links-dot" aria-hidden="true"></span>' : ''}</button>`;
|
|
34
39
|
};
|
|
40
|
+
// A board on a plain git remote has no GitHub checks, so the In Review header names its repo, host/path, instead
|
|
41
|
+
// (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
|
|
42
|
+
const REMOTE_MARK = '<svg class="gh-mark" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
|
|
43
|
+
const remoteLine = (remote, { links = null } = {}) => `<span class="github remote" title="This board’s repo">${linksMark(links, REMOTE_MARK)} <span class="remote-name">${esc(remote.name)}</span></span>`;
|
|
35
44
|
// The In Review header's right end; pending is whether a check-github request is waiting.
|
|
36
45
|
const githubLine = (checks, { pending = false, links = null } = {}) => {
|
|
37
46
|
const mark = linksMark(links);
|
|
@@ -42,9 +51,9 @@
|
|
|
42
51
|
return `<span class="github${checks.late && !checks.idle ? ' late' : ''}${checks.idle ? ' idle' : ''}" title="${title}">${mark} ${shortSince(checks.at)}${note}</span>`;
|
|
43
52
|
};
|
|
44
53
|
|
|
45
|
-
const LINK_NAMES = { github: 'GitHub polling',
|
|
54
|
+
const LINK_NAMES = { github: 'GitHub polling', 'repo-fetch': 'Repo fetch', events: 'Events', wakes: 'Daily wakes', slack: 'Reviewer links' };
|
|
46
55
|
// The Hub links sheet's list, one row per link with a green dot, a muted one while settling, or a red one
|
|
47
|
-
// (docs/adr/
|
|
56
|
+
// (docs/adr/2026-10-29-1639-one-board-registry-for-every-hub-feature.md).
|
|
48
57
|
const LINK_DOTS = { ok: ['pass', 'Working'], settling: ['', 'Settling'], broken: ['fail', 'Not working'] };
|
|
49
58
|
const linkRow = (l) => {
|
|
50
59
|
const st = linkState(l);
|
|
@@ -63,7 +72,7 @@
|
|
|
63
72
|
const step = (ok, done, waiting) => `<li><span class="dot ${ok ? 'pass' : 'pending'}" aria-hidden="true"></span>${ok ? done : waiting}</li>`;
|
|
64
73
|
return `<h2>Connecting this board</h2><ul class="setup-steps">
|
|
65
74
|
${step(setup.events, 'Claude answered the hello', 'Waiting for Claude to answer the hello')}
|
|
66
|
-
${step(setup.github, 'GitHub checks reach this board', 'Waiting for the hub\'s next GitHub check')}
|
|
75
|
+
${setup.github === undefined ? '' : step(setup.github, 'GitHub checks reach this board', 'Waiting for the hub\'s next GitHub check')}
|
|
67
76
|
</ul>`;
|
|
68
77
|
};
|
|
69
78
|
const checkLanded = (checks, pendingSince) => Boolean(checks?.at) && Date.parse(checks.at) >= pendingSince;
|
|
@@ -74,11 +83,11 @@
|
|
|
74
83
|
|
|
75
84
|
// Which header controls take taps, and whether refresh spins. The header paints them disabled with refresh spinning;
|
|
76
85
|
// both turn on together once the first board load has answered or failed.
|
|
77
|
-
// Offline, refresh stays on so a tap can retry the load (docs/adr/
|
|
86
|
+
// Offline, refresh stays on so a tap can retry the load (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md).
|
|
78
87
|
const headerControls = ({ link = 'online', wired = false, boardSettled = false, reloading = false } = {}) => {
|
|
79
88
|
const ready = wired && boardSettled && !reloading;
|
|
80
89
|
return { refresh: ready, settings: ready && link === 'online', spin: !boardSettled || reloading };
|
|
81
90
|
};
|
|
82
91
|
|
|
83
|
-
Object.assign(globalThis.BoardUI, { statusLine, freshSig, nextFresh, githubLine, brokenLinks, linksList, setupCard, checkLanded, githubRefresh, refreshIcon, headerControls });
|
|
92
|
+
Object.assign(globalThis.BoardUI, { statusLine, freshSig, nextFresh, githubLine, remoteLine, brokenLinks, linksList, setupCard, checkLanded, githubRefresh, refreshIcon, headerControls });
|
|
84
93
|
})();
|