@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
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// The board view's header and GitHub status: the time since the last change signal or refresh, refresh, the In Review
|
|
2
|
-
// header's GitHub line (docs/adr/
|
|
3
|
-
// the GitHub mark's Hub links sheet and a new board's setup checklist (docs/adr/
|
|
4
|
-
//
|
|
2
|
+
// header's GitHub line (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md);
|
|
3
|
+
// the GitHub mark's Hub links sheet and a new board's setup checklist (docs/adr/2026-10-29-1639-one-board-registry-for-every-hub-feature.md);
|
|
4
|
+
// and the wake: any tap on an asleep board's page, refresh included, asks the hub to resume its session
|
|
5
|
+
// (docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md). Needs app-core.js.
|
|
5
6
|
BoardView.part((B) => {
|
|
6
7
|
const { $, state, toast } = B;
|
|
7
|
-
const { statusLine, freshSig, nextFresh, githubLine, linksList, setupCard, checkLanded, githubRefresh, headerControls } = BoardUI;
|
|
8
|
+
const { statusLine, freshSig, nextFresh, githubLine, remoteLine, linksList, setupCard, checkLanded, githubRefresh, headerControls } = BoardUI;
|
|
8
9
|
// A request no check answers stops showing after this.
|
|
9
10
|
const GITHUB_GIVE_UP = 5 * 60000;
|
|
10
11
|
|
|
@@ -15,14 +16,22 @@ BoardView.part((B) => {
|
|
|
15
16
|
$('#setup').innerHTML = setupCard(d.setup);
|
|
16
17
|
if ($('#linksSheet').open) $('#linksList').innerHTML = linksList(d.links);
|
|
17
18
|
}
|
|
18
|
-
|
|
19
|
+
// The status line, and the moon right of the title while the owner reads away (hub/presence.mjs ownerTracker).
|
|
20
|
+
const renderStatus = () => {
|
|
21
|
+
$('#status').innerHTML = statusLine(state.fresh?.at, { link: state.link, watcher: state.board?.checks?.watcher, pause: B.pause?.(), waking: state.waking });
|
|
22
|
+
const moon = $('.away-moon');
|
|
23
|
+
const away = Boolean(B.away?.());
|
|
24
|
+
moon.classList.toggle('on', away);
|
|
25
|
+
moon.setAttribute('aria-hidden', String(!away));
|
|
26
|
+
};
|
|
19
27
|
// A board load and its open threads landed; via is ui-header.js nextFresh's.
|
|
20
28
|
function landed(via) {
|
|
21
29
|
state.fresh = nextFresh(state.fresh, { via, at: new Date().toISOString(), sig: freshSig(state.board, state.threadCache, state.openItems) });
|
|
22
30
|
renderStatus();
|
|
23
31
|
}
|
|
24
32
|
B.every(15000, () => { if (!document.hidden) renderStatus(); });
|
|
25
|
-
const githubHeader = (d) => `<span class="lane-github">${
|
|
33
|
+
const githubHeader = (d) => (d.remote ? `<span class="lane-github">${remoteLine(d.remote, { links: d.links })}</span>`
|
|
34
|
+
: `<span class="lane-github">${githubLine(d.checks, { pending: Boolean(state.githubPending), links: d.links })}${githubRefresh(d.checks, state.githubPending)}</span>`);
|
|
26
35
|
|
|
27
36
|
// The Hub links sheet: the GitHub mark opens it, from inside the lane's summary, so the tap must not fold the lane.
|
|
28
37
|
const linksSheet = $('#linksSheet');
|
|
@@ -47,7 +56,7 @@ BoardView.part((B) => {
|
|
|
47
56
|
function askGitHub() {
|
|
48
57
|
state.githubPending = Date.now();
|
|
49
58
|
B.render();
|
|
50
|
-
B.
|
|
59
|
+
B.send('check-github', {}).catch(() => { state.githubPending = null; B.render(); toast('Could not ask Claude to check GitHub'); });
|
|
51
60
|
}
|
|
52
61
|
B.on(B.root, 'click', (ev) => {
|
|
53
62
|
const b = ev.target.closest('[data-check-github]');
|
|
@@ -56,6 +65,22 @@ BoardView.part((B) => {
|
|
|
56
65
|
if (state.link === 'online' && state.board && !state.githubPending) askGitHub();
|
|
57
66
|
});
|
|
58
67
|
|
|
68
|
+
// The wake: the header reads waking… from the tap until the board's reload says the hub took it up.
|
|
69
|
+
state.waking = false;
|
|
70
|
+
async function wake() {
|
|
71
|
+
state.waking = true;
|
|
72
|
+
renderStatus();
|
|
73
|
+
try {
|
|
74
|
+
const r = await fetch('/_hub/wake', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ route: B.route }) });
|
|
75
|
+
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `the hub answered ${r.status}`);
|
|
76
|
+
await B.loadBoard();
|
|
77
|
+
} catch (e) { toast(`Couldn't wake this board's session: ${e.message}`); }
|
|
78
|
+
finally { state.waking = false; if (B.alive()) renderStatus(); }
|
|
79
|
+
}
|
|
80
|
+
const wakeOnTap = () => { if (state.board?.checks?.watcher === 'asleep' && !state.waking && state.link === 'online') wake(); };
|
|
81
|
+
B.on(B.root, 'click', wakeOnTap, true);
|
|
82
|
+
B.on(B.root, 'submit', wakeOnTap, true);
|
|
83
|
+
|
|
59
84
|
state.link = 'online';
|
|
60
85
|
const controls = { wired: false, boardSettled: false, reloading: false };
|
|
61
86
|
function applyControls() {
|
|
@@ -82,7 +107,7 @@ BoardView.part((B) => {
|
|
|
82
107
|
B.on($('.nav'), 'click', (ev) => {
|
|
83
108
|
if (ev.target.closest('a[aria-disabled="true"]')) { ev.preventDefault(); ev.stopImmediatePropagation(); }
|
|
84
109
|
}, true);
|
|
85
|
-
// A load can return in milliseconds, so the spin and "
|
|
110
|
+
// A load can return in milliseconds, so the spin and "reconnecting…" hold long enough to read.
|
|
86
111
|
const RELOAD_MIN = 700;
|
|
87
112
|
async function reloading(load) {
|
|
88
113
|
controls.reloading = true;
|
|
@@ -1,18 +1,59 @@
|
|
|
1
|
-
// The board view's lanes and
|
|
1
|
+
// The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Todo's Queued section, Done's and Asks' Show more,
|
|
2
|
+
// Done's finished-wave folds, and Todo's wave bands with their Implement, triage and card order.
|
|
2
3
|
// Needs app-core.js and app-cards.js.
|
|
3
4
|
BoardView.part((B) => {
|
|
4
5
|
const { $, store, state } = B;
|
|
5
|
-
const { esc } = BoardUI;
|
|
6
|
+
const { esc, waitOn } = BoardUI;
|
|
6
7
|
const LANES = [
|
|
7
8
|
{ id: 'review', name: 'In Review', open: true },
|
|
8
9
|
{ id: 'stale', name: 'Stale', open: true, hideEmpty: true },
|
|
9
10
|
{ id: 'progress', name: 'In Progress', open: true },
|
|
10
11
|
{ id: 'todo', name: 'Todo', open: true },
|
|
11
|
-
{ id: 'done', name: 'Done', open: false },
|
|
12
|
+
{ id: 'done', name: 'Done', open: false, capped: true },
|
|
13
|
+
{ id: 'asks', name: 'Asks', open: false, capped: true },
|
|
12
14
|
];
|
|
15
|
+
// docs/adr/2026-11-03-0259-answered-asks-have-their-own-section-below-done.md
|
|
16
|
+
// A capped lane shows STEP cards, and each Show more tap adds STEP more, kept in memory per lane. A card the owner has
|
|
17
|
+
// open stays shown past the cap. Show more says how many are left (docs/adr/2026-10-29-1639-lists-never-cut-silently.md).
|
|
18
|
+
// older is how many cards wait past `cards` in the archive; the tap that would show past `cards` fetches them first.
|
|
19
|
+
const STEP = 10;
|
|
20
|
+
const moreTaps = new Map();
|
|
21
|
+
function cappedRows(laneId, cards, older = 0) {
|
|
22
|
+
const lastOpen = cards.findLastIndex((it) => state.openItems.has(it.key));
|
|
23
|
+
const shown = Math.max(STEP * (1 + (moreTaps.get(laneId) || 0)), lastOpen + 1);
|
|
24
|
+
const left = Math.max(0, cards.length - shown);
|
|
25
|
+
const n = left + (older || 0);
|
|
26
|
+
const words = `${n} older card${n === 1 ? '' : 's'}`;
|
|
27
|
+
const fetches = older !== 0 && shown + STEP > cards.length;
|
|
28
|
+
return cards.slice(0, shown).map((it) => B.card(it)).join('')
|
|
29
|
+
+ (n > 0 ? `<button type="button" class="thread-more" data-lane-more="${laneId}"${fetches ? ' data-fetch' : ''}>Show more <span class="n">· ${words}</span></button>` : '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// docs/adr/2026-11-04-0307-the-board-keeps-the-newest-100-done-cards.md
|
|
33
|
+
// Done shows the board's Done cards, then those paged in from the archive (GET /api/done), 100 a page, newest first.
|
|
34
|
+
// The board says how many the archive holds (doneArchived), so Done's count is the true total before any page is fetched.
|
|
35
|
+
const { archived } = state;
|
|
36
|
+
const olderThanBoard = (d) => Math.max(0, d.doneArchived - archived.cards.filter((c) => !d.items.some((i) => i.key === c.key)).length);
|
|
37
|
+
function doneRows(d, open) {
|
|
38
|
+
const onBoard = new Set(d.items.map((i) => i.key));
|
|
39
|
+
const cards = [...open, ...archived.cards.filter((c) => !onBoard.has(c.key))].sort((a, b) => String(b.sortAt || '').localeCompare(String(a.sortAt || '')));
|
|
40
|
+
return cappedRows('done', cards, olderThanBoard(d));
|
|
41
|
+
}
|
|
42
|
+
function doneCount(d, items) {
|
|
43
|
+
const onBoard = new Set(d.items.map((i) => i.key));
|
|
44
|
+
const older = olderThanBoard(d);
|
|
45
|
+
return `${items.length + archived.cards.filter((c) => !onBoard.has(c.key)).length + older}`;
|
|
46
|
+
}
|
|
47
|
+
async function fetchOlder() {
|
|
48
|
+
const last = archived.cards.at(-1);
|
|
49
|
+
const r = await fetch(B.site.url(`api/done${last ? `?before=${encodeURIComponent(`${last.sortAt}|${last.key}`)}` : ''}`), { cache: 'no-store' });
|
|
50
|
+
if (!r.ok) throw new Error(`done ${r.status}`);
|
|
51
|
+
const page = await r.json();
|
|
52
|
+
archived.cards.push(...page.cards);
|
|
53
|
+
}
|
|
13
54
|
|
|
14
55
|
// A fold's open state lives in storage, not in setHTML's summary-text match, because its row's count changes.
|
|
15
|
-
// Fold ids are
|
|
56
|
+
// Fold ids are wave:<slug> (board/domain.mjs waveFold), so any id maps to its own storage key.
|
|
16
57
|
const FOLDS = new Proxy({}, { get: (_, id) => ({ store: `foldOpen:${String(id)}` }) });
|
|
17
58
|
// A fold's header reads like a lane's: its name, then its count, then an optional note.
|
|
18
59
|
function fold(id, list, name, note = '') {
|
|
@@ -33,39 +74,116 @@ BoardView.part((B) => {
|
|
|
33
74
|
}).join('');
|
|
34
75
|
}
|
|
35
76
|
|
|
36
|
-
//
|
|
77
|
+
// docs/adr/2026-11-03-1731-each-board-reviews-incoming-prs-that-touch-its-work.md: the Auto Review lane leads the lanes. The PRs
|
|
78
|
+
// the owner has open are kept in memory, as cards are.
|
|
79
|
+
const openReviews = new Set();
|
|
80
|
+
const reviewsHtml = (d) => BoardUI.reviewLane(d.reviews, {
|
|
81
|
+
open: { lane: store.get('lanes', {}).reviews, earlier: store.get(FOLDS['reviews-earlier'].store, false), cards: openReviews } });
|
|
82
|
+
// Opens PR number's review and its lane or fold, for a Needs you line or a push's #review=<number>.
|
|
83
|
+
function openReview(number) {
|
|
84
|
+
openReviews.add(number);
|
|
85
|
+
const s = store.get('lanes', {}); s.reviews = true; store.set('lanes', s);
|
|
86
|
+
if (state.board?.reviews?.earlier.some((c) => c.number === number)) store.set(FOLDS['reviews-earlier'].store, true);
|
|
87
|
+
B.render();
|
|
88
|
+
$(`[data-review="${number}"]`)?.scrollIntoView({ block: 'start' });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// shown is the board's items less pending deletes.
|
|
37
92
|
function lanesHtml(d, shown) {
|
|
38
93
|
const laneState = store.get('lanes', {});
|
|
39
|
-
return LANES.map((lane) => {
|
|
94
|
+
return reviewsHtml(d) + LANES.map((lane) => {
|
|
95
|
+
// Items arrive in each lane's fixed order (domain.mjs laneOrder).
|
|
40
96
|
const items = shown.filter((i) => i.lane === lane.id);
|
|
41
97
|
if (lane.hideEmpty && !items.length) return '';
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return `<details class="lane" data-lane="${lane.id}" ${open ? 'open' : ''}>
|
|
48
|
-
<summary><span class="lane-name">${lane.name}</span><span class="count">${items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
|
|
98
|
+
const expanded = laneState[lane.id] ?? lane.open;
|
|
99
|
+
const open = items.filter((i) => !i.fold);
|
|
100
|
+
const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : open.map((it) => B.card(it)).join('')) + waveFolds(d, items);
|
|
101
|
+
return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
|
|
102
|
+
<summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
|
|
49
103
|
<div class="list">${rows || '<div class="empty">Nothing here.</div>'}
|
|
50
104
|
${lane.id === 'todo' ? BoardUI.planButton(items) : ''}</div>
|
|
51
105
|
</details>`;
|
|
52
106
|
}).join('');
|
|
53
107
|
}
|
|
54
108
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
109
|
+
// docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md
|
|
110
|
+
// Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under Not in a wave. A band's open state lives
|
|
111
|
+
// in storage once the owner taps its head; until then only the first band whose next card is ready (a wave file's:
|
|
112
|
+
// any Todo card) starts open. A band's name shows its goal as a tooltip, or a popover on a long-press. menu is the
|
|
113
|
+
// card whose ⋯ menu is open, goalShown the band whose goal popover is open.
|
|
114
|
+
let menu = null;
|
|
115
|
+
let goalShown = null;
|
|
116
|
+
const triaging = new Set();
|
|
117
|
+
const triageTopic = (r) => `${r.kind}:${r.slug}`;
|
|
118
|
+
function triageLink(r) {
|
|
119
|
+
if (r.triage.running || triaging.has(triageTopic(r))) return '<span class="wv-busy" role="status"><span class="try-spin" aria-hidden="true"></span>triaging…</span>';
|
|
120
|
+
if (r.triage.questions) return `<span class="muted">Triage: ${B.plural(r.triage.questions, 'open question')}</span>`;
|
|
121
|
+
return `<button type="button" class="wv-link" data-triage="${esc(triageTopic(r))}">Triage</button>`;
|
|
122
|
+
}
|
|
123
|
+
const bandStore = (slug) => FOLDS[`band:${slug}`].store;
|
|
124
|
+
const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
|
|
125
|
+
const LANE_WORDS = { progress: 'in progress', review: 'in review', stale: 'stale' };
|
|
126
|
+
const DOTS = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>';
|
|
127
|
+
|
|
128
|
+
// docs/adr/2026-11-04-0150-queued-sits-at-the-top-of-todo.md
|
|
129
|
+
// Todo's Queued section, which holds the cards waiting for an implementer slot, the next to build on top, as they
|
|
130
|
+
// arrive (domain.mjs laneOrder), then its wave bands and loose cards.
|
|
131
|
+
function todoRows(d, shown, all) {
|
|
132
|
+
const queued = all.filter((i) => i.queued);
|
|
133
|
+
const head = queued.length ? `<div class="wb-loose lane-queue"><div class="wb-head wb-loosehead"><span class="lane-name">Queued</span><span class="count">${queued.length}</span></div>${queued.map((it) => B.card(it)).join('')}</div>` : '';
|
|
134
|
+
return head + bandRows(d, shown, all.filter((i) => !i.queued));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function bandRows(d, shown, todo) {
|
|
138
|
+
const bands = d.waveBands || [];
|
|
139
|
+
const byKey = new Map(shown.filter((i) => !i.queued).map((i) => [i.key, i]));
|
|
140
|
+
const banded = new Set(bands.flatMap((b) => b.keys));
|
|
141
|
+
const loose = todo.filter((i) => !banded.has(i.key));
|
|
142
|
+
const first = bands.find((b) => (b.next ? b.next.ready : b.keys.some((k) => byKey.get(k)?.ready)))?.slug;
|
|
143
|
+
if (!bands.length) return loose.map((it) => B.card(it)).join('');
|
|
144
|
+
return bands.map((b) => bandHtml(b, b.keys.map((k) => byKey.get(k)).filter(Boolean), bandOpen(b, first))).join('')
|
|
145
|
+
+ (loose.length ? `<div class="wb-loose"><div class="wb-head wb-loosehead"><span class="lane-name">Not in a wave</span><span class="count">${loose.length}</span></div>${loose.map((it) => B.card(it)).join('')}</div>` : '');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function bandHtml(band, cards, open) {
|
|
149
|
+
const todo = cards.filter((i) => i.lane === 'todo');
|
|
150
|
+
const done = cards.filter((i) => i.lane === 'done');
|
|
151
|
+
const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${i.queued ? 'queued' : LANE_WORDS[i.lane]}`);
|
|
152
|
+
const progress = [`${band.merged} of ${band.total} merged`, ...now].join(' · ');
|
|
153
|
+
const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${esc(band.title)}</span>`;
|
|
154
|
+
const notes = band.kind === 'lineup' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes ${BoardUI.LINK_ARROW}</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
|
|
155
|
+
const next = band.next;
|
|
156
|
+
const go = !next ? '' : next.ready
|
|
157
|
+
? `<button type="button" class="wv-link" data-confirm="card-build" data-key="${esc(next.key)}">Implement</button>`
|
|
158
|
+
: `<span class="wb-next">next: ${esc(next.ref)} ${esc(next.word)}</span>`;
|
|
159
|
+
const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
|
|
160
|
+
const head = `<div class="wb-head" data-band-toggle="${esc(band.slug)}" role="button" tabindex="0" aria-expanded="${open}">${BoardUI.WAVE_ICON}`
|
|
161
|
+
+ `<span class="wb-label">${name} <span class="count">${todo.length}</span> <span class="count">· ${progress}</span></span>`
|
|
162
|
+
+ `<span class="wb-end"><span class="wb-links">${notes}${triageLink(band)}</span>${go}</span>${pop}</div>`;
|
|
163
|
+
if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
|
|
164
|
+
const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#item=${encodeURIComponent(i.key)}">${esc(i.ref)}</a>`).join(', ')}</div>` : '';
|
|
165
|
+
const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
|
|
166
|
+
const actions = (it, at) => (band.kind !== 'lineup' ? null : cardMenu(band.slug, it.key, at, todo.length));
|
|
167
|
+
return `<section class="wb" data-band="${esc(band.slug)}" data-open>${head}<div class="wb-body">${doneRow}${todo.map((it, at) => B.card(it, { step: step(it), actions: actions(it, at) })).join('')}</div></section>`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A lineup card's ⋯ and, while open, its menu: Move up and Move down among the band's Todo cards. A move waits for
|
|
171
|
+
// the server (board/server.mjs lineup-move): the tapped move waits and the other is disabled meanwhile.
|
|
172
|
+
function cardMenu(slug, key, at, count) {
|
|
173
|
+
const shown = menu === key;
|
|
174
|
+
const item = (by, label) => {
|
|
175
|
+
const off = by < 0 ? at === 0 : at === count - 1;
|
|
176
|
+
return `<button type="button" role="menuitem" data-band-move="${by}" data-band="${esc(slug)}" data-key="${esc(key)}"${off ? ' disabled' : ''}>${label}</button>`;
|
|
177
|
+
};
|
|
178
|
+
return `<div class="ask-actions"><button type="button" class="ghost icon wb-dots" data-band-menu="${esc(key)}" aria-label="Card menu" aria-haspopup="menu" aria-expanded="${shown}">${DOTS}</button>`
|
|
179
|
+
+ (shown ? `<div class="wb-menu" role="menu">${item(-1, '↑ Move up')}${item(1, '↓ Move down')}</div>` : '') + '</div>';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Opens the band holding card key or named slug, for a #item= link or a Needs you line.
|
|
183
|
+
function openBand({ key, slug }) {
|
|
184
|
+
const band = (state.board?.waveBands || []).find((b) => b.slug === slug || b.keys.includes(key));
|
|
185
|
+
if (band) store.set(bandStore(band.slug), true);
|
|
186
|
+
return band;
|
|
69
187
|
}
|
|
70
188
|
|
|
71
189
|
B.on(B.root, 'toggle', (ev) => {
|
|
@@ -73,20 +191,106 @@ BoardView.part((B) => {
|
|
|
73
191
|
if (el.matches?.('details.lane')) {
|
|
74
192
|
const s = store.get('lanes', {}); s[el.dataset.lane] = el.open; store.set('lanes', s);
|
|
75
193
|
} else if (el.matches?.('details.fold')) store.set(FOLDS[el.dataset.fold].store, el.open);
|
|
194
|
+
else if (el.matches?.('details.rv-card, details.rv-line')) {
|
|
195
|
+
if (el.open) openReviews.add(Number(el.dataset.review)); else openReviews.delete(Number(el.dataset.review));
|
|
196
|
+
}
|
|
76
197
|
}, true);
|
|
77
198
|
|
|
78
199
|
B.on(B.root, 'click', async (ev) => {
|
|
200
|
+
const answer = ev.target.closest('button[data-review-answer]');
|
|
201
|
+
if (answer) {
|
|
202
|
+
// The tapped answer waits, and the review's other answer is disabled, until the server records it.
|
|
203
|
+
const pr = Number(answer.dataset.review);
|
|
204
|
+
try { await waitOn(answer, B.send('review-answer', { pr, answer: answer.dataset.reviewAnswer }), { group: answer.parentNode.querySelectorAll('button') }); }
|
|
205
|
+
catch { B.notice('Could not send. Is the host awake?'); }
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const review = ev.target.closest('a[data-review-open]');
|
|
209
|
+
if (review) { ev.preventDefault(); openReview(Number(review.dataset.reviewOpen)); return; }
|
|
210
|
+
const more = ev.target.closest('[data-lane-more]');
|
|
211
|
+
if (more) {
|
|
212
|
+
if (more.hasAttribute('data-fetch')) {
|
|
213
|
+
try { await waitOn(more, fetchOlder()); } catch { B.toast('Could not load older cards. Is the host awake?'); return; }
|
|
214
|
+
if (!B.alive()) return;
|
|
215
|
+
}
|
|
216
|
+
moreTaps.set(more.dataset.laneMore, (moreTaps.get(more.dataset.laneMore) || 0) + 1);
|
|
217
|
+
B.render();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
79
220
|
const pb = ev.target.closest('button[data-plan]');
|
|
80
221
|
if (pb) {
|
|
81
|
-
if (!(await B.confirm('Claude
|
|
82
|
-
pb.disabled = true;
|
|
83
|
-
|
|
84
|
-
|
|
222
|
+
if (!(await B.confirm('Claude triages these Todo cards into one or more waves and asks you about them in Needs you. Nothing is implemented until you answer. Start?'))) return;
|
|
223
|
+
try { await waitOn(pb, B.send('plan-waves', { keys: pb.dataset.plan.split(' ') })); pb.textContent = 'Planning… its questions appear in Needs you'; pb.disabled = true; }
|
|
224
|
+
catch { B.notice('Could not send. Is the host awake?'); }
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const tb = ev.target.closest('button[data-triage]');
|
|
228
|
+
if (tb) {
|
|
229
|
+
const topic = tb.dataset.triage;
|
|
230
|
+
const row = state.board.waveBands.find((r) => triageTopic(r) === topic);
|
|
231
|
+
const asks = row.kind === 'lineup'
|
|
232
|
+
? `Claude reads the code for ${row.title}'s unstarted cards and asks you in Needs you about splitting, merging, adding or closing any. Start?`
|
|
233
|
+
: `Claude checks ${row.title}'s unfinished cards, fixes what it can and asks you in Needs you about any that need you. Start?`;
|
|
234
|
+
if (!(await B.confirm(asks))) return;
|
|
235
|
+
triaging.add(topic);
|
|
236
|
+
B.render();
|
|
237
|
+
try { await B.send('wave-triage', { [row.kind]: row.slug }); }
|
|
238
|
+
catch { B.notice('Could not send. Is the host awake?'); }
|
|
239
|
+
finally { triaging.delete(topic); B.render(); }
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const show = ev.target.closest('a[data-band-show]');
|
|
243
|
+
if (show) {
|
|
244
|
+
ev.preventDefault();
|
|
245
|
+
if (!openBand({ slug: show.dataset.bandShow })) return;
|
|
246
|
+
const s = store.get('lanes', {}); s.todo = true; store.set('lanes', s);
|
|
247
|
+
B.render();
|
|
248
|
+
$(`.wb[data-band="${CSS.escape(show.dataset.bandShow)}"]`)?.scrollIntoView({ block: 'start' });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// A band's ⋯ menu and its moves, its head's fold and its name's goal popover. A long-press on the name (touch only;
|
|
253
|
+
// a mouse has the tooltip) opens the popover, and the click that ends the press neither folds the band nor closes it.
|
|
254
|
+
let press = null;
|
|
255
|
+
let pressed = false;
|
|
256
|
+
const endPress = () => { press?.(); press = null; };
|
|
257
|
+
B.on(B.root, 'pointerdown', (ev) => {
|
|
258
|
+
pressed = false;
|
|
259
|
+
const name = ev.target.closest?.('[data-goal]');
|
|
260
|
+
if (!name || ev.pointerType === 'mouse') return;
|
|
261
|
+
endPress();
|
|
262
|
+
press = B.later(450, () => { press = null; pressed = true; goalShown = name.dataset.goal; B.render(); });
|
|
263
|
+
});
|
|
264
|
+
for (const type of ['pointerup', 'pointercancel']) B.on(B.root, type, endPress);
|
|
265
|
+
B.on(B.root, 'contextmenu', (ev) => { if (ev.target.closest?.('[data-goal]')) ev.preventDefault(); });
|
|
266
|
+
B.on(B.root, 'click', async (ev) => {
|
|
267
|
+
if (pressed) { pressed = false; ev.preventDefault(); return; }
|
|
268
|
+
if (goalShown) { goalShown = null; B.render(); if (ev.target.closest('.wb-head')) return; }
|
|
269
|
+
const dots = ev.target.closest('button[data-band-menu]');
|
|
270
|
+
if (dots) { ev.preventDefault(); menu = menu === dots.dataset.bandMenu ? null : dots.dataset.bandMenu; B.render(); return; }
|
|
271
|
+
const mv = ev.target.closest('button[data-band-move]');
|
|
272
|
+
if (mv) {
|
|
273
|
+
ev.preventDefault();
|
|
274
|
+
if (mv.disabled) return;
|
|
275
|
+
const { band, key } = mv.dataset;
|
|
276
|
+
try { await waitOn(mv, B.send('lineup-move', { lineup: band, key, by: Number(mv.dataset.bandMove) }), { group: mv.parentNode.querySelectorAll('button') }); }
|
|
277
|
+
catch { B.toast('Could not move it. Is the host awake?'); }
|
|
278
|
+
menu = null;
|
|
279
|
+
B.render();
|
|
85
280
|
return;
|
|
86
281
|
}
|
|
87
|
-
|
|
88
|
-
|
|
282
|
+
if (menu && !ev.target.closest('.wb-menu')) { menu = null; B.render(); if (ev.target.closest('summary')) { ev.preventDefault(); return; } }
|
|
283
|
+
const head = ev.target.closest('[data-band-toggle]');
|
|
284
|
+
if (head && !ev.target.closest('a, button')) {
|
|
285
|
+
const slug = head.dataset.bandToggle;
|
|
286
|
+
store.set(bandStore(slug), head.getAttribute('aria-expanded') !== 'true');
|
|
287
|
+
B.render();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
B.on(B.root, 'keydown', (ev) => {
|
|
291
|
+
const head = ev.target.closest?.('[data-band-toggle]');
|
|
292
|
+
if (head && ev.target === head && (ev.key === 'Enter' || ev.key === ' ')) { ev.preventDefault(); head.click(); }
|
|
89
293
|
});
|
|
90
294
|
|
|
91
|
-
Object.assign(B, { FOLDS, lanesHtml,
|
|
295
|
+
Object.assign(B, { FOLDS, lanesHtml, openReview, openBand });
|
|
92
296
|
});
|
|
@@ -23,6 +23,8 @@ BoardView.part((B) => {
|
|
|
23
23
|
w.innerHTML = needs.length ? `<h2 style="margin:0 0 6px">Needs you · ${needs.length}</h2>
|
|
24
24
|
<ul>${needs.map((line) => (line.prompt ? promptItem(line.prompt, prompts.state(line.prompt))
|
|
25
25
|
: line.key ? `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
|
|
26
|
+
: line.review ? `<li><a href="${esc(line.url)}" data-review-open="${line.review}">${esc(line.title)}</a> <span class="muted small">· review held for you</span></li>`
|
|
27
|
+
: line.band ? `<li><a href="#" data-band-show="${esc(line.band)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
|
|
26
28
|
: `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// The last visit's board on the board view: reading and saving it, holding the controls that send until the fresh
|
|
2
|
-
// board lands,
|
|
2
|
+
// board lands, flashing the lines the fresh board changed (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md), and
|
|
3
|
+
// the sign on a card that changed lanes or was updated (docs/adr/2026-11-02-2354-an-updated-card-takes-a-brief-tint.md).
|
|
3
4
|
// Needs app-core.js and ui-snapshot.js; app.js paints it.
|
|
4
5
|
BoardView.part((B) => {
|
|
5
6
|
const { $$, state } = B;
|
|
6
|
-
const { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, SNAPSHOT_MAX } = BoardUI;
|
|
7
|
+
const { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, updateSign, SNAPSHOT_MAX } = BoardUI;
|
|
7
8
|
// The hub serves every site from one origin, so each board keeps its own key.
|
|
8
9
|
const KEY = 'boardSnapshot:' + B.site.url('');
|
|
9
10
|
// Every board's snapshots together stay within three at ui-snapshot.js's cap, the boards saved least recently
|
|
@@ -11,11 +12,12 @@ BoardView.part((B) => {
|
|
|
11
12
|
// otherwise fail to save.
|
|
12
13
|
const BUDGET = 3 * SNAPSHOT_MAX;
|
|
13
14
|
const ORDER = 'boardSnapshots';
|
|
15
|
+
const BUILD = document.documentElement.dataset.build;
|
|
14
16
|
|
|
15
17
|
function readSnapshot() {
|
|
16
18
|
let raw = null;
|
|
17
19
|
try { raw = localStorage.getItem(KEY); } catch {}
|
|
18
|
-
return raw ? unpackSnapshot(raw) : null;
|
|
20
|
+
return raw ? unpackSnapshot(raw, BUILD) : null;
|
|
19
21
|
}
|
|
20
22
|
// Makes room for this board's raw snapshot.
|
|
21
23
|
function keepRecent(raw) {
|
|
@@ -38,6 +40,7 @@ BoardView.part((B) => {
|
|
|
38
40
|
saveTimer = null;
|
|
39
41
|
if (state.view !== 'fresh' || !state.board) return;
|
|
40
42
|
const raw = packSnapshot(state.board, state.threadCache, {
|
|
43
|
+
build: BUILD,
|
|
41
44
|
savedAt: state.board.generatedAt || new Date().toISOString(),
|
|
42
45
|
keep: [...state.openItems, ...preloadKeys(state.board.items, [])],
|
|
43
46
|
});
|
|
@@ -66,13 +69,9 @@ BoardView.part((B) => {
|
|
|
66
69
|
}, true);
|
|
67
70
|
}
|
|
68
71
|
|
|
69
|
-
// The lines a fresh board can change: each
|
|
72
|
+
// The lines a fresh board can change: each lane count, Needs you line and thread message. A card shows its own sign below.
|
|
70
73
|
function lines() {
|
|
71
74
|
const out = new Map();
|
|
72
|
-
for (const wrap of $$('details.item[data-key] > summary .title-wrap')) {
|
|
73
|
-
const key = wrap.closest('[data-key]').dataset.key;
|
|
74
|
-
[...wrap.children].forEach((el, i) => out.set(`${key}|${i}`, el));
|
|
75
|
-
}
|
|
76
75
|
for (const count of $$('.lane[data-lane] > summary .count')) out.set(`lane:${count.closest('.lane').dataset.lane}`, count);
|
|
77
76
|
$$('#waiting li').forEach((li, i) => out.set(`needs:${i}`, li));
|
|
78
77
|
for (const msgs of $$('[data-thread] > .msgs')) [...msgs.children].forEach((m, i) => out.set(`thread:${msgs.parentElement.dataset.thread}|${i}`, m));
|
|
@@ -86,12 +85,47 @@ BoardView.part((B) => {
|
|
|
86
85
|
const els = lines();
|
|
87
86
|
const after = texts(els);
|
|
88
87
|
for (const id of changedLines(before, after)) {
|
|
89
|
-
|
|
90
|
-
(el.closest('details.item') || el).classList.add('flash');
|
|
88
|
+
els.get(id).classList.add('flash');
|
|
91
89
|
before[id] = after[id];
|
|
92
90
|
}
|
|
93
91
|
}
|
|
94
92
|
const endFlash = () => { before = null; };
|
|
95
93
|
|
|
96
|
-
|
|
94
|
+
// Since the page last drew it, a last visit's board included, a card that changed lanes, or a work card that takes
|
|
95
|
+
// the place of a drawn New task (its fromAsk), flashes once; any other card whose updateSign changed takes a brief
|
|
96
|
+
// tint on its title row. Neither shows for a change the owner sent from this page: sentHere(key, send) holds the
|
|
97
|
+
// card's next change unsigned until the first board load started after send settles.
|
|
98
|
+
let drawn = new Map();
|
|
99
|
+
const ownChanges = new Map();
|
|
100
|
+
async function sentHere(key, send) {
|
|
101
|
+
ownChanges.set(key, Infinity);
|
|
102
|
+
try { return await send(); } finally { ownChanges.set(key, B.boardLoad || 0); }
|
|
103
|
+
}
|
|
104
|
+
// Each sign's style.css animation length. A lane repaint resets a card's classes, so a sign still running is put back
|
|
105
|
+
// as far into its animation as it was.
|
|
106
|
+
const SIGNS = { flash: 1600, tint: 2400 };
|
|
107
|
+
const running = new Map();
|
|
108
|
+
function signCards(load = null) {
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
for (const it of state.board.items) {
|
|
111
|
+
const was = drawn.get(it.key);
|
|
112
|
+
const moved = was ? was.lane !== it.lane : Boolean(it.fromAsk) && drawn.has(`ask:${it.fromAsk}`);
|
|
113
|
+
const updated = Boolean(was) && was.sign !== updateSign(it);
|
|
114
|
+
const own = ownChanges.get(it.key);
|
|
115
|
+
if (own !== undefined) { if (moved || updated || (load !== null && load > own)) ownChanges.delete(it.key); continue; }
|
|
116
|
+
if ((moved || updated) && state.view === 'fresh') running.set(it.key, { sign: moved ? 'flash' : 'tint', at: now });
|
|
117
|
+
}
|
|
118
|
+
drawn = new Map(state.board.items.map((i) => [i.key, { lane: i.lane, sign: updateSign(i) }]));
|
|
119
|
+
for (const [key, { sign, at }] of running) {
|
|
120
|
+
const card = now - at < SIGNS[sign] && B.root.querySelector(`details.item[data-key="${CSS.escape(key)}"]`);
|
|
121
|
+
if (!card) { running.delete(key); continue; }
|
|
122
|
+
// Re-adding the class after a layout read restarts the animation, from the delay's point.
|
|
123
|
+
card.classList.remove('flash', 'tint');
|
|
124
|
+
card.style.setProperty('--sign-delay', `-${now - at}ms`);
|
|
125
|
+
void card.offsetWidth;
|
|
126
|
+
card.classList.add(sign);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
Object.assign(B, { readSnapshot, saveSnapshotSoon, setView, holdForFlash, flashChanged, endFlash, sentHere, signCards });
|
|
97
131
|
});
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
// A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
|
|
2
2
|
BoardView.part((B) => {
|
|
3
3
|
const { $, store, state, toast, quieted, sendDraft, links } = B;
|
|
4
|
-
const { esc, setHTML, threadMessages, attachButton, protoButton, protoNotice, doneButton } = BoardUI;
|
|
4
|
+
const { esc, setHTML, threadMessages, attachButton, dropZone, protoButton, protoNotice, doneButton, waitOn } = BoardUI;
|
|
5
5
|
|
|
6
6
|
const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
|
|
7
7
|
|
|
8
8
|
// The board, not the thread, decides the Done button, so it follows each board load even when the thread is unchanged.
|
|
9
|
-
const sendingDone = new Set();
|
|
10
9
|
function syncDone(el, key) {
|
|
11
10
|
const send = el.querySelector('form button[type=submit]');
|
|
12
11
|
if (!send) return;
|
|
13
|
-
const html = doneButton(state.board?.items.find((i) => i.key === key)
|
|
12
|
+
const html = doneButton(state.board?.items.find((i) => i.key === key));
|
|
14
13
|
const current = el.querySelector('form [data-confirm="finish"]');
|
|
15
14
|
if (current?.outerHTML === html) return;
|
|
16
15
|
current?.remove();
|
|
@@ -18,10 +17,13 @@ BoardView.part((B) => {
|
|
|
18
17
|
}
|
|
19
18
|
|
|
20
19
|
// Each open card's ui-threads.js threadMessages view. Opening a card marks it seen (app-core.js markSeen) before its
|
|
21
|
-
// thread draws, so its unread messages start from its seen mark as the page loaded or as the card last closed.
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
const
|
|
20
|
+
// thread draws, so its unread messages start from its seen mark as the page loaded or as the card last closed. A
|
|
21
|
+
// board left and opened again in this page keeps them, so its open threads show what they showed.
|
|
22
|
+
const kept = B.place?.threads;
|
|
23
|
+
const seenBefore = new Map(kept?.seenBefore || Object.entries(B.seen));
|
|
24
|
+
const moreTaps = new Map(kept?.moreTaps);
|
|
25
|
+
const firstDrawn = new Map(kept?.firstDrawn);
|
|
26
|
+
B.keep(() => ({ threads: { seenBefore: [...seenBefore], moreTaps: [...moreTaps], firstDrawn: [...firstDrawn] } }));
|
|
25
27
|
const newestTs = (msgs) => msgs.reduce((top, m) => [String(m.ts), m.reply?.at ? String(m.reply.at) : ''].reduce((a, b) => (b > a ? b : a), top), '');
|
|
26
28
|
const viewOf = (key) => ({ seenAt: seenBefore.get(key) || null, more: moreTaps.get(key) || 0, upto: firstDrawn.get(key) || null });
|
|
27
29
|
B.on(B.root, 'toggle', (ev) => {
|
|
@@ -52,13 +54,14 @@ BoardView.part((B) => {
|
|
|
52
54
|
// The reply form is built once per card, so a refresh never drops what is being typed.
|
|
53
55
|
if (!el.querySelector('.msgs')) {
|
|
54
56
|
const draft = store.get('draft:' + key, '');
|
|
55
|
-
el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}"
|
|
57
|
+
el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="Reply in this thread…">${esc(draft)}</textarea>`)}
|
|
56
58
|
<div class="pending-files" data-files></div>
|
|
57
59
|
<div class="row">${attachButton()}${protoButton(key)}<button class="chip quick" type="button" data-quick="What's left?">What's left?</button><button class="primary" type="submit">Send</button></div></form>`;
|
|
58
60
|
BoardAttach.paint('item:' + key, B.site.root);
|
|
59
61
|
}
|
|
60
62
|
syncDone(el, key);
|
|
61
63
|
B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(msgs, links(key), edits, view)));
|
|
64
|
+
for (const id of edits.keys()) BoardAttach.paint('edit:' + id, B.site.root);
|
|
62
65
|
B.flashChanged?.(el);
|
|
63
66
|
// A thread's request is answered by a later thread line, so only a pending one changes the eye.
|
|
64
67
|
const req = msgs.filter((m) => m.kind === 'prototype-request').pop();
|
|
@@ -103,10 +106,8 @@ BoardView.part((B) => {
|
|
|
103
106
|
const b = ev.target.closest('form[data-reply] button[data-quick]');
|
|
104
107
|
if (!b) return;
|
|
105
108
|
const key = b.closest('form').dataset.reply;
|
|
106
|
-
b.
|
|
107
|
-
try { await B.site.send('item-message', { key, text: b.dataset.quick }); loadThread(key, { force: true }); }
|
|
109
|
+
try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick }))); loadThread(key, { force: true }); }
|
|
108
110
|
catch { toast('Could not send'); }
|
|
109
|
-
b.disabled = false;
|
|
110
111
|
});
|
|
111
112
|
B.on(B.root, 'click', async (ev) => {
|
|
112
113
|
const b = ev.target.closest('form[data-reply] button[data-proto]');
|
|
@@ -119,17 +120,17 @@ BoardView.part((B) => {
|
|
|
119
120
|
const n = protoNotice(pending, latest);
|
|
120
121
|
if (!n.bump) { B.notice(n.text); return; }
|
|
121
122
|
if (!(await B.confirm(n.text))) return;
|
|
122
|
-
|
|
123
|
+
await B.bumpAsk(pending, b);
|
|
123
124
|
return;
|
|
124
125
|
}
|
|
125
126
|
if (!(await B.confirm('Ask Claude for prototypes of this item?'))) return;
|
|
126
|
-
b.disabled = true;
|
|
127
127
|
try {
|
|
128
|
-
await B.
|
|
128
|
+
await waitOn(b, B.sentHere(key, () => B.send('prototype-request', { key })));
|
|
129
|
+
b.disabled = true;
|
|
129
130
|
b.title = b.ariaLabel = 'Prototypes asked for';
|
|
130
131
|
toast('Asked Claude for prototypes');
|
|
131
132
|
loadThread(key, { force: true });
|
|
132
|
-
} catch {
|
|
133
|
+
} catch { toast('Could not send'); }
|
|
133
134
|
});
|
|
134
135
|
B.on(B.root, 'submit', async (ev) => {
|
|
135
136
|
const f = ev.target.closest('form[data-reply]');
|
|
@@ -142,14 +143,12 @@ BoardView.part((B) => {
|
|
|
142
143
|
const files = BoardAttach.files(id, B.site.root);
|
|
143
144
|
if ((!text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
|
|
144
145
|
const key = f.dataset.reply;
|
|
145
|
-
const btn = f.querySelector('button[type=submit]'); btn.disabled = true;
|
|
146
146
|
try {
|
|
147
|
-
await sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
|
|
148
|
-
() => B.
|
|
147
|
+
await waitOn(f.querySelector('button[type=submit]'), sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
|
|
148
|
+
() => B.sentHere(key, () => B.send('item-message', { key, text, ...(files.length ? { files } : {}) }))));
|
|
149
149
|
BoardAttach.clear(id, B.site.root);
|
|
150
|
-
btn.disabled = false;
|
|
151
150
|
loadThread(f.dataset.reply, { force: true });
|
|
152
|
-
} catch { B.notice('Could not send. Is the host awake?');
|
|
151
|
+
} catch { B.notice('Could not send. Is the host awake?'); }
|
|
153
152
|
});
|
|
154
153
|
// Send waits while a composer's files upload, the ask's included (attach.js attach-change).
|
|
155
154
|
B.on(B.root, 'attach-change', (ev) => {
|
|
@@ -158,7 +157,7 @@ BoardView.part((B) => {
|
|
|
158
157
|
});
|
|
159
158
|
|
|
160
159
|
Object.assign(B, {
|
|
161
|
-
thread, renderThread, loadThread,
|
|
160
|
+
thread, renderThread, loadThread,
|
|
162
161
|
repaintThread: (key) => { if (state.threadCache.has(key)) renderThread(key, state.threadCache.get(key)); },
|
|
163
162
|
syncDone: (key) => { const el = $(`[data-thread="${CSS.escape(key)}"]`); if (el) syncDone(el, key); },
|
|
164
163
|
});
|