@lovek4d/kickflip 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +177 -78
- package/README.md +46 -5
- package/SKILL.md +58 -37
- package/bin/doctor.mjs +4 -3
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +87 -32
- package/bin/statusline.mjs +4 -3
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +5 -0
- package/board/bin/agent.mjs +1 -1
- package/board/bin/board.mjs +241 -69
- package/board/bin/builder-stop-hook.mjs +4 -3
- package/board/domain.mjs +5 -3
- package/board/on-host.mjs +1 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +33 -14
- package/board/public/app-cards.js +242 -49
- package/board/public/app-core.js +39 -5
- package/board/public/app-header.js +24 -6
- package/board/public/app-lanes.js +76 -26
- package/board/public/app-needs.js +1 -0
- package/board/public/app-snapshot.js +45 -11
- package/board/public/app-threads.js +13 -11
- package/board/public/app.html +4 -1
- package/board/public/app.js +131 -20
- package/board/public/dialogs.js +114 -116
- package/board/public/hub-kit.js +19 -2
- package/board/public/hub.css +24 -18
- package/board/public/linear-key.js +141 -0
- package/board/public/router.js +6 -3
- package/board/public/rules-editor.js +1 -1
- package/board/public/shell.js +1 -1
- package/board/public/sidebar.css +31 -11
- package/board/public/sidebar.js +88 -16
- package/board/public/style.css +141 -46
- package/board/public/toasts.js +6 -4
- package/board/public/ui-cards.js +102 -37
- package/board/public/ui-header.js +17 -8
- package/board/public/ui-hub.js +30 -12
- package/board/public/ui-links.js +18 -10
- package/board/public/ui-order.js +18 -0
- package/board/public/ui-page-header.js +4 -2
- package/board/public/ui-questions.js +37 -16
- package/board/public/ui-sidebar.js +49 -11
- package/board/public/ui-snapshot.js +15 -9
- package/board/public/ui-threads.js +25 -16
- package/board/public/ui.js +23 -11
- package/board/public/view-hub-settings.js +46 -30
- package/board/public/view-hub.js +64 -13
- package/board/public/view-notes.js +7 -4
- package/board/public/view-polling.js +3 -3
- package/board/public/view-questions.js +42 -22
- package/board/public/view-settings.js +90 -35
- package/board/review.mjs +1 -0
- package/board/server.mjs +12 -7
- package/board/store.mjs +14 -6
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +14 -13
- package/docs/hub-session.md +3 -3
- package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/duration.mjs +1 -0
- package/hub/github.mjs +4 -5
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -10
- package/hub/linear.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/pr-report.mjs +1 -0
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -2
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/shell.mjs +2 -1
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +12 -12
- package/hub/sw.js +1 -1
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +4 -3
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/colors.css +7 -5
- package/shared/fake-gh.mjs +54 -0
- package/shared/service.mjs +16 -16
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -1
- package/starter/public/claude.js +3 -2
- package/bin/web-ts.mjs +0 -2
package/board/public/ui-cards.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js and ui-enums.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, LINK_ARROW } = globalThis.BoardUI;
|
|
4
4
|
const EXTERNAL = { target: '_blank', rel: 'noopener' };
|
|
5
5
|
function prChips(it) {
|
|
6
6
|
const pr = it.pr;
|
|
7
7
|
if (!pr) return '';
|
|
8
8
|
const st = pr.state === 'MERGED' ? badge('ready', 'merged') : pr.state === 'CLOSED' ? badge('danger', 'closed') : '';
|
|
9
9
|
// An open PR's chip ends with its state, marked like the reviewer and CI chips.
|
|
10
|
-
const open = pr.state === 'OPEN' ? (pr.isDraft ? { tone: 'notice', mark: ' ●', label: 'Draft' } : { tone: 'ready', mark: ' ✓', label: 'Ready for review' }) : { tone: '
|
|
10
|
+
const open = pr.state === 'OPEN' ? (pr.isDraft ? { tone: 'notice', mark: ' ●', label: 'Draft' } : { tone: 'ready', mark: ' ✓', label: 'Ready for review' }) : { tone: 'quiet', mark: '', label: '' };
|
|
11
11
|
const CI = { pass: { tone: 'ready', mark: '✓' }, fail: { tone: 'danger', mark: '✗' }, pending: { tone: 'notice', mark: '●' } }[pr.checks?.state] || { tone: 'quiet', mark: '?' };
|
|
12
12
|
const ci = pr.checks && pr.state === 'OPEN' ? badge(CI.tone, `CI ${CI.mark}`, { title: `CI ${pr.checks.state}` }) : '';
|
|
13
13
|
const repo = (pr.url.match(/github\.com\/[^/]+\/([^/]+)\//) || [])[1] || '';
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
const label = open.label ? { title: open.label, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${open.label}` } } : { attrs: EXTERNAL };
|
|
17
17
|
return `${badge(open.tone, `${esc(name)}${open.mark}`, { href: pr.url, ...label })}${st}${ci}${codex}`;
|
|
18
18
|
}
|
|
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(
|
|
19
|
+
const linearChips = (it) => (it.linear || []).map((l) => badge('quiet', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
|
|
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('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
21
|
const REVIEW = {
|
|
22
22
|
approved: { tone: 'ready', mark: '✓', label: 'approved' },
|
|
23
23
|
changes: { tone: 'danger', mark: '✗', label: 'asked for changes' },
|
|
@@ -33,36 +33,44 @@
|
|
|
33
33
|
return (it.reviewers || []).map((r) => {
|
|
34
34
|
const rv = globalThis.BoardUI.record(it.review, r);
|
|
35
35
|
const s = REVIEW[rv?.state || 'unsent'];
|
|
36
|
-
|
|
36
|
+
const age = reviewAge(rv).trim();
|
|
37
|
+
return badge(s.tone, `${esc(rv?.github || r)}${age ? ` (${age})` : ''} ${s.mark}`, { title: `${r}${rv?.github ? ` (${rv.github})` : ''}: ${s.label}` });
|
|
37
38
|
}).join('');
|
|
38
39
|
}
|
|
39
40
|
const pendingQuestions = (it) => (it.questions && it.questions.state !== 'resolved' ? it.questions.open + it.questions.answered + it.questions.replied : 0);
|
|
40
41
|
// docs/adr/0045-the-lane-follows-who-acts-next.md
|
|
41
|
-
|
|
42
|
+
// Action needed, last of the time line's orange words, linking to what the owner acts on
|
|
43
|
+
// (docs/adr/0145-build-on-a-blocked-card-queues-it.md).
|
|
44
|
+
function actionWords(it) {
|
|
42
45
|
if (!it.action) return '';
|
|
43
|
-
const external = it.action.kind === ACTION_KIND.ready ? EXTERNAL
|
|
44
|
-
return
|
|
46
|
+
const external = it.action.kind === ACTION_KIND.ready ? Object.entries(EXTERNAL).map(([k, v]) => ` ${k}="${esc(v)}"`).join('') : '';
|
|
47
|
+
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>`;
|
|
48
|
+
}
|
|
49
|
+
// Each unfinished dep, linking to its card: "blocked by #53, #54".
|
|
50
|
+
function blockedWords(it) {
|
|
51
|
+
const deps = it.blockedBy || [];
|
|
52
|
+
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
53
|
}
|
|
46
54
|
const tryWords = (kind) => kind === 'app' ? {
|
|
47
55
|
go: 'Build', going: 'Building…', done: 'Install', failed: 'Build failed',
|
|
48
|
-
none: 'No changes to build: this card’s branch matches main.',
|
|
49
56
|
current: 'Nothing new to build: Install already has this card’s latest push.',
|
|
50
57
|
busy: 'Building now. The badge turns back into Install when the new build is ready.',
|
|
51
58
|
autoSub: 'On: the hub builds each card after each push. Off: a card waits for your Build. Main builds again after every merge either way.',
|
|
52
59
|
saved: (on) => on ? 'Saved. Each push builds its card.' : 'Saved. Cards wait for your Build.',
|
|
53
60
|
} : {
|
|
54
61
|
go: 'Serve', going: 'Serving…', done: 'Visit', failed: 'Serve failed',
|
|
55
|
-
none: 'No changes to serve: this card’s branch matches main.',
|
|
56
62
|
current: 'Nothing new to serve: Visit already shows this card’s latest push.',
|
|
57
63
|
busy: 'Serving now. The badge turns back into Visit when the new server is up.',
|
|
58
64
|
autoSub: 'On: the hub serves each card after each push. Off: a card waits for your Serve. Main serves again after every merge either way.',
|
|
59
65
|
saved: (on) => on ? 'Saved. Each push serves its card.' : 'Saved. Cards wait for your Serve.',
|
|
60
66
|
};
|
|
61
67
|
const spinner = '<span class="try-spin" aria-hidden="true"></span>';
|
|
68
|
+
const tryURL = (run) => run.kind === 'web' ? run.port ? `https://${location.hostname}:${run.port}/` : null : run.url;
|
|
62
69
|
function tryOf(it) {
|
|
63
70
|
if (it.lane === 'done') return null;
|
|
64
71
|
const t = it.try || (it.install ? { kind: 'app', state: 'ready', stale: it.install.older, sha: it.install.sha, url: it.install.url } : null);
|
|
65
|
-
|
|
72
|
+
const run = t ? { ...t, url: tryURL(t) } : null;
|
|
73
|
+
return it.builder?.kind === BUILDER_KIND.install ? { ...run, kind: run?.kind || 'app', state: 'running' } : run;
|
|
66
74
|
}
|
|
67
75
|
function installState(it) {
|
|
68
76
|
const t = tryOf(it);
|
|
@@ -87,20 +95,23 @@
|
|
|
87
95
|
const tag = s.href ? 'a' : 'span';
|
|
88
96
|
return `<${tag} class="install tone-${s.tone}"${attrs}>${s.html}</${tag}>`;
|
|
89
97
|
}
|
|
98
|
+
// A card's Serve / Build, only while the board's Auto-build is off and its branch has changes
|
|
99
|
+
// (docs/adr/0124-only-the-owner-or-auto-build-starts-a-card-run.md).
|
|
90
100
|
function tryButton(it) {
|
|
91
101
|
const t = tryOf(it);
|
|
92
|
-
if (it.kind !== 'work' || !t?.script) return '';
|
|
102
|
+
if (it.kind !== 'work' || !t?.script || t.autoBuild || !t.changes) return '';
|
|
93
103
|
const words = tryWords(t.kind);
|
|
94
|
-
const reason =
|
|
104
|
+
const reason = t.state === 'running' ? words.busy : t.state === 'ready' && !t.stale ? words.current : '';
|
|
95
105
|
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
106
|
}
|
|
97
107
|
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
108
|
function mainBar(main) {
|
|
99
109
|
if (!main) return '';
|
|
100
110
|
const words = tryWords(main.kind);
|
|
111
|
+
const url = tryURL(main);
|
|
101
112
|
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' &&
|
|
113
|
+
: 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.' })
|
|
114
|
+
: main.state === 'ready' && url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
|
|
104
115
|
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>`;
|
|
105
116
|
}
|
|
106
117
|
// The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, and reads "Merging…"
|
|
@@ -110,44 +121,98 @@
|
|
|
110
121
|
const merging = sending || it.merging;
|
|
111
122
|
return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${merging ? ' disabled' : ''}>${merging ? 'Merging…' : 'Merge'}</button>`;
|
|
112
123
|
}
|
|
113
|
-
|
|
124
|
+
// The open card's Implement, rightmost in its action row, on a card board/domain.mjs marks buildable: it confirms, then
|
|
125
|
+
// sends card-build, and reads "Starting…" while it is sent and the board server starts the implementer. Build and Serve
|
|
126
|
+
// mean the package only (docs/adr/0122-a-todo-card-that-nothing-blocks-has-build.md,
|
|
127
|
+
// docs/adr/0139-a-build-tap-starts-the-build-in-the-board-server.md, docs/adr/0166-a-todo-cards-button-reads-implement.md).
|
|
128
|
+
function buildButton(it, sending = false) {
|
|
129
|
+
if (!it.buildable) return '';
|
|
130
|
+
return `<button class="build" data-confirm="card-build" data-key="${esc(it.key)}"${sending ? ' disabled' : ''}>${sending ? 'Starting…' : 'Implement'}</button>`;
|
|
131
|
+
}
|
|
132
|
+
// A builder the Implement tap started reads "Waiting for orchestrator" until the session's builder posts its first line.
|
|
133
|
+
const builderLine = (b) => [b.progress || (b.waiting ? 'Waiting for orchestrator' : 'Starting'), b.at && ago(b.at)].filter(Boolean).join(' · ');
|
|
134
|
+
// Five bars at the end of the time row while a build runs on an In Progress card: done phases filled, the current one
|
|
135
|
+
// marked. The phases are store.mjs PHASES (docs/adr/0137-a-builder-status-line-is-a-phase-and-a-short-detail.md,
|
|
136
|
+
// docs/adr/0160-a-building-cards-phase-bars-end-its-time-row.md).
|
|
137
|
+
const BUILD_PHASES = ['reading', 'building', 'testing', 'checking', 'landing'];
|
|
138
|
+
function phaseBars(phase) {
|
|
139
|
+
const at = BUILD_PHASES.indexOf(phase);
|
|
140
|
+
if (at < 0) return '';
|
|
141
|
+
const name = `${phase[0].toUpperCase()}${phase.slice(1)}`;
|
|
142
|
+
return `<span class="phase-bars" role="img" aria-label="${name}, step ${at + 1} of ${BUILD_PHASES.length}">${BUILD_PHASES.map((_, i) => `<i class="${i < at ? 'done' : i === at ? 'now' : ''}"></i>`).join('')}</span>`;
|
|
143
|
+
}
|
|
114
144
|
const stalledChip = (it) => it.lane !== 'done' && it.builder?.stalled ? badge('danger', 'Stalled', {
|
|
115
|
-
button: true, title: 'No recent progress. Nudge Claude to check this
|
|
145
|
+
button: true, title: 'No recent progress. Nudge Claude to check this implementer',
|
|
116
146
|
attrs: { type: 'button', 'data-confirm': 'nudge', 'data-key': it.key },
|
|
117
147
|
}) : '';
|
|
118
148
|
const unreadCount = (othersAt, seenAt) => (seenAt ? (othersAt || []).filter((t) => String(t) > seenAt).length : 0);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
149
|
+
// When a hold ends, as a card reads it: the time today, the weekday within six days, else the date
|
|
150
|
+
// (docs/adr/0118-a-card-can-be-held-until-a-time.md).
|
|
151
|
+
function heldUntilWords(iso, now = new Date()) {
|
|
152
|
+
const d = new Date(iso);
|
|
153
|
+
const midnight = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
154
|
+
const days = Math.round((midnight(d) - midnight(now)) / 86400000);
|
|
155
|
+
if (days === 0) return clock(d);
|
|
156
|
+
if (days > 0 && days < 7) return `${d.toLocaleDateString([], { weekday: 'short' })} ${clock(d)}`;
|
|
157
|
+
return `${d.toLocaleDateString([], { month: 'numeric', day: 'numeric' })}, ${clock(d)}`;
|
|
158
|
+
}
|
|
159
|
+
function holdWords(it) {
|
|
160
|
+
if (!it.held) return '';
|
|
161
|
+
const d = it.heldUntil && new Date(it.heldUntil);
|
|
162
|
+
const title = !d ? 'Held until you unhold it.'
|
|
163
|
+
: `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.'}`;
|
|
164
|
+
return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
|
|
165
|
+
}
|
|
166
|
+
// A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed
|
|
167
|
+
// or ready, then a building card's phase bars, then its unread count, which app-cards.js takes away when the card opens
|
|
168
|
+
// (docs/adr/0154-a-new-card-is-ready-grilling-or-prototyping.md, docs/adr/0160-a-building-cards-phase-bars-end-its-time-row.md).
|
|
169
|
+
const SEP = '<span class="sep"> · </span>';
|
|
170
|
+
function timeLine(it, unread, bars) {
|
|
171
|
+
const iso = it.sortAt;
|
|
172
|
+
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);
|
|
173
|
+
const count = unread ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${unread} new message${unread === 1 ? '' : 's'}</span>` : '';
|
|
174
|
+
const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
|
|
175
|
+
if (!parts.length && !count && !tail) return '';
|
|
176
|
+
return `<div class="line6 sub card-time">${parts.join(SEP)}${tail}${count}</div>`;
|
|
177
|
+
}
|
|
178
|
+
// The Hold sheet's picker (app-cards.js): /api/board's holdChoices, the chips hub/duration.mjs shares with Snooze and
|
|
179
|
+
// Keep awake, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
|
|
180
|
+
// durationPicker's markup also uses.
|
|
181
|
+
function holdPicker(key, choices) {
|
|
182
|
+
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}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
|
|
183
|
+
return `<div class="hub-view"><div class="dur-chips" role="group" aria-labelledby="holdTitle"><div class="dur-choices">${chips}</div>`
|
|
184
|
+
+ `<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>`;
|
|
185
|
+
}
|
|
124
186
|
// A New card's bump, edit and delete, and its line from its ask state (domain.mjs askItem;
|
|
125
187
|
// docs/adr/0047-the-plus-makes-a-new-card.md). top is whether it is the latest bump.
|
|
126
188
|
const askActions = (it, top) => `<div class="ask-actions">${bumpButton(it, top)}${it.ask.canEdit ? editButton(it.id) : ''}${withdrawButton(it.id)}</div>`;
|
|
127
|
-
const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key,
|
|
128
|
-
const
|
|
129
|
-
const askLine = (ask) => `${ask.from ? `Moved from ${esc(ask.from)} · ` : ''}${esc(time(ask.askedAt))} · ${ask.edited ? 'Edited · ' : ''}${ASK_STATE[ask.state] || ''}`;
|
|
189
|
+
const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
|
|
190
|
+
const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && `<span class="working">${workingLine}</span>`].filter(Boolean).join(' · ');
|
|
130
191
|
function workLines(it, { unread = 0, top = false } = {}) {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
const
|
|
192
|
+
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);
|
|
193
|
+
// A card from an ask reads New until its session sets ready, grilling or prototyping (domain.mjs unsorted).
|
|
194
|
+
const ids = it.kind === 'ask' ? askChip(it) : it.unsorted ? `${badge('notice', 'New')} ${ticket}` : ticket;
|
|
195
|
+
const wave = it.lineup ? badge('quiet', `${WAVE_ICON}${esc(it.lineup.title)}`, { title: `In the wave ${it.lineup.title}` }) : '';
|
|
196
|
+
const chips = `${stalledChip(it)}${it.followUp ? badge('quiet', 'follow-up') : ''}${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
|
|
197
|
+
const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
|
|
136
198
|
: it.waitsOn === 'checking' ? '<div class="line3 sub agent"><span class="working-dot" aria-hidden="true"></span>Orchestrator review</div>'
|
|
137
|
-
: it.builder ? `<div class="line3 sub agent"><span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}</div>`
|
|
199
|
+
: it.builder ? `<div class="line3 sub agent${it.builder.planner ? ' planner' : ''}"><span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}</div>`
|
|
200
|
+
: it.refusal ? `<div class="line3 sub refused">${esc(it.refusal)}</div>`
|
|
138
201
|
: it.working ? `<div class="line3 sub agent">${workingLine}</div>`
|
|
139
|
-
: it.next ? `<div class="line3 sub next">Next: ${esc(it.next)}</div>` : '';
|
|
202
|
+
: it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
|
|
140
203
|
const eta = it.eta && it.builder ? `<div class="line3 sub eta">${esc(etaLine(it.eta))}</div>` : '';
|
|
141
204
|
return `${cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
|
|
142
205
|
${status}${eta}
|
|
143
206
|
${chips ? `<div class="line2 meta">${chips}</div>` : ''}
|
|
144
|
-
${
|
|
207
|
+
${wave ? `<div class="line5 meta">${wave}</div>` : ''}
|
|
208
|
+
${timeLine(it, unread, it.lane === 'progress' && it.builder && !it.builder.planner && it.waitsOn !== 'checking' ? phaseBars(it.builder.phase) : '')}`;
|
|
145
209
|
}
|
|
146
|
-
const
|
|
210
|
+
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>';
|
|
211
|
+
const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
|
|
147
212
|
function planButton(items) {
|
|
148
|
-
const keys = items.filter((i) => i.kind === '
|
|
149
|
-
return keys.length > 1 ? `<button class="
|
|
213
|
+
const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blockedBy?.length).map((i) => i.key);
|
|
214
|
+
return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}">Plan waves</button>` : '';
|
|
150
215
|
}
|
|
151
216
|
|
|
152
|
-
Object.assign(globalThis.BoardUI, { prChips, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions,
|
|
217
|
+
Object.assign(globalThis.BoardUI, { prChips, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, buildButton, builderLine, unreadCount, heldUntilWords, holdPicker, workLines, askChip, askActions, askLine, planButton, WAVE_ICON });
|
|
153
218
|
})();
|
|
@@ -2,12 +2,17 @@
|
|
|
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/0021-the-board-shows-github-checks-and-watcher-health.md)
|
|
5
|
+
// /api/board's checks.watcher (docs/adr/0021-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/0147-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">Paused until ${esc(BoardUI.usageClock(pause.resets_at))}</span>`
|
|
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
12
|
: link === 'reconnecting' ? '<span class="link-state">Reconnecting…</span>'
|
|
10
|
-
: watcher === '
|
|
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])]);
|
|
@@ -27,11 +32,15 @@
|
|
|
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/0127-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,7 +51,7 @@
|
|
|
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
56
|
// (docs/adr/0040-one-board-registry-for-every-hub-feature.md).
|
|
48
57
|
const LINK_DOTS = { ok: ['pass', 'Working'], settling: ['', 'Settling'], broken: ['fail', 'Not working'] };
|
|
@@ -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;
|
|
@@ -80,5 +89,5 @@
|
|
|
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
|
})();
|
package/board/public/ui-hub.js
CHANGED
|
@@ -3,12 +3,19 @@
|
|
|
3
3
|
(function () {
|
|
4
4
|
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
5
5
|
const TILE_PATHS = {
|
|
6
|
+
github: '<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"/>',
|
|
7
|
+
codex: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3"/><path d="M13 15h4"/>',
|
|
8
|
+
linear: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
|
9
|
+
slack: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
|
10
|
+
tailscale: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18"/>',
|
|
11
|
+
notifications: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
|
6
12
|
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
|
7
13
|
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
|
8
14
|
moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
|
|
9
15
|
power: '<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/>',
|
|
10
16
|
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h18"/>',
|
|
11
17
|
snooze: '<path d="M4 10h6l-6 8h6"/><path d="M14 4h6l-6 8h6"/>',
|
|
18
|
+
coffee: '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><path d="M6 2v2"/><path d="M10 2v2"/><path d="M14 2v2"/>',
|
|
12
19
|
laptop: '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M2 19h20"/>',
|
|
13
20
|
key: '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
|
|
14
21
|
};
|
|
@@ -36,14 +43,15 @@
|
|
|
36
43
|
return `<div class="hold-banner ub-banner ub-${going ? 'going' : 'paused'}" role="status"><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">${icon}</svg><span>${esc(text)}</span><form method="post" action="/_hub/settings/keep-going"><button class="btn${going ? '' : ' quiet'}" name="value" value="${going ? 'off' : 'on'}">${going ? 'Stop' : 'Keep going'}</button></form></div>`;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
|
|
46
|
+
// builder: the word for who builds Codex's work, hub/views.mjs hubView's claude.builder.
|
|
47
|
+
function bufferControl(pause, builder) {
|
|
40
48
|
const at = pause.pause_at;
|
|
41
49
|
const off = at === 100;
|
|
42
|
-
return `<div class="
|
|
50
|
+
return `<div class="block"><div class="ub-row"><span class="ub-label"><span class="title">Pause work at</span><span class="ub-sub">${off ? 'No buffer' : `${100 - at}% buffer`}</span></span>`
|
|
43
51
|
+ `<form class="segments ub-step" method="post" action="/_hub/settings/pause-at" role="group" aria-label="Pause work at"><button name="value" value="${at - 5}" aria-label="Pause 5% sooner"${at <= 50 ? ' disabled' : ''}>−</button>`
|
|
44
52
|
+ `<output class="${off ? 'off' : ''}" aria-live="polite">${off ? 'Off' : `${at}%`}</output>`
|
|
45
53
|
+ `<button name="value" value="${at + 5}" aria-label="Pause 5% later"${off ? ' disabled' : ''}>+</button></form></div>`
|
|
46
|
-
+ `<p class="u-share-desc">${off ? 'Off: work keeps going until usage runs out.' :
|
|
54
|
+
+ `<p class="u-share-desc">${off ? 'Off: work keeps going until usage runs out.' : `Claude’s new work waits when either window passes this, and resumes at that window’s reset; Codex hands its work to ${builder} implementers.`}</p></div>`;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
// What polling does now, the last and next check and the queue, with Send now: hub/views.mjs pollingNow's words.
|
|
@@ -53,10 +61,10 @@
|
|
|
53
61
|
// This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet, disabled}} or
|
|
54
62
|
// null while unknown, which keeps the row hidden.
|
|
55
63
|
function deviceRow(state, { compact = false } = {}) {
|
|
56
|
-
if (!state) return `<div class="row" id="push" hidden>${tile('bell', '
|
|
64
|
+
if (!state) return `<div class="row" id="push" hidden>${tile('bell', 'var(--accent)')}</div>`;
|
|
57
65
|
const a = state.action;
|
|
58
66
|
const button = a ? `<button type="button" class="${a.quiet ? 'btn quiet' : 'btn'}" data-device="${esc(a.act)}"${a.disabled ? ' disabled' : ''}>${esc(a.label)}</button>` : '';
|
|
59
|
-
return `<div class="row" id="push">${tile('bell', '
|
|
67
|
+
return `<div class="row" id="push">${tile('bell', state.action?.act === 'test' ? 'var(--color-success)' : 'var(--accent)')}<span class="body"><span class="text"><span class="title">${esc(state.title)}</span>${compact ? '' : `<span class="sub wrap">${esc(state.sub)}</span>`}</span>${compact && state.title === 'This device' ? `<span class="val">${state.sub?.startsWith('On') ? 'On' : 'Off'}</span>` : ''}${button}</span></div>`;
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
// The Codex share control: codex is hub/views.mjs hubView's codex, and its form posts to /_hub/settings/codex, which
|
|
@@ -65,14 +73,24 @@
|
|
|
65
73
|
const off = !codex.usable;
|
|
66
74
|
const buttons = codex.choices.map((c) => `<button type="submit" name="value" value="${esc(c.value)}" aria-pressed="${c.on}"${off ? ' disabled' : ''}>${esc(c.label)}</button>`).join('');
|
|
67
75
|
return `<div class="u-share"><form class="segments${off ? ' is-disabled' : ''}" method="post" action="/_hub/settings/codex" role="group" aria-label="Codex share"${off ? ' aria-disabled="true"' : ''}>${buttons}</form>`
|
|
68
|
-
+ `<p class="u-share-desc">${esc(codex.description)}</p>${codex.fix ? `<p class="u-share-fix"><code>${esc(codex.fix)}</code></p>` : ''}
|
|
76
|
+
+ `<p class="u-share-desc">${esc(codex.description)}</p>${codex.fix ? `<p class="u-share-fix"><code>${esc(codex.fix)}</code></p>` : ''}</div>`;
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
//
|
|
79
|
+
// The Claude model control, drawn as Codex share's: claude is hub/views.mjs hubView's claude, and its form posts to
|
|
80
|
+
// /_hub/settings/claude-model, which view-hub.js sends.
|
|
81
|
+
function claudeModel(claude) {
|
|
82
|
+
const buttons = claude.choices.map((c) => `<button type="submit" name="value" value="${esc(c.value)}" aria-pressed="${c.on}">${esc(c.label)}</button>`).join('');
|
|
83
|
+
return `<div class="u-share"><form class="segments" method="post" action="/_hub/settings/claude-model" role="group" aria-label="Claude model">${buttons}</form>`
|
|
84
|
+
+ `<p class="u-share-desc">${esc(claude.description)}</p></div>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// data is the usage reading; claude, when given, opens Claude's block with the Claude model control, even without a
|
|
88
|
+
// reading; codex, when given, draws Codex's block even without a reading: the Codex share control
|
|
72
89
|
// first, then Codex's bars while share isn't Off.
|
|
73
|
-
function usageCard(data, codex, pause) {
|
|
90
|
+
function usageCard(data, codex, pause, claude) {
|
|
74
91
|
const { usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords } = BoardUI;
|
|
75
92
|
const products = usageProducts(data);
|
|
93
|
+
if (claude && !products.includes('claude')) products.unshift('claude');
|
|
76
94
|
if (codex && !products.includes('codex')) products.push('codex');
|
|
77
95
|
if (!products.length) return '';
|
|
78
96
|
const head = (name, tag) => `<div class="u-head"><span class="title">${name}</span>${tag ? `<span class="u-asof">${esc(tag)}</span>` : ''}</div>`;
|
|
@@ -81,13 +99,13 @@
|
|
|
81
99
|
`<div class="u-tone-${usageTone(r[key])}${pause?.pause_at < 100 ? ' ub-marked' : ''}"><div class="u-win"><span>${label}</span><span>${usageWords(r, key)}</span></div>${usageBar(r[key]).replace('</span>', `${pause?.pause_at < 100 ? `<span class="ub-mark" style="left:${pause.pause_at}%"></span>` : ''}</span>`)}</div>`).join('');
|
|
82
100
|
const blocks = products.map((p) => {
|
|
83
101
|
const r = data?.[p] || { at: null };
|
|
84
|
-
if (p
|
|
102
|
+
if (p === 'claude' && claude) return `<div class="block u-block">${head(usageNames.claude, asof(r))}${claudeModel(claude)}${windows(r)}</div>`;
|
|
103
|
+
if (p !== 'codex' || !codex) return `<div class="block u-block">${head(usageNames[p], asof(r))}${windows(r)}</div>`;
|
|
85
104
|
const bars = codex.usable && codex.share !== 'off';
|
|
86
105
|
return `<div class="block u-block">${head(usageNames.codex, codex.tag || (bars ? asof(r) : ''))}${codexShare(codex)}${bars ? `<div class="u-codex-bars">${windows(r)}</div>` : ''}</div>`;
|
|
87
106
|
}).join('');
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}</div>${note}</section>`;
|
|
107
|
+
const buffer = pause && products.includes('claude') ? bufferControl(pause, claude?.builder ?? 'Claude') : '';
|
|
108
|
+
return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}${buffer}</div></section>`;
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
globalThis.HubUI = { esc, tile, CHEVRON, row, flash, holdBanner, pauseBanner, nowRow, deviceRow, usageCard };
|
package/board/public/ui-links.js
CHANGED
|
@@ -3,22 +3,28 @@
|
|
|
3
3
|
// the ui-*.js files that render text.
|
|
4
4
|
(function () {
|
|
5
5
|
const { esc, record } = globalThis.BoardUI;
|
|
6
|
-
// The Linear id shape
|
|
6
|
+
// The Linear id shape, which an internal ref shares.
|
|
7
7
|
const LINEAR_ID = /^([A-Z][A-Z0-9]*)-\d+$/;
|
|
8
8
|
const LINEAR_ISSUE = /^https:\/\/linear\.app\/[^/]+\/issue\//;
|
|
9
|
+
// A GitHub ticket's ref (board/domain.mjs refOf).
|
|
10
|
+
const ISSUE = /^#\d+$/;
|
|
9
11
|
const prUrl = (repo, n) => `https://github.com/${repo}/pull/${n}`;
|
|
10
12
|
|
|
11
|
-
// What a renderer needs to link text shown on item `key` (null for none): each ref
|
|
12
|
-
// prefix of each Linear team the board's cards link to (none on a
|
|
13
|
-
// a
|
|
14
|
-
//
|
|
15
|
-
// wave's
|
|
13
|
+
// What a renderer needs to link text shown on item `key` (null for none): each ref, and each card id that differs
|
|
14
|
+
// from its ref, to its card's key, the issue url prefix of each Linear team the board's cards link to (none on a
|
|
15
|
+
// board without a workspace), that item's repo for a bare #n that is no card's ref, and `base`, which a page other
|
|
16
|
+
// than the board sets to './' so a card link opens the board. A ref shaped like a Linear id, such as INT-<n>, or a
|
|
17
|
+
// ticket #n links from anywhere; a wave's own short id, such as B1, links only from that wave's cards, since a plan
|
|
18
|
+
// for another wave reuses those names.
|
|
16
19
|
function linkContext(items = [], key = null, { base = '' } = {}) {
|
|
17
20
|
const wave = key && !key.startsWith('ask:') ? key.slice(0, key.indexOf('/')) : null;
|
|
18
21
|
const byRef = new Map();
|
|
19
22
|
for (const i of items) {
|
|
20
|
-
if (i.kind === 'ask'
|
|
21
|
-
|
|
23
|
+
if (i.kind === 'ask') continue;
|
|
24
|
+
for (const ref of new Set([i.ref, i.id])) {
|
|
25
|
+
if (!ref || (!LINEAR_ID.test(ref) && !ISSUE.test(ref) && i.wave !== wave)) continue;
|
|
26
|
+
byRef.set(ref, [...(byRef.get(ref) || []), i]);
|
|
27
|
+
}
|
|
22
28
|
}
|
|
23
29
|
const refs = Object.create(null);
|
|
24
30
|
for (const [ref, list] of byRef) {
|
|
@@ -62,11 +68,13 @@
|
|
|
62
68
|
return anyLink(url, url) + esc(raw.slice(url.length));
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
const cardLink = (ref, ctx) => `<a href="${esc(`${ctx.base || ''}#item=${encodeURIComponent(record(ctx.refs, ref))}`)}">${esc(ref)}</a>`;
|
|
72
|
+
|
|
65
73
|
// The longest hyphen-prefix of a word that is a known ref or a Linear id, then the rest as text.
|
|
66
74
|
function wordLink(word, ctx) {
|
|
67
75
|
for (let w = word; w; w = w.includes('-') ? w.slice(0, w.lastIndexOf('-')) : '') {
|
|
68
76
|
const rest = esc(word.slice(w.length));
|
|
69
|
-
if (record(ctx.refs, w)) return
|
|
77
|
+
if (record(ctx.refs, w)) return cardLink(w, ctx) + rest;
|
|
70
78
|
const team = LINEAR_ID.exec(w)?.[1];
|
|
71
79
|
const prefix = team && team !== 'INT' && record(ctx.teams, team);
|
|
72
80
|
if (prefix) return out(prefix + w, w) + rest;
|
|
@@ -90,7 +98,7 @@
|
|
|
90
98
|
else if (name) {
|
|
91
99
|
const repo = owner ? `${owner}/${name}` : record(ctx.repos, name);
|
|
92
100
|
html += repo ? out(prUrl(repo, n), all) : esc(all);
|
|
93
|
-
} else if (bare) html += ctx.repo && !colour(bare) ? out(prUrl(ctx.repo, bare), all) : esc(all);
|
|
101
|
+
} else if (bare) html += record(ctx.refs, all) ? cardLink(all, ctx) : ctx.repo && !colour(bare) ? out(prUrl(ctx.repo, bare), all) : esc(all);
|
|
94
102
|
else html += wordLink(word, ctx);
|
|
95
103
|
}
|
|
96
104
|
return html + esc(s.slice(at));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The board's order of its items, each lane together, which board/domain.mjs sorts the board by and the page sorts a
|
|
2
|
+
// tap it draws at once by, so the card lands where the server will put it
|
|
3
|
+
// (docs/adr/0140-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, sortAt, order and key.
|
|
4
|
+
// Needs nothing else: board/domain.mjs runs this file in a vm context.
|
|
5
|
+
(function () {
|
|
6
|
+
const LANE_ORDER = { review: 0, stale: 1, progress: 2, todo: 3, done: 4, asks: 5 };
|
|
7
|
+
// New cards lead Todo, oldest first, so the top one is the next taken (docs/adr/0047-the-plus-makes-a-new-card.md);
|
|
8
|
+
// then bumped cards, the latest bump on top (docs/adr/0019-a-card-that-hasnt-started-can-be-bumped.md); then the rest
|
|
9
|
+
// newest first by sortAt, a card with no time last, the last added to its wave file on top.
|
|
10
|
+
function laneOrder(a, b) {
|
|
11
|
+
const bumped = (i) => i.bumpedAt || '';
|
|
12
|
+
const unsorted = (i) => (i.isNew ? 0 : 1);
|
|
13
|
+
const at = (i) => String(i.sortAt || '');
|
|
14
|
+
const age = a.isNew ? at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
|
|
15
|
+
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || unsorted(a) - unsorted(b) || bumped(b).localeCompare(bumped(a)) || age || b.order - a.order || a.key.localeCompare(b.key);
|
|
16
|
+
}
|
|
17
|
+
Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
|
|
18
|
+
})();
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
const ARROW = icon(24, '<path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/>');
|
|
7
7
|
const REFRESH = icon(18, '<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>');
|
|
8
8
|
const GEAR = icon(18, '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>');
|
|
9
|
+
const BULB = icon(18, '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>');
|
|
9
10
|
const id = (v) => (v ? ` id="${esc(v)}"` : '');
|
|
10
11
|
|
|
11
12
|
// Where the back arrow goes (docs/adr/0048-a-questions-takes-show-inline.md): 'back', one step back in history, when
|
|
@@ -41,10 +42,10 @@
|
|
|
41
42
|
// lead is 'logo' (the kickflip, which toggles the sidebar, sidebar.js; logoHref, the hub's page, without it)
|
|
42
43
|
// or 'back' (the arrow, which follows backStep, with backHref the work item or the board, beside a phone-only logo). The controls (time and refresh, gear) paint disabled with refresh spinning until the
|
|
43
44
|
// page's script turns them on (docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md). With controls
|
|
44
|
-
// false, trail (HTML) takes their place. leadAttrs adds to or replaces the lead link's attributes, and level is the
|
|
45
|
+
// false, trail (HTML) takes their place. bulb puts the architecture review's bulb between them (app-arch.js). leadAttrs adds to or replaces the lead link's attributes, and level is the
|
|
45
46
|
// title's heading level, for a header that isn't the page's own, such as the sidebar's (ui-sidebar.js).
|
|
46
47
|
function header({ lead = 'logo', title = '', titleId, backHref = './', backLabel = 'Back to the board', backId, logoSrc = 'kickflip.png',
|
|
47
|
-
controls = true, trail = '', time = '…', settingsHref = 'settings.html', logoHref = '/_hub/', leadAttrs = {}, level = 1 } = {}) {
|
|
48
|
+
controls = true, trail = '', time = '…', bulb = false, settingsHref = 'settings.html', logoHref = '/_hub/', leadAttrs = {}, level = 1 } = {}) {
|
|
48
49
|
const attrs = (base) => Object.entries({ ...base, ...leadAttrs }).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
|
|
49
50
|
const logo = (cls = 'hub') => `<a class="${cls}" href="${esc(logoHref)}"${attrs({ 'aria-label': 'Boards' })}><img src="${esc(logoSrc)}" alt=""></a>`;
|
|
50
51
|
const first = lead === 'back'
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
: logo();
|
|
53
54
|
const nav = controls ? `<nav class="nav">`
|
|
54
55
|
+ `<span class="refresh"><button class="ghost spinning" id="refresh" type="button" disabled aria-label="Refresh" title="Refresh">${REFRESH}</button><span class="muted" id="status">${esc(time)}</span></span>`
|
|
56
|
+
+ (bulb ? `<button class="ghost icon arch-bulb" id="archBulb" type="button" aria-label="Architecture review" title="Architecture review" aria-haspopup="dialog">${BULB}</button>` : '')
|
|
55
57
|
+ (settingsHref ? `<a href="${esc(settingsHref)}" id="settingsLink" class="ghost icon" aria-label="Settings" title="Settings" aria-disabled="true" tabindex="-1" aria-haspopup="dialog">${GEAR}</a>` : '')
|
|
56
58
|
+ `</nav>` : trail;
|
|
57
59
|
const script = lead === 'back' ? `<script>(${wireBack})(${backStep});</script>` : '';
|