@lovek4d/kickflip 0.4.0 → 0.5.3
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 +69 -48
- package/README.md +1 -1
- package/SKILL.md +22 -20
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/bin/board.mjs +108 -39
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -3
- package/board/headless.mjs +5 -3
- package/board/merge3.mjs +1 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -2
- package/board/public/app-asks.js +9 -92
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +70 -37
- package/board/public/app-core.js +24 -25
- package/board/public/app-header.js +2 -1
- package/board/public/app-lanes.js +55 -23
- package/board/public/app-needs.js +129 -4
- package/board/public/app-reader.js +174 -0
- package/board/public/app-snapshot.js +6 -5
- package/board/public/app-threads.js +83 -58
- package/board/public/app.html +2 -0
- package/board/public/app.js +66 -24
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +4 -2
- package/board/public/hub.css +1 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +100 -0
- package/board/public/prototype-kit.js +28 -3
- package/board/public/prototype-switcher.js +42 -107
- package/board/public/shell.js +7 -1
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +44 -5
- package/board/public/style.css +194 -35
- package/board/public/ui-cards.js +80 -44
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +2 -2
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-phase-bars.js +4 -4
- package/board/public/ui-questions.js +63 -47
- package/board/public/ui-sidebar.js +27 -15
- package/board/public/ui-threads.js +27 -14
- package/board/public/ui.js +76 -14
- package/board/public/view-frame.js +1 -0
- package/board/public/view-hub-settings.js +11 -10
- package/board/public/view-hub.js +17 -3
- package/board/public/view-questions.js +174 -202
- package/board/public/view-settings.js +208 -70
- package/board/server.mjs +19 -18
- package/board/store.mjs +20 -13
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +24 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +20 -0
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -14
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +2 -1
- package/shared/audit.mjs +4 -0
- package/shared/fake-gh.mjs +17 -4
- package/shared/owner-check.mjs +3 -0
- package/shared/service.mjs +4 -4
- package/shared/tailscale.mjs +1 -0
- package/shared/usage-gate.mjs +1 -1
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
// The board view's Needs you
|
|
2
|
-
//
|
|
1
|
+
// The board view's Needs you (domain.mjs needsOf), led by the board session's pending prompts, answered in place
|
|
2
|
+
// (prompts.js). It draws twice, and the stylesheet shows one by width
|
|
3
|
+
// (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md):
|
|
4
|
+
// - on a phone, #needsDeck at the top of the board: one thing at a time, a question's options drafting its answer on the tap
|
|
5
|
+
// (the draft Send on the questions page sends) and a prompt's buttons answering it, Skip to the next, a dot for each thing waiting, and All open questions at its foot;
|
|
6
|
+
// - from 900px, #waiting, one line each, in the pane beside the lanes, over Waiting on you mounted there
|
|
7
|
+
// (view-questions.js, embedded) while the width lasts. Waiting on you shows the questions there, so #waiting leaves out
|
|
8
|
+
// a line that only asks questions, and with nothing else left the orange section is gone. A link into those
|
|
9
|
+
// questions, wherever it sits on the board, picks its topic's pill there instead of opening the page.
|
|
10
|
+
// Needs app-core.js.
|
|
3
11
|
BoardView.part((B) => {
|
|
4
12
|
const { $, plural, state, toast } = B;
|
|
5
|
-
const { esc, promptItem } = BoardUI;
|
|
13
|
+
const { esc, promptItem, choiceLabel, isPrototype, getDraft, setDraft } = BoardUI;
|
|
6
14
|
|
|
7
15
|
const prompts = PromptAnswers.wire(B, {
|
|
8
16
|
list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
|
|
@@ -11,13 +19,21 @@ BoardView.part((B) => {
|
|
|
11
19
|
after: () => B.loadBoard(),
|
|
12
20
|
});
|
|
13
21
|
|
|
22
|
+
// From 900px, the pane beside the lanes, the same width the stylesheet splits the board at.
|
|
23
|
+
const wide = matchMedia('(min-width: 900px)');
|
|
24
|
+
|
|
14
25
|
function renderNeeds(d) {
|
|
26
|
+
const needs = (d.needs || []).filter((line) => !line.prompt || prompts.shows(line.prompt));
|
|
27
|
+
renderList(wide.matches ? needs.filter((line) => !line.questions || line.ready || line.action) : needs);
|
|
28
|
+
renderDeck(d, needs);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderList(needs) {
|
|
15
32
|
const parts = (line) => [
|
|
16
33
|
line.questions ? `<a href="${esc(line.questions.url)}">${plural(line.questions.open, 'question')}</a>` : '',
|
|
17
34
|
line.ready ? `<a href="${esc(line.ready)}" target="_blank" rel="noopener">Ready on GitHub</a>` : '',
|
|
18
35
|
line.action ? `<a href="${esc(line.action.url)}">${esc(line.action.label)}</a>` : '',
|
|
19
36
|
].filter(Boolean).join(' · ');
|
|
20
|
-
const needs = (d.needs || []).filter((line) => !line.prompt || prompts.shows(line.prompt));
|
|
21
37
|
const w = $('#waiting');
|
|
22
38
|
w.hidden = needs.length === 0;
|
|
23
39
|
w.innerHTML = needs.length ? `<h2 style="margin:0 0 6px">Needs you · ${needs.length}</h2>
|
|
@@ -28,5 +44,114 @@ BoardView.part((B) => {
|
|
|
28
44
|
: `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
|
|
29
45
|
}
|
|
30
46
|
|
|
47
|
+
// The deck's things, in Needs you order: a prompt; each open question of a line that asks some, in its topic's
|
|
48
|
+
// order; any other line as itself. → [{id, what, html}]
|
|
49
|
+
function deckOf(d, needs) {
|
|
50
|
+
const items = d.items || [];
|
|
51
|
+
const owner = (d.owner || []).filter((q) => q.state === 'open');
|
|
52
|
+
const topicOf = (line) => line.topic || items.find((it) => it.key === line.key)?.questions?.topic || null;
|
|
53
|
+
return needs.flatMap((line) => {
|
|
54
|
+
if (line.prompt) return [{ id: `p:${line.prompt.id}`, what: 'Claude asks', html: `<ul class="deck-prompt">${promptItem(line.prompt, prompts.state(line.prompt))}</ul>` }];
|
|
55
|
+
const what = line.key ? line.title.split(':')[0] : line.title;
|
|
56
|
+
const topic = line.questions && topicOf(line);
|
|
57
|
+
const order = (d.ownerTopics || []).find((t) => t.id === topic)?.questions || [];
|
|
58
|
+
const asks = topic ? owner.filter((q) => q.topic === topic).sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)) : [];
|
|
59
|
+
if (asks.length) return asks.map((q) => ({ id: `q:${q.id}`, what, html: questionHtml(q) }));
|
|
60
|
+
const [label, href, attrs] = line.questions ? [`Answer ${plural(line.questions.open, 'question')}`, line.questions.url, '']
|
|
61
|
+
: line.ready ? ['Ready on GitHub', line.ready, ' target="_blank" rel="noopener"']
|
|
62
|
+
: line.action ? [line.action.label, line.action.url, '']
|
|
63
|
+
: line.review ? ['Open the review', line.url, ` data-review-open="${line.review}"`]
|
|
64
|
+
: ['Open', line.url, ''];
|
|
65
|
+
const title = line.key ? line.title.slice(what.length + 1).trim() : line.title;
|
|
66
|
+
return [{ id: `l:${line.key || line.review || line.band || line.url}`, what,
|
|
67
|
+
html: `<h3>${line.key ? `<a href="${esc(line.url)}">${esc(title)}</a>` : esc(title)}</h3>${line.review ? '<p class="ctx">review held for you</p>' : ''}<div class="deck-acts"><a class="button primary" href="${esc(href)}"${attrs}>${esc(label)}</a></div>` }];
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const questionHtml = (q) => {
|
|
71
|
+
const picked = getDraft(q.id).choice;
|
|
72
|
+
return `<h3>${esc(q.question)}</h3>${q.context ? `<p class="ctx">${esc(q.context)}</p>` : ''}
|
|
73
|
+
${isPrototype(q.url) ? `<a class="button deck-proto" href="${esc(q.url)}">View prototypes</a>` : ''}
|
|
74
|
+
<div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map((o) => `<button type="button" class="${o.recommended ? 'rec' : ''}" aria-pressed="${o.id === picked}" data-deck-pick="${esc(o.id)}"><span class="lbl">${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}</span>${o.detail ? `<small>${esc(o.detail)}</small>` : ''}</button>`).join('')}</div>`;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// The thing on screen, by id, so an arrival or a repaint keeps it; once it is gone the deck shows what took its place.
|
|
78
|
+
let showing = null;
|
|
79
|
+
let deck = [];
|
|
80
|
+
function renderDeck(d, needs) {
|
|
81
|
+
const el = $('#needsDeck');
|
|
82
|
+
const before = deck.findIndex((x) => x.id === showing);
|
|
83
|
+
deck = deckOf(d, needs);
|
|
84
|
+
el.hidden = deck.length === 0;
|
|
85
|
+
if (!deck.length) { el.innerHTML = ''; showing = null; return; }
|
|
86
|
+
const now = deck.find((x) => x.id === showing) || deck[Math.min(Math.max(before, 0), deck.length - 1)];
|
|
87
|
+
showing = now.id;
|
|
88
|
+
const open = (d.owner || []).filter((q) => q.state === 'open').length;
|
|
89
|
+
const drafted = (d.owner || []).filter((q) => q.state === 'open' && 'choice' in getDraft(q.id)).length;
|
|
90
|
+
BoardUI.setHTML(el, `<div class="deck-head"><h2>Needs you</h2><span class="deck-n">${deck.length}</span></div>
|
|
91
|
+
<div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${esc(now.what)}</p>${now.html}</div>
|
|
92
|
+
${deck.length > 1 ? `<div class="deck-bar"><span class="deck-dots" aria-hidden="true">${deck.map((x) => `<i${x === now ? ' class="on"' : ''}></i>`).join('')}</span><button type="button" class="ghost" data-deck-skip>Skip →</button></div>` : ''}
|
|
93
|
+
${open ? `<a class="deck-more" href="questions.html">All open questions · ${open}${drafted ? ` · ${drafted} drafted, Send there` : ''}</a>` : ''}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
B.on(B.root, 'click', (ev) => {
|
|
97
|
+
if (!ev.target.closest?.('#needsDeck')) return;
|
|
98
|
+
if (ev.target.closest('[data-deck-skip]')) {
|
|
99
|
+
const at = deck.findIndex((x) => x.id === showing);
|
|
100
|
+
showing = deck[(at + 1) % deck.length].id;
|
|
101
|
+
renderNeeds(state.board);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const opt = ev.target.closest('button[data-deck-pick]');
|
|
105
|
+
if (!opt) return;
|
|
106
|
+
const id = opt.closest('[data-deck]').dataset.deck.slice(2);
|
|
107
|
+
setDraft(id, { ...getDraft(id), choice: opt.dataset.deckPick });
|
|
108
|
+
const at = deck.findIndex((x) => x.id === showing);
|
|
109
|
+
showing = deck[(at + 1) % deck.length].id;
|
|
110
|
+
renderNeeds(state.board);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// From 900px, Waiting on you in the pane under the Needs you lines, mounted while the width lasts.
|
|
114
|
+
let questions = null;
|
|
115
|
+
function fitPane() {
|
|
116
|
+
if (wide.matches === Boolean(questions)) return;
|
|
117
|
+
if (state.board) renderNeeds(state.board);
|
|
118
|
+
if (questions) { questions.unmount(); questions.slot.remove(); questions = null; return; }
|
|
119
|
+
$('#needsPane').insertAdjacentHTML('beforeend', `<div class="view-questions needs-questions">${AppViews.questions.markup({ embedded: true })}</div>`);
|
|
120
|
+
const slot = $('#needsPane .needs-questions');
|
|
121
|
+
questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true }), { slot });
|
|
122
|
+
questions.mount();
|
|
123
|
+
}
|
|
124
|
+
// From 900px, a link into open questions for a topic the pane holds picks that topic's pill there, rather than
|
|
125
|
+
// navigating to the page. It runs before the router's listener, which skips a prevented click.
|
|
126
|
+
B.on(B.root, 'click', (ev) => {
|
|
127
|
+
if (ev.defaultPrevented || ev.button || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey || !questions) return;
|
|
128
|
+
const a = ev.target.closest?.('a[href]');
|
|
129
|
+
if (!a || (a.target && a.target !== '_self')) return;
|
|
130
|
+
const home = new URL(`${B.site.root}/`, location.href);
|
|
131
|
+
const url = new URL(a.getAttribute('href'), home);
|
|
132
|
+
if (url.pathname !== new URL('questions.html', home).pathname || url.hash.length < 2) return;
|
|
133
|
+
const id = decodeURIComponent(url.hash.slice(1));
|
|
134
|
+
const topic = (state.board?.ownerTopics || []).find((t) => t.id === id || t.questions.includes(id));
|
|
135
|
+
const pill = topic && questions.slot.querySelector(`button[data-topic="${CSS.escape(topic.id)}"]`);
|
|
136
|
+
if (!pill) return;
|
|
137
|
+
ev.preventDefault();
|
|
138
|
+
pill.click();
|
|
139
|
+
const picked = questions.slot.querySelector(`button[data-topic="${CSS.escape(topic.id)}"]`);
|
|
140
|
+
picked.focus();
|
|
141
|
+
picked.scrollIntoView({ block: 'nearest' });
|
|
142
|
+
});
|
|
143
|
+
// A double-click on a pane pill focuses its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
|
|
144
|
+
// wave:<slug> a band; any other topic, or one the board doesn't show, does nothing.
|
|
145
|
+
B.on(B.root, 'dblclick', (ev) => {
|
|
146
|
+
const pill = questions && ev.target.closest?.('button[data-topic]');
|
|
147
|
+
if (!pill || !questions.slot.contains(pill)) return;
|
|
148
|
+
const id = pill.dataset.topic;
|
|
149
|
+
if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
|
|
150
|
+
else if (id.startsWith('wave:')) B.focusOnBoard({ slug: id.slice(5) });
|
|
151
|
+
});
|
|
152
|
+
fitPane();
|
|
153
|
+
B.on(wide, 'change', fitPane);
|
|
154
|
+
B.atUnmount(() => { questions?.unmount(); questions = null; });
|
|
155
|
+
|
|
31
156
|
Object.assign(B, { renderNeeds });
|
|
32
157
|
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "Open the log"
|
|
2
|
+
// (ui-threads.js) and a card's or a lineup's Notes (app-cards.js, app-lanes.js) open over the board in one sheet in
|
|
3
|
+
// place of log.html and notes.html, which still open on their own. It closes on its ✕, Escape, a tap on the dim area
|
|
4
|
+
// and Back, through a history entry of its own that carries the board's URL, so the router's pop keeps the board.
|
|
5
|
+
// A link in it closes it first, then opens through the shell.
|
|
6
|
+
// A log written as a stream (headless.mjs: one stream-json event per line) reads as lines, what Claude says and each
|
|
7
|
+
// tool call, with a raw toggle for the file as it is; any other log reads as its text. It is re-read every 3 seconds
|
|
8
|
+
// until the run's result arrives, and follows new lines while read at its end.
|
|
9
|
+
BoardView.part((B) => {
|
|
10
|
+
const { $, state } = B;
|
|
11
|
+
const { esc, markdown, linkContext } = BoardUI;
|
|
12
|
+
const sheet = $('#readerSheet');
|
|
13
|
+
const body = sheet.querySelector('.reader-body');
|
|
14
|
+
const tools = sheet.querySelector('.reader-tools');
|
|
15
|
+
const more = sheet.querySelector('.reader-new');
|
|
16
|
+
let opened = 0;
|
|
17
|
+
let pushed = false;
|
|
18
|
+
let pending = null;
|
|
19
|
+
let log = null;
|
|
20
|
+
let stopReading = null;
|
|
21
|
+
|
|
22
|
+
// → the log's lines, each a stream event or, for a line that isn't one, its text; null when no line is an event.
|
|
23
|
+
function streamOf(text) {
|
|
24
|
+
const lines = text.split('\n');
|
|
25
|
+
if (lines.at(-1) === '') lines.pop();
|
|
26
|
+
const read = (line) => { try { const e = JSON.parse(line); return typeof e?.type === 'string' ? e : null; } catch { return null; } };
|
|
27
|
+
const events = lines.map(read);
|
|
28
|
+
return events.some(Boolean) ? lines.map((line, i) => events[i] ?? line) : null;
|
|
29
|
+
}
|
|
30
|
+
const short = (v) => (v.length > 400 ? `${v.slice(0, 400)}…` : v);
|
|
31
|
+
const textOf = (content) => (typeof content === 'string' ? content : content.map((part) => part.text ?? '').join('\n'));
|
|
32
|
+
const callOf = (input) => input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.description ?? JSON.stringify(input);
|
|
33
|
+
// A stream line's readable rows {kind, label, body}. Thinking and events other than the start, messages and the
|
|
34
|
+
// result have none; the raw view shows them.
|
|
35
|
+
function rowsOf(e) {
|
|
36
|
+
if (typeof e === 'string') return [{ kind: 'out', body: e }];
|
|
37
|
+
if (e.type === 'system') return e.subtype === 'init' ? [{ kind: 'sys', label: 'start', body: `${e.model} in ${e.cwd}` }] : [];
|
|
38
|
+
if (e.type === 'result') return [{ kind: 'text end', label: e.is_error ? 'stopped' : 'done', body: e.result ?? e.subtype }];
|
|
39
|
+
if (e.type !== 'assistant' && e.type !== 'user') return [];
|
|
40
|
+
const content = e.message.content;
|
|
41
|
+
if (typeof content === 'string') return [{ kind: 'text', body: content }];
|
|
42
|
+
return content.flatMap((c) => (c.type === 'text' ? [{ kind: 'text', body: c.text }]
|
|
43
|
+
: c.type === 'tool_use' ? [{ kind: 'tool', label: c.name.toLowerCase(), body: callOf(c.input) }]
|
|
44
|
+
: c.type === 'tool_result' ? [{ kind: 'out', body: short(textOf(c.content)) }] : []));
|
|
45
|
+
}
|
|
46
|
+
const rowHtml = (r) => `<div class="reader-row ${r.kind}">${r.label ? `<span class="reader-tag">${esc(r.label)}</span>` : ''}${esc(r.body)}</div>`;
|
|
47
|
+
|
|
48
|
+
const atEnd = () => body.scrollHeight - body.scrollTop - body.clientHeight < 40;
|
|
49
|
+
const toEnd = () => { body.scrollTop = body.scrollHeight; };
|
|
50
|
+
function drawTools() {
|
|
51
|
+
if (!log?.stream) { tools.innerHTML = ''; return; }
|
|
52
|
+
const word = log.finished ? 'finished' : log.following ? 'following' : 'paused';
|
|
53
|
+
tools.innerHTML = `<span class="reader-status${word === 'following' ? ' on' : ''}">${word}</span><button type="button" data-reader="raw" aria-pressed="${log.raw}">raw</button>`;
|
|
54
|
+
}
|
|
55
|
+
function drawMore() {
|
|
56
|
+
more.hidden = !log?.unseen;
|
|
57
|
+
if (log?.unseen) more.textContent = `${log.unseen} new ${log.unseen === 1 ? 'line' : 'lines'} ↓`;
|
|
58
|
+
}
|
|
59
|
+
function drawLog() {
|
|
60
|
+
const { text, stream, raw } = log;
|
|
61
|
+
body.innerHTML = !text ? '<p class="muted reader-empty">The log is empty so far.</p>'
|
|
62
|
+
: !stream ? `<pre class="reader-text">${esc(text)}</pre>`
|
|
63
|
+
: raw ? `<pre class="reader-raw">${esc(text)}</pre>`
|
|
64
|
+
: stream.flatMap(rowsOf).map(rowHtml).join('');
|
|
65
|
+
drawTools();
|
|
66
|
+
drawMore();
|
|
67
|
+
}
|
|
68
|
+
function follow() {
|
|
69
|
+
log.following = true;
|
|
70
|
+
log.unseen = 0;
|
|
71
|
+
toEnd();
|
|
72
|
+
drawTools();
|
|
73
|
+
drawMore();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readLog(n) {
|
|
77
|
+
let answer;
|
|
78
|
+
try {
|
|
79
|
+
const r = await fetch(B.site.url(`api/log?file=${encodeURIComponent(log.file)}`), { cache: 'no-store' });
|
|
80
|
+
answer = { ok: r.ok, ...(await r.json()) };
|
|
81
|
+
} catch { answer = null; }
|
|
82
|
+
if (n !== opened) return;
|
|
83
|
+
if (answer && !answer.ok) { body.innerHTML = `<p class="muted">${esc(answer.error || 'Could not load this log.')}</p>`; return; }
|
|
84
|
+
if (answer && answer.size !== log.size) {
|
|
85
|
+
const before = log.stream?.length ?? 0;
|
|
86
|
+
const stream = streamOf(answer.text);
|
|
87
|
+
Object.assign(log, { size: answer.size, text: answer.text, stream, finished: Boolean(stream?.some((e) => e.type === 'result')) });
|
|
88
|
+
if (!log.following) log.unseen += Math.max(0, (stream?.length ?? 0) - before);
|
|
89
|
+
drawLog();
|
|
90
|
+
if (log.following) toEnd();
|
|
91
|
+
}
|
|
92
|
+
if (!log.finished) stopReading = B.later(3000, () => readLog(n));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function show(title, ref) {
|
|
96
|
+
opened++;
|
|
97
|
+
stopReading?.();
|
|
98
|
+
log = null;
|
|
99
|
+
sheet.querySelector('#readerTitle').textContent = title;
|
|
100
|
+
sheet.querySelector('.reader-ref').textContent = ref;
|
|
101
|
+
tools.innerHTML = '';
|
|
102
|
+
more.hidden = true;
|
|
103
|
+
body.innerHTML = '<p class="muted">Loading…</p>';
|
|
104
|
+
sheet.showModal();
|
|
105
|
+
history.pushState({ ...history.state, idx: history.state.idx + 1, reader: true }, '', location.href);
|
|
106
|
+
pushed = true;
|
|
107
|
+
return opened;
|
|
108
|
+
}
|
|
109
|
+
function openLog(file) {
|
|
110
|
+
const n = show(`${file.split('-')[0]} log`, file);
|
|
111
|
+
log = { file, size: -1, text: '', stream: null, raw: false, following: true, unseen: 0, finished: false };
|
|
112
|
+
readLog(n);
|
|
113
|
+
}
|
|
114
|
+
async function openNotes(search) {
|
|
115
|
+
const wave = new URLSearchParams(search).has('wave');
|
|
116
|
+
const n = show('notes', '');
|
|
117
|
+
let html;
|
|
118
|
+
try {
|
|
119
|
+
const r = await fetch(B.site.url(`api/notes${search}`), { cache: 'no-store' });
|
|
120
|
+
const answer = await r.json();
|
|
121
|
+
if (!r.ok) throw new Error(answer.error || `notes ${r.status}`);
|
|
122
|
+
if (n !== opened) return;
|
|
123
|
+
sheet.querySelector('.reader-ref').textContent = `${wave ? answer.title : answer.ref} notes`;
|
|
124
|
+
html = `<article class="plan reader-md">${markdown(answer.markdown, linkContext(state.board?.items || [], answer.key ?? null))}</article>`;
|
|
125
|
+
} catch (e) { html = `<p class="muted">Could not load these notes: ${esc(e.message)}</p>`; }
|
|
126
|
+
if (n === opened) body.innerHTML = html;
|
|
127
|
+
}
|
|
128
|
+
function close() {
|
|
129
|
+
if (!sheet.open) return;
|
|
130
|
+
sheet.close();
|
|
131
|
+
opened++;
|
|
132
|
+
stopReading?.();
|
|
133
|
+
if (pushed) { pushed = false; history.back(); }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const plainTap = (ev) => !ev.defaultPrevented && ev.button === 0 && !ev.metaKey && !ev.ctrlKey && !ev.shiftKey && !ev.altKey;
|
|
137
|
+
B.on(B.root, 'click', (ev) => {
|
|
138
|
+
if (!plainTap(ev)) return;
|
|
139
|
+
const a = ev.target.closest('a[href^="log.html?"], a[href^="notes.html?"]');
|
|
140
|
+
if (!a) return;
|
|
141
|
+
ev.preventDefault();
|
|
142
|
+
const href = a.getAttribute('href');
|
|
143
|
+
if (href.startsWith('log.html')) openLog(new URLSearchParams(href.slice(href.indexOf('?'))).get('file'));
|
|
144
|
+
else openNotes(href.slice(href.indexOf('?')));
|
|
145
|
+
});
|
|
146
|
+
B.on(sheet, 'click', (ev) => {
|
|
147
|
+
if (ev.target === sheet) { close(); return; }
|
|
148
|
+
const act = ev.target.closest('[data-reader]')?.dataset.reader;
|
|
149
|
+
if (act === 'close') close();
|
|
150
|
+
else if (act === 'raw') { log.raw = !log.raw; drawLog(); follow(); }
|
|
151
|
+
else if (act === 'end') follow();
|
|
152
|
+
const a = ev.target.closest('a[href]');
|
|
153
|
+
if (!a || !plainTap(ev) || (a.target && a.target !== '_self')) return;
|
|
154
|
+
ev.preventDefault();
|
|
155
|
+
pending = new URL(a.getAttribute('href'), location.href).href;
|
|
156
|
+
close();
|
|
157
|
+
});
|
|
158
|
+
B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); close(); });
|
|
159
|
+
B.on(body, 'scroll', () => {
|
|
160
|
+
if (!log?.stream) return;
|
|
161
|
+
log.following = atEnd();
|
|
162
|
+
if (log.following) log.unseen = 0;
|
|
163
|
+
drawTools();
|
|
164
|
+
drawMore();
|
|
165
|
+
});
|
|
166
|
+
// Back while it's open: the router has kept the board, as the entry under it holds the same URL.
|
|
167
|
+
B.on(window, 'popstate', () => {
|
|
168
|
+
if (pushed) { pushed = false; sheet.close(); opened++; stopReading?.(); }
|
|
169
|
+
if (!pending) return;
|
|
170
|
+
const href = pending;
|
|
171
|
+
pending = null;
|
|
172
|
+
if (!AppShell.open(href)) location.assign(href);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -8,8 +8,8 @@ BoardView.part((B) => {
|
|
|
8
8
|
// The hub serves every site from one origin, so each board keeps its own key.
|
|
9
9
|
const KEY = 'boardSnapshot:' + B.site.url('');
|
|
10
10
|
// Every board's snapshots together stay within three at ui-snapshot.js's cap, the boards saved least recently
|
|
11
|
-
// dropping first, so they leave room in the browser's 5 MB of storage for the drafts
|
|
12
|
-
//
|
|
11
|
+
// dropping first, so they leave room in the browser's 5 MB of storage for the drafts, which would otherwise fail to
|
|
12
|
+
// save.
|
|
13
13
|
const BUDGET = 3 * SNAPSHOT_MAX;
|
|
14
14
|
const ORDER = 'boardSnapshots';
|
|
15
15
|
const BUILD = document.documentElement.dataset.build;
|
|
@@ -91,8 +91,8 @@ BoardView.part((B) => {
|
|
|
91
91
|
}
|
|
92
92
|
const endFlash = () => { before = null; };
|
|
93
93
|
|
|
94
|
-
// Since the page last drew it, a last visit's board included, a card that changed lanes,
|
|
95
|
-
// the place of a drawn New task (its fromAsk), flashes once; any other card whose updateSign changed takes a brief
|
|
94
|
+
// Since the page last drew it, a last visit's board included, a card that changed lanes, a work card that takes
|
|
95
|
+
// the place of a drawn New task (its fromAsk), or a work card or New task that joins Todo at the bottom, flashes once; any other card whose updateSign changed takes a brief
|
|
96
96
|
// tint on its title row. Neither shows for a change the owner sent from this page: sentHere(key, send) holds the
|
|
97
97
|
// card's next change unsigned until the first board load started after send settles.
|
|
98
98
|
let drawn = new Map();
|
|
@@ -109,7 +109,8 @@ BoardView.part((B) => {
|
|
|
109
109
|
const now = Date.now();
|
|
110
110
|
for (const it of state.board.items) {
|
|
111
111
|
const was = drawn.get(it.key);
|
|
112
|
-
const
|
|
112
|
+
const arrived = !was && drawn.size > 0 && it.lane === 'todo' && (it.isNew || (it.kind === 'work' && !it.fromAsk));
|
|
113
|
+
const moved = was ? was.lane !== it.lane : arrived || (Boolean(it.fromAsk) && drawn.has(`ask:${it.fromAsk}`));
|
|
113
114
|
const updated = Boolean(was) && was.sign !== updateSign(it);
|
|
114
115
|
const own = ownChanges.get(it.key);
|
|
115
116
|
if (own !== undefined) { if (moved || updated || (load !== null && load > own)) ownChanges.delete(it.key); continue; }
|
|
@@ -1,35 +1,58 @@
|
|
|
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,
|
|
4
|
+
const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, boltButton, doneButton, waitOn, newQuestionsLine } = BoardUI;
|
|
5
|
+
|
|
6
|
+
const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more' };
|
|
7
|
+
// A /prototype or /grill word in a message, the last one winning, and the message without it.
|
|
8
|
+
const SLASH = /(^|\s)\/(grill|prototype)(?=\s|$)/g;
|
|
9
|
+
const slashOf = (value) => ({ flag: [...value.matchAll(SLASH)].pop()?.[2] ?? null, text: value.replace(SLASH, '$1').replace(/ {2,}/g, ' ').trim() });
|
|
5
10
|
|
|
6
11
|
const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
|
|
7
12
|
|
|
8
|
-
//
|
|
13
|
+
// docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md,
|
|
14
|
+
// docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md
|
|
15
|
+
// A Todo card's reply box sends with Reply (intent: 'reply') or Implement (intent: 'build'), which is the card's build
|
|
16
|
+
// action: builder orange once the card is ready, quiet before. Any other card's box sends with Send, carrying no intent.
|
|
17
|
+
// The pair wraps onto its own line under the row.
|
|
18
|
+
const sendsOf = (item) => (item?.lane !== 'todo' ? 'one' : item.ready ? 'todo-ready' : 'todo');
|
|
19
|
+
const sends = (kind) => (kind === 'one'
|
|
20
|
+
? `<span data-sends="one">${sendButton('Send', 0)}</span>`
|
|
21
|
+
: `<span class="send-actions" data-sends="${kind}">${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${sendButton('Reply', 0, ' data-intent="reply"')}</span>`);
|
|
22
|
+
// The board, not the thread, decides the Done button and the send buttons, so they follow each board load even when
|
|
23
|
+
// the thread is unchanged.
|
|
9
24
|
function syncDone(el, key) {
|
|
10
|
-
const
|
|
11
|
-
if (!
|
|
12
|
-
const
|
|
25
|
+
const at = el.querySelector('form [data-sends]');
|
|
26
|
+
if (!at) return;
|
|
27
|
+
const item = state.board?.items.find((i) => i.key === key);
|
|
28
|
+
const kind = sendsOf(item);
|
|
29
|
+
const todo = item?.lane === 'todo';
|
|
30
|
+
if (at.dataset.sends !== kind) { at.insertAdjacentHTML('afterend', sends(kind)); at.remove(); }
|
|
31
|
+
const tools = el.querySelector('form .tools');
|
|
32
|
+
const bolt = tools.querySelector('[data-bolt]');
|
|
33
|
+
if (todo && !bolt) tools.insertAdjacentHTML('afterbegin', boltButton(key));
|
|
34
|
+
else if (!todo) bolt?.remove();
|
|
35
|
+
const html = doneButton(item);
|
|
13
36
|
const current = el.querySelector('form [data-confirm="finish"]');
|
|
14
37
|
if (current?.outerHTML === html) return;
|
|
15
38
|
current?.remove();
|
|
16
|
-
if (html)
|
|
39
|
+
if (html) el.querySelector('form [data-sends]').insertAdjacentHTML('beforebegin', html);
|
|
17
40
|
}
|
|
18
41
|
|
|
19
42
|
// Each open card's ui-threads.js threadMessages view. Opening a card marks it seen (app-core.js markSeen) before its
|
|
20
|
-
// thread draws, so its unread messages start from its
|
|
21
|
-
//
|
|
43
|
+
// thread draws, so its unread messages start from its read mark from before it opened (B.seenBefore). A board left
|
|
44
|
+
// and opened again in this page keeps them, so its open threads show what they showed.
|
|
22
45
|
const kept = B.place?.threads;
|
|
23
|
-
const seenBefore = new Map(kept?.seenBefore || Object.entries(B.seen));
|
|
24
46
|
const moreTaps = new Map(kept?.moreTaps);
|
|
25
47
|
const firstDrawn = new Map(kept?.firstDrawn);
|
|
26
|
-
B.keep(() => ({ threads: {
|
|
48
|
+
B.keep(() => ({ threads: { moreTaps: [...moreTaps], firstDrawn: [...firstDrawn] } }));
|
|
49
|
+
const seenAt = (key) => (B.seenBefore.has(key) ? B.seenBefore.get(key) : B.seen[key] || null);
|
|
27
50
|
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), '');
|
|
28
|
-
const viewOf = (key) => ({ seenAt:
|
|
51
|
+
const viewOf = (key) => ({ seenAt: seenAt(key), more: moreTaps.get(key) || 0, upto: firstDrawn.get(key) || null });
|
|
29
52
|
B.on(B.root, 'toggle', (ev) => {
|
|
30
53
|
const el = ev.target;
|
|
31
54
|
if (!el.matches?.('details.item') || el.open) return;
|
|
32
|
-
seenBefore.
|
|
55
|
+
B.seenBefore.delete(el.dataset.key);
|
|
33
56
|
moreTaps.delete(el.dataset.key);
|
|
34
57
|
firstDrawn.delete(el.dataset.key);
|
|
35
58
|
}, true);
|
|
@@ -48,28 +71,30 @@ BoardView.part((B) => {
|
|
|
48
71
|
const edits = editsIn(key);
|
|
49
72
|
if (!firstDrawn.has(key) && msgs.length) firstDrawn.set(key, newestTs(msgs));
|
|
50
73
|
const view = viewOf(key);
|
|
51
|
-
const
|
|
74
|
+
const asked = newQuestionsLine(state.board?.items.find((i) => i.key === key)?.questions, seenAt(key));
|
|
75
|
+
const lines = asked.length ? [...msgs, ...asked].sort((a, b) => String(a.ts).localeCompare(String(b.ts))) : msgs;
|
|
76
|
+
const sig = JSON.stringify([lines, [...edits.keys()], view]);
|
|
52
77
|
if (el.dataset.sig === sig) return;
|
|
53
78
|
el.dataset.sig = sig;
|
|
54
79
|
// The reply form is built once per card, so a refresh never drops what is being typed.
|
|
55
80
|
if (!el.querySelector('.msgs')) {
|
|
56
81
|
const draft = store.get('draft:' + key, '');
|
|
57
|
-
el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="
|
|
82
|
+
el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="${esc(PLACEHOLDER)}">${esc(draft)}</textarea>`)}
|
|
58
83
|
<div class="pending-files" data-files></div>
|
|
59
|
-
<div class="row">${
|
|
84
|
+
<div class="row"><span class="tools">${protoButton(key)}${grillButton(key)}</span><button class="chip quick" type="button" data-quick="What's left?">What's left?</button>${sends('one')}</div></form>`;
|
|
60
85
|
BoardAttach.paint('item:' + key, B.site.root);
|
|
61
86
|
}
|
|
62
87
|
syncDone(el, key);
|
|
63
|
-
B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(
|
|
88
|
+
B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(lines, links(key), edits, view)));
|
|
64
89
|
for (const id of edits.keys()) BoardAttach.paint('edit:' + id, B.site.root);
|
|
65
90
|
B.flashChanged?.(el);
|
|
66
|
-
// A thread's request is answered by a later thread line, so only a pending one changes the eye.
|
|
67
|
-
const req = msgs.filter((m) => m.kind === 'prototype-request').pop();
|
|
68
|
-
const proto = req && (req.state === 'waiting' || req.state === 'working') ? { pending: req.eventId } : null;
|
|
69
|
-
const pb = el.querySelector('form [data-proto]');
|
|
70
|
-
if (pb && pb.dataset.protoPending !== proto?.pending) pb.outerHTML = protoButton(key, proto);
|
|
71
91
|
}
|
|
72
92
|
|
|
93
|
+
// docs/adr/2026-11-05-0430-the-eye-and-fire-send-their-request-at-once.md
|
|
94
|
+
// The composer's eye asks for a prototype and its fire for a grill: a tap sends the request at once, with whatever is
|
|
95
|
+
// typed, as /prototype or /grill in the text does on Reply. Nothing typed sends the bare request.
|
|
96
|
+
const PLACEHOLDER = 'Reply in this thread… (/prototype or /grill asks for one)';
|
|
97
|
+
|
|
73
98
|
// A load while one for the same thread runs shares it, so a card the page inserts open isn't fetched twice; force
|
|
74
99
|
// starts a new one, after a send. maxAge skips a thread that loaded that recently. from is a load already started
|
|
75
100
|
// (app.html BoardBoot). A response older than the one shown is dropped. A failed load keeps the previous render.
|
|
@@ -99,61 +124,61 @@ BoardView.part((B) => {
|
|
|
99
124
|
|
|
100
125
|
B.on(B.root, 'input', (ev) => {
|
|
101
126
|
const f = ev.target.closest('form[data-reply]');
|
|
102
|
-
if (!f || ev.target.tagName !== 'TEXTAREA'
|
|
127
|
+
if (!f || ev.target.tagName !== 'TEXTAREA') return;
|
|
128
|
+
if (quieted('draft:' + f.dataset.reply, ev.target)) return;
|
|
103
129
|
store.set('draft:' + f.dataset.reply, ev.target.value);
|
|
104
130
|
});
|
|
105
131
|
B.on(B.root, 'click', async (ev) => {
|
|
106
132
|
const b = ev.target.closest('form[data-reply] button[data-quick]');
|
|
107
133
|
if (!b) return;
|
|
108
134
|
const key = b.closest('form').dataset.reply;
|
|
109
|
-
|
|
110
|
-
|
|
135
|
+
// Spins until the thread shows the message (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
136
|
+
try { await waitOn(b, BoardUI.pending.within(B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick })).then(() => loadThread(key, { force: true })), B.stillWaiting)); }
|
|
137
|
+
catch (e) { B.failed(e); }
|
|
111
138
|
});
|
|
139
|
+
// docs/adr/2026-11-05-0010-a-lightning-button-in-a-todo-threads-footer-starts-a-quick-build.md
|
|
112
140
|
B.on(B.root, 'click', async (ev) => {
|
|
113
|
-
const b = ev.target.closest('form[data-reply] button[data-
|
|
141
|
+
const b = ev.target.closest('form[data-reply] button[data-bolt]');
|
|
114
142
|
if (!b) return;
|
|
115
|
-
const key = b.dataset.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
let latest = null;
|
|
119
|
-
try { latest = (await B.site.events({ kind: 'ask-bump', limit: 1 }))[0]?.data?.id ?? null; } catch {}
|
|
120
|
-
const n = protoNotice(pending, latest);
|
|
121
|
-
if (!n.bump) { B.notice(n.text); return; }
|
|
122
|
-
if (!(await B.confirm(n.text))) return;
|
|
123
|
-
await B.bumpAsk(pending, b);
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
if (!(await B.confirm('Ask Claude for prototypes of this item?'))) return;
|
|
127
|
-
try {
|
|
128
|
-
await waitOn(b, B.sentHere(key, () => B.send('prototype-request', { key })));
|
|
129
|
-
b.disabled = true;
|
|
130
|
-
b.title = b.ariaLabel = 'Prototypes asked for';
|
|
131
|
-
toast('Asked Claude for prototypes');
|
|
132
|
-
loadThread(key, { force: true });
|
|
133
|
-
} catch { toast('Could not send'); }
|
|
143
|
+
const key = b.dataset.bolt;
|
|
144
|
+
try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: 'Quick build', intent: 'build' }))); loadThread(key, { force: true }); }
|
|
145
|
+
catch { toast('Could not send'); }
|
|
134
146
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (!f) return;
|
|
138
|
-
ev.preventDefault();
|
|
147
|
+
// Sends the composer's draft as one message, or as a request when flag names one; button spins until the thread has it.
|
|
148
|
+
async function post(f, flag, button) {
|
|
139
149
|
const ta = f.querySelector('textarea');
|
|
140
150
|
ta.blur();
|
|
141
|
-
const text = ta.value.trim();
|
|
151
|
+
const text = flag ? slashOf(ta.value).text : ta.value.trim();
|
|
142
152
|
const id = f.dataset.composer;
|
|
143
153
|
const files = BoardAttach.files(id, B.site.root);
|
|
144
|
-
|
|
154
|
+
// An empty box still sends Implement, the card's build action.
|
|
155
|
+
const intent = flag ? null : button.dataset.intent || null;
|
|
156
|
+
if ((!flag && !text && !files.length && intent !== 'build') || BoardAttach.busy(id, B.site.root)) return;
|
|
145
157
|
const key = f.dataset.reply;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
158
|
+
const data = { key, ...(text || !flag ? { text } : {}), ...(files.length ? { files } : {}), ...(intent ? { intent } : {}) };
|
|
159
|
+
// Spins until the thread shows the message (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
160
|
+
const sending = (async () => {
|
|
161
|
+
await sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
|
|
162
|
+
() => B.sentHere(key, () => B.send(REQUEST_EVENT[flag] || 'item-message', data)));
|
|
149
163
|
BoardAttach.clear(id, B.site.root);
|
|
150
|
-
loadThread(
|
|
151
|
-
}
|
|
164
|
+
await loadThread(key, { force: true });
|
|
165
|
+
})();
|
|
166
|
+
try { await waitOn(button, BoardUI.pending.within(sending, B.stillWaiting)); } catch (e) { B.failed(e); }
|
|
167
|
+
}
|
|
168
|
+
B.on(B.root, 'click', (ev) => {
|
|
169
|
+
const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill]');
|
|
170
|
+
if (b) post(b.closest('form'), b.hasAttribute('data-proto') ? 'prototype' : 'grill', b);
|
|
171
|
+
});
|
|
172
|
+
B.on(B.root, 'submit', (ev) => {
|
|
173
|
+
const f = ev.target.closest('form[data-reply]');
|
|
174
|
+
if (!f) return;
|
|
175
|
+
ev.preventDefault();
|
|
176
|
+
post(f, slashOf(f.querySelector('textarea').value).flag, ev.submitter);
|
|
152
177
|
});
|
|
153
|
-
//
|
|
178
|
+
// A composer's send buttons wait while its files upload, the ask's included (attach.js attach-change).
|
|
154
179
|
B.on(B.root, 'attach-change', (ev) => {
|
|
155
|
-
const b
|
|
156
|
-
if (b) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
180
|
+
for (const b of ev.target.querySelectorAll('button[type=submit]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
181
|
+
if (ev.target.matches?.('form[data-reply]')) for (const b of ev.target.querySelectorAll('button[data-proto], button[data-grill]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
157
182
|
});
|
|
158
183
|
|
|
159
184
|
Object.assign(B, {
|
package/board/public/app.html
CHANGED
|
@@ -64,6 +64,7 @@ window.BoardBoot = (function () {
|
|
|
64
64
|
<script src="/_hub/app/linear-key.js"></script>
|
|
65
65
|
<script src="/_hub/app/sidebar.js"></script>
|
|
66
66
|
<script src="/_hub/app/app.js"></script>
|
|
67
|
+
<script src="/_hub/app/new-task.js"></script>
|
|
67
68
|
<script src="/_hub/app/app-core.js"></script>
|
|
68
69
|
<script src="/_hub/app/app-threads.js"></script>
|
|
69
70
|
<script src="/_hub/app/app-cards.js"></script>
|
|
@@ -72,6 +73,7 @@ window.BoardBoot = (function () {
|
|
|
72
73
|
<script src="/_hub/app/app-needs.js"></script>
|
|
73
74
|
<script src="/_hub/app/app-header.js"></script>
|
|
74
75
|
<script src="/_hub/app/app-arch.js"></script>
|
|
76
|
+
<script src="/_hub/app/app-reader.js"></script>
|
|
75
77
|
<script src="/_hub/app/app-banner.js"></script>
|
|
76
78
|
<script src="/_hub/app/app-snapshot.js"></script>
|
|
77
79
|
<script src="/_hub/app/view-settings.js"></script>
|