@lovek4d/kickflip 0.3.1 → 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.
Files changed (104) hide show
  1. package/CONTEXT.md +151 -115
  2. package/README.md +23 -14
  3. package/SKILL.md +37 -37
  4. package/bin/doctor.mjs +4 -4
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +34 -26
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/arch-review.mjs +1 -1
  9. package/board/auto-review.mjs +2 -0
  10. package/board/bin/board.mjs +186 -69
  11. package/board/domain.mjs +8 -5
  12. package/board/finish.mjs +3 -0
  13. package/board/headless.mjs +3 -0
  14. package/board/on-host.mjs +1 -1
  15. package/board/permissions.mjs +2 -0
  16. package/board/public/app-arch.js +2 -2
  17. package/board/public/app-asks.js +57 -38
  18. package/board/public/app-banner.js +2 -2
  19. package/board/public/app-cards.js +156 -72
  20. package/board/public/app-core.js +2 -4
  21. package/board/public/app-header.js +12 -5
  22. package/board/public/app-lanes.js +201 -47
  23. package/board/public/app-needs.js +2 -1
  24. package/board/public/app-snapshot.js +2 -2
  25. package/board/public/app-threads.js +11 -14
  26. package/board/public/app.html +2 -1
  27. package/board/public/app.js +53 -83
  28. package/board/public/attach.js +39 -3
  29. package/board/public/dialogs.js +59 -14
  30. package/board/public/hub-kit.js +9 -6
  31. package/board/public/hub.css +5 -18
  32. package/board/public/linear-key.js +6 -4
  33. package/board/public/log.html +48 -0
  34. package/board/public/prompts.js +9 -8
  35. package/board/public/prototype-kit.js +6 -6
  36. package/board/public/prototype-switcher.js +13 -5
  37. package/board/public/prototype.html +1 -0
  38. package/board/public/router.js +8 -7
  39. package/board/public/rules-editor.js +3 -4
  40. package/board/public/shell.js +20 -6
  41. package/board/public/sidebar.css +21 -1
  42. package/board/public/sidebar.js +64 -31
  43. package/board/public/style.css +155 -62
  44. package/board/public/toasts.js +2 -2
  45. package/board/public/ui-cards.js +176 -71
  46. package/board/public/ui-enums.js +5 -4
  47. package/board/public/ui-header.js +10 -10
  48. package/board/public/ui-hub.js +4 -4
  49. package/board/public/ui-markdown.js +1 -1
  50. package/board/public/ui-order.js +9 -6
  51. package/board/public/ui-page-header.js +10 -7
  52. package/board/public/ui-patch.js +53 -0
  53. package/board/public/ui-phase-bars.js +17 -0
  54. package/board/public/ui-prompts.js +8 -8
  55. package/board/public/ui-questions.js +26 -23
  56. package/board/public/ui-sidebar.js +46 -22
  57. package/board/public/ui-snapshot.js +20 -9
  58. package/board/public/ui-threads.js +17 -11
  59. package/board/public/ui.js +80 -12
  60. package/board/public/view-frame.js +1 -1
  61. package/board/public/view-hub-settings.js +25 -18
  62. package/board/public/view-hub.js +53 -12
  63. package/board/public/view-notes.js +1 -1
  64. package/board/public/view-questions.js +101 -50
  65. package/board/public/view-settings.js +167 -49
  66. package/board/review.mjs +1 -1
  67. package/board/server.mjs +18 -12
  68. package/board/store.mjs +13 -12
  69. package/board/tickets.mjs +3 -3
  70. package/board/worktree.mjs +20 -14
  71. package/docs/hub-session.md +4 -4
  72. package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
  73. package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
  74. package/hub/board-repo.mjs +1 -1
  75. package/hub/boards.mjs +4 -4
  76. package/hub/close-board.mjs +1 -1
  77. package/hub/github.mjs +4 -4
  78. package/hub/home.mjs +1 -1
  79. package/hub/hub.mjs +12 -12
  80. package/hub/incoming.mjs +2 -0
  81. package/hub/linear.mjs +2 -2
  82. package/hub/live.mjs +2 -2
  83. package/hub/new-board.mjs +2 -2
  84. package/hub/permission-denied-hook.mjs +2 -0
  85. package/hub/pr-report.mjs +3 -1
  86. package/hub/presence.mjs +1 -1
  87. package/hub/prompts.mjs +1 -1
  88. package/hub/push.mjs +2 -2
  89. package/hub/readiness.mjs +2 -2
  90. package/hub/repos.mjs +3 -3
  91. package/hub/restart-board.mjs +1 -1
  92. package/hub/schedule.mjs +1 -1
  93. package/hub/sessions.mjs +2 -2
  94. package/hub/sw.js +1 -1
  95. package/hub/try.mjs +8 -4
  96. package/hub/usage.mjs +3 -3
  97. package/hub/views.mjs +1 -1
  98. package/package.json +5 -1
  99. package/shared/card-prs.mjs +1 -0
  100. package/shared/colors.css +5 -4
  101. package/shared/fake-gh.mjs +59 -8
  102. package/shared/repos.mjs +1 -1
  103. package/shared/usage-gate.mjs +1 -1
  104. package/board/public/view-polling.js +0 -87
@@ -4,9 +4,11 @@
4
4
  // opens the topic whose pill was to its left before the Send, else to its right, and with no topic left that has an
5
5
  // open question goes back to the board, where the Sent line shows as a toast (BoardUI.sentNotice). An open question
6
6
  // with `urgent: true` in owner.json shows alone first, and its Send follows the same rule for its topic. A question
7
- // whose options are prototype takes frames each take with ?take= (docs/adr/0048-a-questions-takes-show-inline.md).
7
+ // whose options are prototype takes frames each take with ?take= (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md).
8
8
  // Each question's eye sends `prototype-request {key?, question, note, choice}` for that question instead of an answer,
9
- // with its note box as the note and its pick so far as the choice (docs/adr/0126-each-question-has-its-own-prototype-eye.md).
9
+ // with its note box as the note and its pick so far as the choice (docs/adr/2026-11-02-1822-each-question-has-its-own-prototype-eye.md).
10
+ // The board server resolves the question with it, and once it is recorded the page moves on as a Send does
11
+ // (docs/adr/2026-11-03-1803-a-question-asked-for-a-prototype-is-resolved.md).
10
12
  // fits: each framed take's measured size, kept across repaints and visits so a take redraws at its size at once.
11
13
  (globalThis.AppViews ||= {}).questions = ((fits) => ({
12
14
  className: 'view-questions',
@@ -18,7 +20,7 @@
18
20
 
19
21
  create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
20
22
  const { $, site } = B;
21
- const { esc, choiceLabel, questionCard, roundChip, takes, linkContext, badge, topicName, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
23
+ const { esc, choiceLabel, questionCard, roundChip, takes, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
22
24
  // prototype-switcher.js reads a draft's note and clears the draft once it sends that question's answer.
23
25
  const draftKey = (id) => 'qdraft:' + id;
24
26
  const getDraft = (id) => { try { return JSON.parse(localStorage.getItem(draftKey(id)) || '{}'); } catch { return {}; } };
@@ -26,12 +28,21 @@
26
28
  const hashId = () => { const h = location.hash.slice(1); try { return decodeURIComponent(h); } catch { return h; } };
27
29
 
28
30
  let all = [];
31
+ // The shown topic's resolved questions, which /api/board leaves out: GET /api/questions answers them as the topic
32
+ // shows, and again once its resolved count changes or after a failed load, at its next draw (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
33
+ let resolved = { topic: null, n: 0, questions: [] };
34
+ // The board last landed, whose version the next load and each tap name, so an unchanged board downloads nothing
35
+ // and a changed one comes as a patch (ui-patch.js).
36
+ let board = null;
29
37
  let items = [];
30
38
  let topics = [];
31
39
  let topicId = null;
32
40
  let soloId = null;
33
41
  let shown = '';
34
42
  let sentLine = '';
43
+ // The footer after a prototype request: the blue spinner, then these words.
44
+ const PROTOTYPE_REQUESTED = 'prototype requested';
45
+ const statusHtml = (line) => (line.startsWith(PROTOTYPE_REQUESTED) ? '<span class="try-spin" aria-hidden="true"></span>' : '') + esc(line);
35
46
  let scrollTake = null;
36
47
  const takeResources = new Map();
37
48
  const clearTakeResources = (within = root) => {
@@ -41,13 +52,14 @@
41
52
  };
42
53
  B.atUnmount(() => clearTakeResources());
43
54
 
44
- const byId = (id) => all.find((q) => q.id === id);
55
+ const send = (kind, data) => site.send(kind, { ...data, ...(board ? { since: board.version } : {}) });
56
+ const byId = (id) => all.find((q) => q.id === id) || resolved.questions.find((q) => q.id === id);
45
57
  const topicOf = (qid) => topics.find((t) => t.questions.includes(qid)) || null;
46
58
  const openTopics = () => topics.filter((t) => t.state !== 'resolved');
47
59
  const links = (q) => linkContext(items, q.topic?.startsWith('item:') ? q.topic.slice(5) : null, { base: './' });
48
60
  const urgentOpen = () => all.find((q) => q.urgent && q.state === 'open');
49
61
 
50
- // docs/adr/0038-waiting-on-you-leads-with-what-needs-the-owner.md
62
+ // docs/adr/2026-10-29-1639-waiting-on-you-leads-with-what-needs-the-owner.md
51
63
  function topicBar() {
52
64
  const list = topicRow(topics, topicId).pills;
53
65
  if (list.length < 2) return '';
@@ -61,7 +73,6 @@
61
73
  const uploading = (qs) => qs.some((q) => BoardAttach.busy('q:' + q.id, site.root));
62
74
  function pending(qs) {
63
75
  return qs.filter((q) => {
64
- if (q.state === 'requested') return false;
65
76
  const d = getDraft(q.id);
66
77
  if (filesOf(q).length) return true;
67
78
  if (!('choice' in d) && !d.note) return false;
@@ -76,8 +87,21 @@
76
87
  return t ? t.questions.map(byId).filter(Boolean) : [];
77
88
  }
78
89
 
90
+ function loadResolved() {
91
+ const t = soloId ? null : topics.find((x) => x.id === topicId);
92
+ if (!t?.resolved || (resolved.topic === t.id && resolved.n === t.resolved)) return;
93
+ resolved = { topic: t.id, n: t.resolved, questions: [] };
94
+ const asked = resolved;
95
+ fetchJSON(site.url('api/questions?topic=' + encodeURIComponent(t.id)), { sleep: B.sleep }).then(({ body }) => {
96
+ if (!B.alive() || resolved !== asked) return;
97
+ asked.questions = body.questions || [];
98
+ repaint(false);
99
+ }, () => { if (resolved === asked) resolved = { topic: null, n: 0, questions: [] }; });
100
+ }
101
+
79
102
  function render() {
80
103
  clearTakeResources();
104
+ loadResolved();
81
105
  const el = $('#page');
82
106
  const qs = visible();
83
107
  if (!qs.length) {
@@ -94,7 +118,7 @@
94
118
  el.innerHTML = `${head}<form id="qform">
95
119
  ${live.map((q, i) => questionCard(q, { n: soloId ? 0 : i + 1, draft: getDraft(q.id), links: links(q), fit: fitOf(q) })).join('')}
96
120
  ${done.length ? `<details class="done"><summary>Resolved · ${done.length}</summary>${done.map((q) => questionCard(q, { draft: getDraft(q.id), links: links(q), fit: fitOf(q) })).join('')}</details>` : ''}
97
- <div class="bar"><span class="muted small${sentLine ? ' sent' : ''}" id="qstatus" role="status">${esc(sentLine)}</span><span class="send-hint" aria-hidden="true">${/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? '⌘↵' : 'Ctrl+↵'}</span><button class="primary" type="submit" id="send">Send</button></div>
121
+ <div class="bar"><span class="muted small${sentLine ? ' sent' : ''}" id="qstatus" role="status">${statusHtml(sentLine)}</span><span class="send-hint" aria-hidden="true">${/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? '⌘↵' : 'Ctrl+↵'}</span><button class="primary" type="submit" id="send">Send</button></div>
98
122
  </form>${soloId ? '' : waitingTopics() + resolvedTopics()}`;
99
123
  BoardAttach.paintAll();
100
124
  el.querySelectorAll('.take-shot').forEach(fitTake);
@@ -104,7 +128,7 @@
104
128
  // A width sets its page's height, so a size measured at another width is measured again.
105
129
  const fitKey = (qid, oid) => `${site.root}\n${qid}\n${oid}`;
106
130
  const fitOf = (q) => (oid) => { const f = fits.get(fitKey(q.id, oid)); return f && f.w === window.innerWidth ? f : null; };
107
- // A phone's header is a bar at the bottom (docs/adr/0070-phone-headers-sit-at-the-bottom.md), so only a header
131
+ // A phone's header is a bar at the bottom (docs/adr/2026-10-31-1532-phone-headers-sit-at-the-bottom.md), so only a header
108
132
  // at the top covers the frame's top.
109
133
  const frameTop = (shot) => {
110
134
  const head = $('header.top')?.getBoundingClientRect();
@@ -128,7 +152,7 @@
128
152
  // A framed take is sized to its page once it loads, since the frame can't scroll. A page that fills its frame at
129
153
  // any height is a full-screen take and gets screenH. A full-screen take, or a grid take past 80% of the screen's
130
154
  // height, turns its question into one full-width take under a choice row
131
- // (docs/adr/0048-a-questions-takes-show-inline.md).
155
+ // (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md).
132
156
  function fitTake(shot) {
133
157
  const frame = shot.querySelector('iframe');
134
158
  const sec = shot.closest('section.q');
@@ -202,7 +226,7 @@
202
226
  function waitingTopics() {
203
227
  const list = topicRow(topics, topicId).waiting;
204
228
  return list.length ? `<details class="done"><summary>Waiting on Claude · ${list.length}</summary>${list.map((t) =>
205
- `<div class="trow"><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button><span class="muted small">${t.requested ? 'prototype requested' : t.answered ? 'answered' : 'Claude replied'}</span></div>`).join('')}</details>` : '';
229
+ `<div class="trow"><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button><span class="muted small">${t.answered ? 'answered' : 'Claude replied'}</span></div>`).join('')}</details>` : '';
206
230
  }
207
231
 
208
232
  function resolvedTopics() {
@@ -211,25 +235,41 @@
211
235
  `<div><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button></div>`).join('')}</details>` : '';
212
236
  }
213
237
 
214
- async function askPrototype(q) {
238
+ // The topic a tap on this page acts for, the pills before it and its name, which moveOn reads once it is recorded.
239
+ function leaving() {
240
+ const from = soloId ? topicOf(soloId)?.id : topicId;
241
+ return { from, pills: topicRow(topics, from).pills, name: topicName(topics.find((t) => t.id === from) || { title: '' }) };
242
+ }
243
+ // After a recorded tap: a load that started before it went out would read its question as still open.
244
+ // `footer` is a line or, for a Send, a function of the reloaded board giving the whole line.
245
+ // The tap's POST answers with the board as it left it, which lands here in place of a reload.
246
+ async function moveOnOnceLoaded({ from, pills }, what, out, footer = what) {
247
+ await loading;
248
+ if (!B.alive()) return;
249
+ if (!land(out.board, false)) await load(false);
250
+ moveOn(from, pills, what, typeof footer === 'function' ? footer() : footer, typeof footer !== 'function');
251
+ }
252
+
253
+ // The tapped Prototype button waits (ui.js waitOn) while the request is sent.
254
+ async function askPrototype(q, button) {
215
255
  const st = $('#qstatus');
216
256
  const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
217
- st.textContent = 'Sending…';
257
+ const at = leaving();
258
+ st.textContent = '';
218
259
  const d = getDraft(q.id);
219
260
  const last = q.answers[q.answers.length - 1];
220
- try { await site.send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d.note || '').trim(), choice: d.choice ?? (last ? last.choice : null) }); }
221
- catch { st.textContent = 'Could not send. Is the host awake?'; return; }
261
+ let out;
262
+ try { out = await BoardUI.waitOn(button, send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d.note || '').trim(), choice: d.choice ?? (last ? last.choice : null) })); }
263
+ catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
222
264
  setDraft(q.id, {});
223
- sentLine = 'Prototype please.';
224
- await loading;
225
- if (await load(false)) { shown = sigNow(); render(); }
265
+ await moveOnOnceLoaded(at, `Prototype requested${at.name ? ` for ${at.name}` : ''}.`, out, PROTOTYPE_REQUESTED);
226
266
  }
227
267
 
228
268
  function updateBar() {
229
269
  const qs = visible();
230
270
  const n = pending(qs).length;
231
271
  const b = $('#send');
232
- if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' : n ? `Send ${n} answer${n === 1 ? '' : 's'}` : 'Send'; }
272
+ if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' : 'Send'; }
233
273
  const live = qs.filter((q) => q.state !== 'resolved');
234
274
  const answered = live.filter((q) => q.state !== 'open').length;
235
275
  $('#progress').textContent = live.length > 1 ? `${answered}/${live.length} answered` : '';
@@ -252,45 +292,58 @@
252
292
  async function fetchBoard(first) {
253
293
  const page = $('#page');
254
294
  try {
255
- const { body: d } = await fetchJSON(site.url('api/board'), { onWait: () => { if (first) page.innerHTML = restartNotice(); }, sleep: B.sleep });
295
+ const { body: d } = await fetchJSON(site.url('api/board' + (board ? '?since=' + encodeURIComponent(board.version) : '')), { tag: board?.version, onWait: () => { if (first) page.innerHTML = restartNotice(); }, sleep: B.sleep });
256
296
  if (!B.alive()) return false;
257
- all = d.owner || [];
258
- items = d.items || [];
259
- topics = d.ownerTopics || [];
260
- site.syncBadge();
261
- if (first) pick(hashId());
262
- else if (soloId && byId(soloId)?.state !== 'open') pick('');
263
- else if (!topics.some((t) => t.id === topicId)) pick('');
264
- loaded = true;
265
- const sig = sigNow();
266
- const active = document.activeElement;
267
- if (sig !== shown && !(active && active.tagName === 'TEXTAREA')) {
268
- shown = sig;
269
- const y = scrollY;
270
- render();
271
- scrollTo(0, first ? 0 : y);
272
- }
297
+ if (d) land(d, first);
298
+ repaint(first);
273
299
  return true;
274
300
  } catch (e) {
275
301
  if (first) page.innerHTML = `<p class="muted">Could not load the questions: ${esc(e.message)}</p>`;
276
302
  return false;
277
303
  }
278
304
  }
305
+ // A board, or a patch from the board held, from a load or a tap's POST. → false for a patch from a board no longer
306
+ // held, which leaves the page as it is.
307
+ function land(d, first) {
308
+ const next = d.since === undefined ? d : BoardUI.applyPatch(board, d);
309
+ if (!next) return false;
310
+ board = next;
311
+ all = board.owner;
312
+ items = board.items;
313
+ topics = cardTopics(board.ownerTopics, items);
314
+ site.syncBadge();
315
+ if (first) pick(hashId());
316
+ else if (soloId && byId(soloId)?.state !== 'open') pick('');
317
+ else if (!topics.some((t) => t.id === topicId)) pick('');
318
+ loaded = true;
319
+ return true;
320
+ }
321
+ // Draws what changed, unless the owner is typing a note.
322
+ function repaint(first) {
323
+ const sig = sigNow();
324
+ const active = document.activeElement;
325
+ if (sig !== shown && !(active && active.tagName === 'TEXTAREA')) {
326
+ shown = sig;
327
+ const y = scrollY;
328
+ render();
329
+ scrollTo(0, first ? 0 : y);
330
+ }
331
+ }
279
332
 
280
- function moveOn(from, pills, what) {
333
+ function moveOn(from, pills, what, footer = what, countOpen = true) {
281
334
  soloId = null;
282
335
  const next = nextQuestion(all, pills, { topic: from });
283
336
  const left = topics.find((t) => t.id === from)?.open || 0;
284
337
  if (next?.topic === from) {
285
338
  topicId = from;
286
- sentLine = `${what} ${left} still open, answer ${left === 1 ? 'it' : 'them'} any time.`;
339
+ sentLine = countOpen ? `${footer.replace(/\.?$/, '.')} ${left} still open.` : footer;
287
340
  shown = sigNow();
288
341
  const y = scrollY;
289
342
  render();
290
343
  scrollTo(0, y);
291
344
  return;
292
345
  }
293
- sentLine = what;
346
+ sentLine = footer;
294
347
  if (!next) {
295
348
  sentNotice.put(B.route, what);
296
349
  if (ctx.up?.()) return;
@@ -308,7 +361,7 @@
308
361
  B.on(root, 'click', (ev) => {
309
362
  if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
310
363
  const eye = ev.target.closest && ev.target.closest('[data-proto]');
311
- if (eye) { if (!eye.disabled) askPrototype(byId(eye.closest('section.q').dataset.q)); return; }
364
+ if (eye) { askPrototype(byId(eye.closest('section.q').dataset.q), eye); return; }
312
365
  const b = ev.target.closest && ev.target.closest('[data-topic]');
313
366
  if (b) { soloId = null; topicId = b.dataset.topic; sentLine = ''; shown = ''; render(); scrollTo(0, 0); }
314
367
  });
@@ -359,11 +412,9 @@
359
412
  const st = $('#qstatus');
360
413
  if (uploading(visible())) return;
361
414
  if (!list.length) { st.textContent = 'Pick an option or write a note first.'; return; }
362
- st.textContent = 'Sending…';
363
- const from = soloId ? topicOf(soloId)?.id : topicId;
364
- const pills = topicRow(topics, from).pills;
365
- const name = topicName(topics.find((t) => t.id === from) || { title: '' });
366
- // docs/adr/0163-send-records-every-ready-answer-in-one-request.md: one request, recorded all or none.
415
+ st.textContent = '';
416
+ const at = leaving();
417
+ // docs/adr/2026-11-03-1519-send-records-every-ready-answer-in-one-request.md: one request, recorded all or none. Send waits meanwhile.
367
418
  const answers = list.map((q) => {
368
419
  const d = getDraft(q.id);
369
420
  const last = q.answers[q.answers.length - 1];
@@ -371,17 +422,17 @@
371
422
  const files = filesOf(q);
372
423
  return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d.note || '').trim(), ...(files.length ? { files } : {}) };
373
424
  });
374
- try { await site.send('owner-answers', { answers }); }
425
+ let out;
426
+ try { out = await BoardUI.waitOn($('#send'), send('owner-answers', { answers })); }
375
427
  catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
376
428
  for (const q of list) {
377
429
  setDraft(q.id, {});
378
430
  BoardAttach.clear('q:' + q.id, site.root);
379
431
  }
380
- const what = `Sent ${list.length} answer${list.length === 1 ? '' : 's'}${name ? ` to ${name}` : ''}.`;
381
- // A load that started before these answers went out would read them as still open.
382
- await loading;
383
- if (await load(false)) moveOn(from, pills, what);
384
- else if (B.alive()) { sentLine = what; $('#qstatus').textContent = what; $('#qstatus').classList.add('sent'); }
432
+ await moveOnOnceLoaded(at, `Sent ${list.length} answer${list.length === 1 ? '' : 's'}${at.name ? ` to ${at.name}` : ''}.`, out, () => {
433
+ const open = all.filter((q) => q.state === 'open').length;
434
+ return `sent ${list.length} answer${list.length === 1 ? '' : 's'}${open ? ` · ${open} still open` : ''}`;
435
+ });
385
436
  });
386
437
  // Each composer painted raises a bubbling attach-change (attach.js).
387
438
  B.on(root, 'attach-change', updateBar);