@lovek4d/kickflip 0.6.1 → 0.7.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 +29 -17
- package/SKILL.md +12 -8
- package/bin/event-lines.mjs +1 -0
- package/bin/kickflip.mjs +7 -7
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/bin/board.mjs +46 -32
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -9
- package/board/onboarding.mjs +1 -1
- package/board/prototype-page.mjs +1 -1
- package/board/public/app-arch.js +2 -2
- package/board/public/app-banner.js +1 -1
- package/board/public/app-cards.js +13 -4
- package/board/public/app-lanes.js +2 -2
- package/board/public/app-needs.js +107 -26
- package/board/public/app-reader.js +52 -42
- package/board/public/app-threads.js +2 -2
- package/board/public/app.html +1 -1
- package/board/public/app.js +22 -12
- package/board/public/progress-review.js +4 -4
- package/board/public/prototype-kit.js +25 -11
- package/board/public/prototype-switcher.js +47 -32
- package/board/public/prototype.html +7 -4
- package/board/public/router.js +34 -6
- package/board/public/shell.js +19 -13
- package/board/public/sidebar.css +2 -2
- package/board/public/style.css +58 -11
- package/board/public/ui-cards.js +44 -27
- package/board/public/ui-enums.js +10 -1
- package/board/public/ui-links.js +1 -1
- package/board/public/ui-order.js +8 -5
- package/board/public/ui-progress-bar.js +6 -7
- package/board/public/ui-questions.js +18 -3
- package/board/public/ui-sidebar.js +3 -2
- package/board/public/ui-threads.js +10 -12
- package/board/public/ui.js +4 -2
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +18 -2
- package/board/public/view-settings.js +60 -28
- package/board/server.mjs +19 -20
- package/board/stop.mjs +1 -1
- package/board/store.mjs +25 -25
- package/board/tickets.mjs +1 -1
- package/board/transcript.mjs +4 -2
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +6 -6
- package/hub/try.mjs +6 -6
- package/hub/upgrade.mjs +1 -1
- package/package.json +1 -1
- package/shared/tailscale.mjs +1 -1
package/board/public/ui-cards.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js, ui-enums.js and ui-progress-bar.js.
|
|
2
2
|
(function () {
|
|
3
|
-
const { esc, escText, ago, lasted, clock, cardTime, etaLine, workingLine, thinkingLine, isPaused, PAUSED, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, LINK_ARROW, progressBar } = globalThis.BoardUI;
|
|
3
|
+
const { esc, escText, ago, lasted, clock, cardTime, etaLine, workingLine, thinkingLine, isPaused, PAUSED, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, PHASES, LINK_ARROW, progressBar } = globalThis.BoardUI;
|
|
4
4
|
const EXTERNAL = { target: '_blank', rel: 'noopener' };
|
|
5
5
|
// The PR badge is the one place a PR's state shows: its mark and tone, marked like the reviewer and CI chips, and its
|
|
6
6
|
// title (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). A card with several PRs shows a
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
function blockedWords(it) {
|
|
69
69
|
const deps = it.blockedBy || [];
|
|
70
70
|
const waits = it.bumpedAt && !it.held;
|
|
71
|
-
const refs = deps.map((d, i) => `<a class="card-blocked" href="#
|
|
71
|
+
const refs = deps.map((d, i) => `<a class="card-blocked" href="#selectedCard=${encodeURIComponent(d.key)}">${i ? '' : waits ? 'waits on ' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
|
|
72
72
|
const until = it.blockedUntil ? `${deps.length ? ' and until' : waits ? 'waits until' : 'blocked until'} ${it.blockedUntil}` : '';
|
|
73
73
|
const tail = until + (it.blockReason && (refs || until) ? `, ${it.blockReason}` : '');
|
|
74
74
|
return refs + (tail ? `<span class="card-blocked">${esc(tail)}</span>` : '');
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
if (!t) return null;
|
|
100
100
|
const words = tryWords(t.kind);
|
|
101
101
|
if (t.state === 'running') return { tone: 'notice', html: spinner + esc(words.going), attrs: { role: 'status' } };
|
|
102
|
-
if (t.state === 'failed') return { tone: 'danger', html: `${esc(words.failed)} ${LINK_ARROW}`, href: `#
|
|
102
|
+
if (t.state === 'failed') return { tone: 'danger', html: `${esc(words.failed)} ${LINK_ARROW}`, href: `#selectedCard=${encodeURIComponent(it.key)}`,
|
|
103
103
|
title: 'The build script stopped with an error. Tap for its log in the card’s thread.', attrs: { 'data-try-log': it.key } };
|
|
104
104
|
if (t.state !== 'ready' || !t.url) return null;
|
|
105
105
|
return { tone: t.stale ? 'quiet' : 'ready', html: `${esc(words.done)}${t.stale ? ' (stale)' : ''} ${LINK_ARROW}`, href: t.url,
|
|
@@ -172,23 +172,15 @@
|
|
|
172
172
|
// Five bars at the end of an In Progress card's time row: reached phases filled, a running one marked. The phases are
|
|
173
173
|
// store.mjs PHASES (docs/adr/2026-11-03-0128-a-builder-status-line-is-a-phase-and-a-short-detail.md,
|
|
174
174
|
// docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
|
|
175
|
-
const BUILD_PHASES =
|
|
175
|
+
const BUILD_PHASES = PHASES.progress;
|
|
176
176
|
const stepWords = (phase) => `step ${BUILD_PHASES.indexOf(phase) + 1} of ${BUILD_PHASES.length}`;
|
|
177
177
|
const ALL_DONE = `all ${BUILD_PHASES.length} steps done`;
|
|
178
|
-
// A card's progress bar (ui-progress-bar.js), or null off In Progress and on a card only a planner
|
|
179
|
-
// phases it reached, which of them is running, whether it dims because the card waits on something
|
|
180
|
-
// questions, a blocker or queue, a reopening) or its build is stale, and its label. An install
|
|
181
|
-
// build reached, undimmed
|
|
182
|
-
// (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md).
|
|
183
|
-
// A Todo card a grill or prototype runs on draws the planner's dashes instead, in Todo's grey, the grill's first and the
|
|
184
|
-
// prototype's second running (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md,
|
|
185
|
-
// docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md).
|
|
178
|
+
// A card's progress bar (ui-progress-bar.js), In Progress's phase, or null off In Progress and on a card only a planner
|
|
179
|
+
// works on: how many phases it reached, which of them is running, whether it dims because the card waits on something
|
|
180
|
+
// other than a builder (its questions, a blocker or queue, a reopening) or its build is stale, and its label. An install
|
|
181
|
+
// is no build: its card keeps the phase its build reached, undimmed
|
|
182
|
+
// (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md, docs/adr/2026-11-11-1200-every-lane-has-phases.md).
|
|
186
183
|
function phaseStrip(it) {
|
|
187
|
-
if (it.lane === 'todo' && it.builder?.planner) {
|
|
188
|
-
const prototype = it.builder.kind === 'prototype';
|
|
189
|
-
const done = prototype ? 2 : 1;
|
|
190
|
-
return { count: BUILD_PHASES.length, done, now: done - 1, tone: 'in-todo', label: prototype ? 'Prototyping' : 'Grilling' };
|
|
191
|
-
}
|
|
192
184
|
if (it.lane !== 'progress' || it.builder?.planner) return null;
|
|
193
185
|
const all = BUILD_PHASES.length;
|
|
194
186
|
// Orchestrator review is the checking phase, before landing (docs/adr/2026-11-04-2130-the-hub-launches-every-agent-from-the-card.md).
|
|
@@ -245,7 +237,7 @@
|
|
|
245
237
|
if (it.waitsOn === 'checking') return 'Orchestrator review';
|
|
246
238
|
if (it.lane === 'done') return 'done';
|
|
247
239
|
if (it.grilling || it.prototyping) return 'preheating';
|
|
248
|
-
if (it.lane === 'todo') return it.
|
|
240
|
+
if (it.lane === 'todo') return it.phase && (it.ready || it.phase !== 'ready') ? PHASE_LOOK[it.phase][0] : 'to do';
|
|
249
241
|
if (it.lane === 'progress') return it.reachedPhase ? `stopped at ${it.reachedPhase}` : 'in progress';
|
|
250
242
|
return 'in review';
|
|
251
243
|
}
|
|
@@ -267,17 +259,36 @@
|
|
|
267
259
|
: `Held until ${d.toLocaleDateString([], { weekday: 'short', month: 'numeric', day: 'numeric' })}, ${clock(d)}. ${it.lane === 'todo' ? 'It starts on its own then, once nothing else blocks it.' : 'Claude picks it up again then.'}`;
|
|
268
260
|
return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
|
|
269
261
|
}
|
|
262
|
+
// docs/adr/2026-11-11-1200-every-lane-has-phases.md
|
|
263
|
+
// A Todo, In Review or Done card's phase as a word, toned by who acts next: grey while a machine waits, blue while
|
|
264
|
+
// Claude works, orange while it waits on the owner, green once it can go and purple once merged. In Progress shows its
|
|
265
|
+
// phase only as its progress bar, and a card In Review for an owner wait other than the merge has none. Ready reads
|
|
266
|
+
// only on a card that can start (domain.mjs ready), so a blocked one shows what blocks it instead.
|
|
267
|
+
const PHASE_LOOK = {
|
|
268
|
+
new: ['new', 'quiet'], grilling: ['grilling', 'info'], prototyping: ['prototyping', 'info'], staging: ['staging', 'notice'], ready: ['ready', 'success'],
|
|
269
|
+
draft: ['draft', 'quiet'], reviewers: ['pending review', 'notice'], merge: ['pending merge', 'notice'], stale: ['stale', 'quiet'],
|
|
270
|
+
merged: ['merged', 'merged'], closed: ['closed', 'quiet'],
|
|
271
|
+
};
|
|
272
|
+
function phaseWord(it) {
|
|
273
|
+
if (it.kind !== 'work' || it.lane === 'progress' || !it.phase || (it.phase === 'ready' && !it.ready)) return '';
|
|
274
|
+
const [word, tone] = PHASE_LOOK[it.phase];
|
|
275
|
+
return `<span class="card-phase ph-${tone}">${word}</span>`;
|
|
276
|
+
}
|
|
270
277
|
// A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed,
|
|
271
|
-
//
|
|
272
|
-
// (docs/adr/2026-11-06-
|
|
278
|
+
// bumped out by the card whose bump took its slot, or held while host recovers on a bumped ready card a busy host holds,
|
|
279
|
+
// both grey (docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md), stalled in red or a build's stale
|
|
280
|
+
// in grey (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md), a stale review reading it
|
|
281
|
+
// once, as its phase, then the phase word (phaseWord), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
|
|
273
282
|
// takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
|
|
274
283
|
// docs/adr/2026-11-03-0516-a-new-card-is-ready-grilling-or-prototyping.md, docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
|
|
275
284
|
const SEP = '<span class="sep"> · </span>';
|
|
276
285
|
const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
|
|
277
286
|
function timeLine(it, unread, bars) {
|
|
278
287
|
const iso = it.sortAt;
|
|
279
|
-
const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it),
|
|
280
|
-
it.
|
|
288
|
+
const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it),
|
|
289
|
+
it.bumpedOut && `<a class="card-bumped-out" href="#selectedCard=${encodeURIComponent(it.bumpedOut.key)}">bumped out by ${esc(it.bumpedOut.ref)}</a>`,
|
|
290
|
+
it.hostHeld && '<span class="card-host-held">held while host recovers</span>',
|
|
291
|
+
it.stalled ? '<span class="card-stalled">stalled</span>' : it.stale && it.phase !== 'stale' && '<span class="card-stale">stale</span>', phaseWord(it)].filter(Boolean);
|
|
281
292
|
const news = newWords(unread, 'message');
|
|
282
293
|
const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
|
|
283
294
|
const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
|
|
@@ -296,7 +307,7 @@
|
|
|
296
307
|
+ `<div class="dur-custom"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-hold-at="${esc(key)}" required aria-label="Custom hold until a date and time"></div></div></div>`;
|
|
297
308
|
}
|
|
298
309
|
// A New card's bump, edit and delete, and its line from its ask state (domain.mjs askItem;
|
|
299
|
-
// docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). top is whether it is
|
|
310
|
+
// docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). top is whether it is bumped already.
|
|
300
311
|
const askActions = (it, top) => `<div class="ask-actions">${bumpButton(it, top)}${it.ask.canEdit ? editButton(it.id) : ''}${withdrawButton(it.id)}</div>`;
|
|
301
312
|
// A card waiting on its owner step has the owner's Done where the bump sits: a check icon that confirms, then sends
|
|
302
313
|
// card-done (app-cards.js; docs/adr/2026-11-04-0158-a-card-whose-code-landed-stays-in-review-while-an-owner-step-remains.md).
|
|
@@ -304,15 +315,21 @@
|
|
|
304
315
|
const ownerDoneButton = (it) => `<button type="button" class="ghost icon owner-done" data-confirm="finish" data-key="${esc(it.key)}" aria-label="Done" title="${esc(`Done: ${it.ownerStep}`)}">${CHECK}</button>`;
|
|
305
316
|
const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
|
|
306
317
|
: it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
|
|
307
|
-
|
|
318
|
+
// docs/adr/2026-11-07-0115-the-orchestrator-sorts-new-tasks-itself.md: a New card reads a grey "waiting", its dot
|
|
319
|
+
// still, until the orchestrator picks it up (the watcher's working mark), then "thinking".
|
|
320
|
+
const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting</span>';
|
|
321
|
+
const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' ? thinkingLine() : ask.state === 'waiting' && PICKUP_WAIT].filter(Boolean).join(' · ');
|
|
308
322
|
// In a wave band (app-lanes.js), step is the number of the card's phase in its wave, shown inside its id chip, and actions replace
|
|
309
323
|
// its bump with the band's ⋯ menu.
|
|
310
324
|
function workLines(it, { unread = 0, top = false, step = 0, actions = null } = {}) {
|
|
311
325
|
const ticket = it.ticket ? ticketChip(it) : it.ticketSource === 'linear' ? linearChips(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : linearChips(it);
|
|
312
|
-
// A work card always shows its ticket;
|
|
326
|
+
// A work card always shows its ticket; an ask, which has none, shows New or Ask there (askChip).
|
|
313
327
|
const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
|
|
314
328
|
const ids = it.kind === 'ask' ? askChip(it) : chip;
|
|
315
329
|
const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
|
|
330
|
+
// A handled ask's asked time sits under its title, since its Ask badge holds the ticket's spot
|
|
331
|
+
// (docs/adr/2026-11-10-1200-an-ask-is-titled-in-place-and-wears-an-ask-badge.md).
|
|
332
|
+
const asked = it.kind === 'ask' && !it.isNew ? `<div class="line3 sub">asked ${esc(cardTime(it.ask.askedAt))}</div>` : '';
|
|
316
333
|
const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
|
|
317
334
|
: it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${escText(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
|
|
318
335
|
: it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
|
|
@@ -327,12 +344,12 @@
|
|
|
327
344
|
const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${escText(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
|
|
328
345
|
: `<div class="line3 sub check">${escText(`Checks: ${it.ownerCheck.words}`)}</div>`;
|
|
329
346
|
return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
|
|
330
|
-
${status}${check}${eta}
|
|
347
|
+
${asked}${status}${check}${eta}
|
|
331
348
|
${chips ? `<div class="line2 meta">${chips}</div>` : ''}
|
|
332
349
|
${timeLine(it, unread, progressBar(phaseStrip(it)))}`;
|
|
333
350
|
}
|
|
334
351
|
const WAVE_ICON = '<svg class="wave-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 9c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/><path d="M2 16c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/></svg>';
|
|
335
|
-
const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
|
|
352
|
+
const askChip = (it) => (it.isNew ? badge('notice', 'New') : badge('quiet', 'Ask'));
|
|
336
353
|
function planButton(items) {
|
|
337
354
|
const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blocked).map((i) => i.key);
|
|
338
355
|
return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}"${BoardUI.pending.has('todo:plan') ? BoardUI.BUSY : ''}>Plan waves</button>` : '';
|
package/board/public/ui-enums.js
CHANGED
|
@@ -7,5 +7,14 @@
|
|
|
7
7
|
const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done', stalled: 'stalled' });
|
|
8
8
|
const BUILDER_KIND = Object.freeze({ install: 'install' });
|
|
9
9
|
const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
|
|
10
|
-
|
|
10
|
+
// Each lane's phases, in order, which a card stores beside its lane (docs/adr/2026-11-11-1200-every-lane-has-phases.md).
|
|
11
|
+
// In Progress's are the five build steps its progress bar draws; a review's "reviewers" and "merge" read "pending review"
|
|
12
|
+
// and "pending merge" on the card (ui-cards.js PHASE_LOOK).
|
|
13
|
+
const PHASES = Object.freeze({
|
|
14
|
+
todo: Object.freeze(['new', 'grilling', 'prototyping', 'staging', 'ready']),
|
|
15
|
+
progress: Object.freeze(['reading', 'building', 'testing', 'checking', 'landing']),
|
|
16
|
+
review: Object.freeze(['draft', 'reviewers', 'merge', 'stale']),
|
|
17
|
+
done: Object.freeze(['merged', 'closed']),
|
|
18
|
+
});
|
|
19
|
+
Object.assign(globalThis.BoardUI ||= {}, { BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, PHASES });
|
|
11
20
|
})();
|
package/board/public/ui-links.js
CHANGED
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
return anyLink(url, url) + esc(raw.slice(url.length));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
const cardLink = (ref, ctx) => `<a href="${esc(`${ctx.base || ''}#
|
|
71
|
+
const cardLink = (ref, ctx) => `<a href="${esc(`${ctx.base || ''}#selectedCard=${encodeURIComponent(record(ctx.refs, ref))}`)}">${esc(ref)}</a>`;
|
|
72
72
|
|
|
73
73
|
// The longest hyphen-prefix of a word that is a known ref or a Linear id, then the rest as text.
|
|
74
74
|
function wordLink(word, ctx) {
|
package/board/public/ui-order.js
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
// The board's order of its items, each lane together, which board/domain.mjs sorts the board by and the page sorts a
|
|
2
2
|
// tap it draws at once by, so the card lands where the server will put it
|
|
3
|
-
// (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, sortAt, order and key.
|
|
3
|
+
// (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, bumpedOut, sortAt, order and key.
|
|
4
4
|
// Needs nothing else: board/domain.mjs runs this file in a vm context.
|
|
5
5
|
(function () {
|
|
6
6
|
const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3, asks: 4 };
|
|
7
|
-
//
|
|
8
|
-
// (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md
|
|
7
|
+
// Every lane but Done lists bumped cards first, the earliest bump on top, a bumped New ask included
|
|
8
|
+
// (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
|
|
9
|
+
// docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md), then in Todo the cards a bump bumped out,
|
|
10
|
+
// the earliest first, so the pull takes them next. Todo lists the rest oldest first by sortAt, a card with no
|
|
9
11
|
// time last, the first added to its wave file on top, so a New ask and every new card join the bottom and the pull takes the
|
|
10
12
|
// top ready card (docs/adr/2026-11-05-2345-new-todo-cards-join-the-bottom.md,
|
|
11
13
|
// docs/adr/2026-11-06-0300-a-new-task-lands-at-the-bottom-of-todo-and-flashes-blue.md). Every other
|
|
12
14
|
// lane lists its cards newest first by sortAt, a card with no time last, the last added to its wave file on top.
|
|
15
|
+
// Present times first, the earliest on top.
|
|
16
|
+
const earliest = (x, y) => (x ? 0 : 1) - (y ? 0 : 1) || String(x || '').localeCompare(String(y || ''));
|
|
13
17
|
function laneOrder(a, b) {
|
|
14
|
-
const bumped = (i) => i.bumpedAt || '';
|
|
15
18
|
const at = (i) => String(i.sortAt || '');
|
|
16
19
|
const oldestFirst = a.lane === 'todo';
|
|
17
20
|
const timeless = (i) => (at(i) ? 0 : 1);
|
|
18
21
|
const age = oldestFirst ? timeless(a) - timeless(b) || at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
|
|
19
22
|
const added = a.lane !== 'todo' ? b.order - a.order : a.order - b.order;
|
|
20
|
-
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] ||
|
|
23
|
+
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || earliest(a.bumpedAt, b.bumpedAt) || earliest(a.bumpedOut?.at, b.bumpedOut?.at) || age || added || a.key.localeCompare(b.key);
|
|
21
24
|
}
|
|
22
25
|
Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
|
|
23
26
|
})();
|
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
// The progress bar every bar on the board draws from: an In Progress card's five build phases and a
|
|
2
|
-
//
|
|
1
|
+
// The progress bar every bar on the board draws from: an In Progress card's five build phases (ui-cards.js), and a
|
|
2
|
+
// starting board's three start steps (ui-sidebar.js)
|
|
3
3
|
// (docs/adr/2026-11-06-0038-a-progress-bar-pulses-one-step-at-a-time.md). It needs nothing else, since hub/hub.mjs
|
|
4
4
|
// renders the shell's first sidebar with it in a vm context. style.css draws the bars.
|
|
5
5
|
(function () {
|
|
6
6
|
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
7
|
-
// {count, done, now, percent, dim,
|
|
7
|
+
// {count, done, now, percent, dim, label}, or null for no bar. done counts the steps reached, filled; now is the
|
|
8
8
|
// index of the one step still running, which pulses, so no bar can draw two. percent fills the step after the done
|
|
9
|
-
// ones that far. A dim bar draws its running step filled, without the pulse.
|
|
10
|
-
// Todo's grey; with none it is In Progress's orange.
|
|
9
|
+
// ones that far. A dim bar draws its running step filled, without the pulse.
|
|
11
10
|
function progressBar(bar) {
|
|
12
11
|
if (!bar) return '';
|
|
13
|
-
const { count, done, now, percent, dim,
|
|
12
|
+
const { count, done, now, percent, dim, label } = bar;
|
|
14
13
|
const step = (i) => (i === now ? `<i class="${dim ? 'done' : 'now'}"></i>` : i < done ? '<i class="done"></i>'
|
|
15
14
|
: i === done && percent !== undefined ? `<i class="fill" style="--p:${percent}%"></i>` : '<i class=""></i>');
|
|
16
|
-
return `<span class="phase-bars${dim ? ' dim' : ''}
|
|
15
|
+
return `<span class="phase-bars${dim ? ' dim' : ''}" role="img" aria-label="${esc(label)}" title="${esc(label)}">${Array.from({ length: count }, (_, i) => step(i)).join('')}</span>`;
|
|
17
16
|
}
|
|
18
17
|
// Every pulse on the page beats on one clock: each one that starts is set to the document timeline's origin, so a
|
|
19
18
|
// card's running step and its status dot blink together, and a redraw that draws a new copy never restarts the beat.
|
|
@@ -129,8 +129,8 @@
|
|
|
129
129
|
${answerHistory(q, links)}
|
|
130
130
|
</section>`;
|
|
131
131
|
}
|
|
132
|
-
// A card's
|
|
133
|
-
const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) :
|
|
132
|
+
// A pill reads its topic's title, a card's being its ref then its title, whichever tracker it comes from; an ask (item:ask:<id>) reads the time it appeared.
|
|
133
|
+
const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) : t.title);
|
|
134
134
|
// /api/board's ownerTopics, each card's topic named from its card, which the board sends in its place while the card
|
|
135
135
|
// is on the board (board/server.mjs pageBoard). cards: the board's items.
|
|
136
136
|
function cardTopics(topics, cards) {
|
|
@@ -174,5 +174,20 @@
|
|
|
174
174
|
},
|
|
175
175
|
};
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
// A prototype viewer's Round pill and its sheet, on the full page (prototype-switcher.js) and in the board's right pane
|
|
178
|
+
// (app-needs.js). rounds: the card's prototype questions, oldest round first, as /api/board's card prototypes.rounds
|
|
179
|
+
// lists them ({n, q, url, confirm, state}). The pill names the round qid is in, out of the newest round's number; a
|
|
180
|
+
// card with a single round gets none.
|
|
181
|
+
function roundPill(rounds, qid) {
|
|
182
|
+
const at = (rounds || []).find((r) => r.q === qid);
|
|
183
|
+
if (!at || rounds.length < 2) return '';
|
|
184
|
+
return `<button type="button" class="ps-chip" data-ps-top="rounds">Round ${at.n} of ${Math.max(...rounds.map((r) => r.n))} · earlier</button>`;
|
|
185
|
+
}
|
|
186
|
+
const ROUND_WORD = { open: 'open', requested: 'prototype requested' };
|
|
187
|
+
function roundSheet({ ref, rounds, qid }) {
|
|
188
|
+
const rows = [...rounds].reverse().map((r) => `<button type="button" data-ps-round="${esc(r.url)}"><span>Round ${r.n}${r.confirm ? ' confirm' : ''}${r.q === qid ? ' · showing' : ''}</span><span>${ROUND_WORD[r.state] || 'answered'}</span></button>`);
|
|
189
|
+
return `<div class="ps-sheet" role="dialog" aria-label="Rounds"><h3>Rounds on ${esc(ref)}</h3>${rows.join('')}</div>`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
Object.assign(globalThis.BoardUI, { isPrototype, getDraft, setDraft, choiceLabel, choiceOf, noteOf, isSent, option, answerHistory, questionCard, roundChip, roundPill, roundSheet, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
|
|
178
193
|
})();
|
|
@@ -209,7 +209,8 @@
|
|
|
209
209
|
// (docs/adr/2026-11-06-1305-a-busy-host-shows-only-on-the-hub-row-and-the-logo-dot.md).
|
|
210
210
|
const busy = hubBusy(data);
|
|
211
211
|
// A board under Pause board carries its own pause (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md).
|
|
212
|
-
|
|
212
|
+
// A red fault on a board row keeps its word, and a down or degraded hub keeps the Hub row: red and orange beat the grey pause (AGENTS.md).
|
|
213
|
+
const holdWord = (s) => (paused || s.pause?.state === 'paused') && !/class="word t-down"/.test(s.sub || '') ? word('paused', 'paused') : '';
|
|
213
214
|
const siteRow = (s) => {
|
|
214
215
|
const sub = s.board && holdWord(s) ? holdWord(s) + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
|
|
215
216
|
return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub ? `<span class="sb-status">${sub}</span>` : '', ctx: contextWords(s.ctx), on: s.route === here, key: keys?.get(siteHref(s)) });
|
|
@@ -227,7 +228,7 @@
|
|
|
227
228
|
: device === 'install' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Add Claude to your Home Screen', sub: 'Notifications come only to the Home Screen app', cls: 'sb-turnon' })
|
|
228
229
|
: '';
|
|
229
230
|
const usage = sidebarUsage(data?.usage);
|
|
230
|
-
const hub = paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: 'the host is busy' } : data?.hub;
|
|
231
|
+
const hub = data?.hub?.tone === 'down' || data?.hub?.tone === 'wait' ? data.hub : paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: 'the host is busy' } : data?.hub;
|
|
231
232
|
const n = data?.notifications || {};
|
|
232
233
|
const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
|
|
233
234
|
const notify = device === 'blocked' ? word('needs', 'Notifications blocked on this device')
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
(function () {
|
|
4
4
|
const { esc, escText, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, thinkingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
|
|
5
5
|
const VIA = { slack: ' · Slack', github: ' · GitHub' };
|
|
6
|
-
// A link to a card on this board (#
|
|
6
|
+
// A link to a card on this board (#selectedCard=<key>) or on another board (/<route>/#selectedCard=<key>, an ask's move), or ''.
|
|
7
7
|
function cardLink(link) {
|
|
8
|
-
const key = /^(?:\/[\w-]+\/)?#item=([^&]+)/.exec(link)?.[1];
|
|
8
|
+
const key = /^(?:\/[\w-]+\/)?#(?:selectedCard|item)=([^&]+)/.exec(link)?.[1];
|
|
9
9
|
if (!key) return '';
|
|
10
10
|
let ask = key.startsWith('ask:');
|
|
11
11
|
try { ask = decodeURIComponent(key).startsWith('ask:'); } catch {}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
: m.kind === 'visual' ? `<div class="msg link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
42
42
|
// A request line's mark is its words' icon, or m.mark's for a line whose words vary; a Full person's names them.
|
|
43
43
|
: m.request && m.card ? `<div class="msg request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
44
|
-
: m.request ? `<div class="msg request">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
|
|
44
|
+
: m.request ? `<div class="msg request${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
|
|
45
45
|
// docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md: a card's move between lanes.
|
|
46
46
|
: m.kind === 'move' ? `<div class="msg request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
47
47
|
: m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
@@ -52,22 +52,20 @@
|
|
|
52
52
|
${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
|
|
53
53
|
<div class="who">${m.who === 'love' ? (m.by ? esc(m.by) : 'You') : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
|
|
54
54
|
<div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
|
|
55
|
-
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">
|
|
55
|
+
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">View transcript</a></div>` : ''}
|
|
56
56
|
${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
|
|
57
57
|
${m.eventId ? msgFooter(m) : ''}
|
|
58
58
|
</div>`);
|
|
59
59
|
|
|
60
60
|
// One agent run (domain.mjs threadOf gives its started line, status lines and finished line the same `run`) is one
|
|
61
|
-
// closed details line at its
|
|
62
|
-
//
|
|
61
|
+
// closed details line at its first line's place: the action, then its status, finished when its newest line is a finished
|
|
62
|
+
// line and running otherwise, and nothing after;
|
|
63
|
+
// expanding it shows the run's lines and, when the run has a log, a View transcript link.
|
|
63
64
|
function foldedRun(m, links, edits) {
|
|
64
|
-
const
|
|
65
|
-
const running = !m.folded.some((f) => f.finished);
|
|
66
|
-
const newest = statuses.at(-1);
|
|
67
|
-
const tail = running ? (newest ? `running · ${escText(newest.text)}` : 'running') : 'finished';
|
|
65
|
+
const tail = m.folded.at(-1).finished ? 'finished' : 'running';
|
|
68
66
|
const log = m.folded.find((f) => f.log)?.log;
|
|
69
|
-
const
|
|
70
|
-
return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${
|
|
67
|
+
const logLine = log ? `<div class="small fold-log"><a href="log.html?file=${encodeURIComponent(log)}">View transcript</a></div>` : '';
|
|
68
|
+
return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${tail === 'finished' ? ' class="finished"' : ''}>${statusMark('status lines')}${escText(m.agent)} · ${tail}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}${logLine}</details>`;
|
|
71
69
|
}
|
|
72
70
|
function foldStatus(lines) {
|
|
73
71
|
const runs = new Map();
|
package/board/public/ui.js
CHANGED
|
@@ -103,6 +103,8 @@
|
|
|
103
103
|
'ask-link': mini('<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>'),
|
|
104
104
|
move: mini('<path d="M3 7h14"/><path d="m13 3 4 4-4 4"/><path d="M21 17H7"/><path d="m11 13-4 4 4 4"/>'),
|
|
105
105
|
'Implementation requested': mini('<path d="m8 6-6 6 6 6"/><path d="m16 6 6 6-6 6"/>'),
|
|
106
|
+
'Marked as staging': mini('<path d="m12 2 10 5-10 5L2 7z"/><path d="m2 12 10 5 10-5"/><path d="m2 17 10 5 10-5"/>'),
|
|
107
|
+
'Marked as ready': mini('<path d="M4 22V4"/><path d="M4 4h13l-2 4 2 4H4"/>'),
|
|
106
108
|
Held: mini('<path d="M9 5v14"/><path d="M15 5v14"/>'),
|
|
107
109
|
Resumed: mini('<path d="M7 4v16l13-8z"/>'),
|
|
108
110
|
'Related to': LINK, 'Unrelated from': LINK, 'Follow-up': LINK, 'Follow-up of': LINK,
|
|
@@ -122,8 +124,8 @@
|
|
|
122
124
|
// Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
|
|
123
125
|
const withdrawButton = (id) => `<button class="ghost icon" data-withdraw="${esc(id)}" aria-label="Delete" title="Delete">${TRASH}</button>`;
|
|
124
126
|
const editButton = (id) => `<button class="ghost icon" data-edit="${esc(id)}" aria-label="Edit" title="Edit">${PENCIL}</button>`;
|
|
125
|
-
// A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange
|
|
126
|
-
//
|
|
127
|
+
// A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange and disabled once
|
|
128
|
+
// bumped. A work card's is the same icon: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where
|
|
127
129
|
// it is filled orange, card-unbump {key} (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
|
|
128
130
|
// docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
|
|
129
131
|
const bumpButton = (it, top) => `<button class="ghost icon${it.bumpedAt ? ' bumped' : ''}" data-bump="${esc(it.id)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
(globalThis.AppViews ||= {}).notes = {
|
|
5
5
|
className: 'view-notes',
|
|
6
6
|
title: () => 'Notes',
|
|
7
|
-
markup: ({ item, wave }) => `${BoardUI.header({ lead: 'back', title: 'Notes', titleId: 'ref', backHref: wave ? './' : `./#
|
|
7
|
+
markup: ({ item, wave }) => `${BoardUI.header({ lead: 'back', title: 'Notes', titleId: 'ref', backHref: wave ? './' : `./#selectedCard=${encodeURIComponent(item || '')}`, controls: false })}
|
|
8
8
|
<main>
|
|
9
9
|
<article class="plan" id="plan"><p class="muted">Loading the notes…</p></article>
|
|
10
10
|
</main>`,
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
return `<div class="topics" role="group" aria-label="Topics">${list.map((t) => pillHtml(t)).join('')}</div>`;
|
|
86
86
|
}
|
|
87
87
|
// An arrival (arrive) slides in with a glow and settles (style.css .arrived).
|
|
88
|
-
const pillHtml = (t, arrived = false) => `<button class="${arrived ? 'arrived ' : ''}${t.progress ? 'progress' : ''}" data-topic="${esc(t.id)}" aria-pressed="${t.id === topicId}" title="${esc(t.title)}">${esc(topicName(t))}
|
|
88
|
+
const pillHtml = (t, arrived = false) => `<button class="${arrived ? 'arrived ' : ''}${t.progress ? 'progress' : ''}" data-topic="${esc(t.id)}" aria-pressed="${t.id === topicId}" title="${esc(t.title)}"><span class="name">${esc(topicName(t))}</span><span class="n">${t.state === 'resolved' ? 'resolved' : t.open}</span></button>`;
|
|
89
89
|
const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
|
|
90
90
|
|
|
91
91
|
// Only open questions changed here since their last answer, with files attached, reopened with a pick, or with a
|
|
@@ -150,10 +150,12 @@
|
|
|
150
150
|
updateBar();
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// A toast names a topic by its card's ref, the title up to its colon; an ask by its time.
|
|
154
|
+
const toastName = (t) => (t.askedAt ? topicName(t) : String(t.title).match(/^(\S+):/)?.[1] || t.title);
|
|
153
155
|
// The topic a tap on this page acts for, the pills before it and its name, which moveOn reads once it is recorded.
|
|
154
156
|
function leaving() {
|
|
155
157
|
const from = soloId ? topicOf(soloId)?.id : topicId;
|
|
156
|
-
return { from, pills: topicRow(topics, from).pills, name:
|
|
158
|
+
return { from, pills: topicRow(topics, from).pills, name: toastName(topics.find((t) => t.id === from) || { title: '' }) };
|
|
157
159
|
}
|
|
158
160
|
// After a recorded tap: a load that started before it went out would read its question as still open.
|
|
159
161
|
// `footer` is a line or, for a Send, a function of the reloaded board giving the whole line.
|
|
@@ -364,6 +366,14 @@
|
|
|
364
366
|
scroll(0);
|
|
365
367
|
}
|
|
366
368
|
|
|
369
|
+
let lastTap = { id: null, at: 0 };
|
|
370
|
+
// item:<key> is a card, wave:<slug> a band. Embedded, the board is beside the pane; alone, it is the page before this one.
|
|
371
|
+
function openOnBoard(id) {
|
|
372
|
+
if (ctx.embedded) return ctx.openOnBoard(id);
|
|
373
|
+
if (!id.startsWith('item:')) return;
|
|
374
|
+
const href = new URL(`./#item=${encodeURIComponent(id.slice(5))}`, location.href).href;
|
|
375
|
+
if (!AppShell.open(href)) location.assign(href);
|
|
376
|
+
}
|
|
367
377
|
B.on(root, 'click', (ev) => {
|
|
368
378
|
if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
|
|
369
379
|
const eye = ev.target.closest && ev.target.closest('[data-proto]');
|
|
@@ -372,9 +382,14 @@
|
|
|
372
382
|
if (reopenBadge) { if (!reopenBadge.disabled) reopen(byId(reopenBadge.dataset.reopen), reopenBadge); return; }
|
|
373
383
|
const b = ev.target.closest && ev.target.closest('[data-topic]');
|
|
374
384
|
if (b) {
|
|
385
|
+
// Two taps on one pill within 300ms open its card on the board; a touch screen sends no dblclick.
|
|
386
|
+
const now = Date.now();
|
|
387
|
+
if (lastTap.id === b.dataset.topic && now - lastTap.at < 300) { lastTap = { id: null, at: 0 }; openOnBoard(b.dataset.topic); return; }
|
|
388
|
+
lastTap = { id: b.dataset.topic, at: now };
|
|
375
389
|
soloId = null; setTopic(b.dataset.topic); sentLine = ''; shown = '';
|
|
376
390
|
const first = topics.find((t) => t.id === topicId)?.questions[0];
|
|
377
391
|
if (first) setHash(first);
|
|
392
|
+
ctx.onTopic?.(topicId);
|
|
378
393
|
render(); scroll(0);
|
|
379
394
|
}
|
|
380
395
|
});
|
|
@@ -441,6 +456,7 @@
|
|
|
441
456
|
if (!loaded) return false;
|
|
442
457
|
const topic = topics.find((t) => t.id === id);
|
|
443
458
|
if (topic) { soloId = null; setTopic(topic.id); } else pick(id || '');
|
|
459
|
+
ctx.onTopic?.(topicId);
|
|
444
460
|
sentLine = '';
|
|
445
461
|
shown = '';
|
|
446
462
|
render();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Board Settings, a modal the app shell (shell.js) mounts over its board for /<route>/settings.html.
|
|
2
2
|
// Saving reviewers sends a `reviewers` event {add: [{name, slack_user, github}], remove: [name]}; the server writes both
|
|
3
|
-
// itself and the list redraws from its answer. Add
|
|
4
|
-
//
|
|
3
|
+
// itself and the list redraws from its answer. Add searches the Slack members the server fetches (GET api/reviewer-people), then
|
|
4
|
+
// takes a GitHub login guessed from the name or typed, and stays off until a member and a login are there. Tickets save on each change, and Done, ✕,
|
|
5
5
|
// Escape and the backdrop wait for that save to land before closing. An unsaved edit marks [data-dirty], and the shell
|
|
6
6
|
// asks before closing the modal then. Sharing (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md)
|
|
7
7
|
// lists the hub's GET /_hub/share/<route>, who it's shared with, each View or Full, and who was just refused, and saves
|
|
@@ -76,9 +76,9 @@
|
|
|
76
76
|
<p class="muted small">Everyone here gets each PR link on this board once it's ready, and the weekday digest. Someone you add also gets the links of PRs already in review.</p>
|
|
77
77
|
<div id="list"><p class="muted small">Loading…</p></div>
|
|
78
78
|
<form class="add pick" id="addForm">
|
|
79
|
-
<
|
|
80
|
-
<div
|
|
81
|
-
<button type="submit" id="addBtn" disabled>Add</button>
|
|
79
|
+
<input id="slackFilter" placeholder="Search Slack members" autocomplete="off">
|
|
80
|
+
<div id="slackPeople" class="pick-list"><p class="muted small">Loading…</p></div>
|
|
81
|
+
<div class="login-row" id="loginRow" hidden><input id="githubFilter" placeholder="GitHub login" autocomplete="off" autocapitalize="off" spellcheck="false"><button type="submit" id="addBtn" disabled>Add</button></div>
|
|
82
82
|
</form>
|
|
83
83
|
<p class="muted small" id="pickStatus" role="status"></p>
|
|
84
84
|
<div class="row" style="margin-top:12px"><span class="muted small" id="saveStatus"></span><button class="primary" id="save" disabled>Save</button></div>
|
|
@@ -362,40 +362,72 @@
|
|
|
362
362
|
render();
|
|
363
363
|
});
|
|
364
364
|
|
|
365
|
-
// The
|
|
366
|
-
|
|
367
|
-
|
|
365
|
+
// The picker: search the Slack members and tap one, then the GitHub login field holds a guess from the name (or what
|
|
366
|
+
// the person types), and Add stages them for Save. people is null while loading, 'failed' once the list can't be read.
|
|
367
|
+
let people = null;
|
|
368
|
+
const picked = { slack: null };
|
|
368
369
|
const fits = (text, filter) => text.toLowerCase().includes(filter.trim().toLowerCase());
|
|
370
|
+
const squash = (text) => text.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
371
|
+
// The collaborator whose login is a plain spelling of the name: first+last, first, last, or first initial + last.
|
|
372
|
+
function guessLogin(name, logins) {
|
|
373
|
+
const words = name.toLowerCase().split(/\s+/).map(squash).filter(Boolean);
|
|
374
|
+
if (!words.length) return '';
|
|
375
|
+
const first = words[0], last = words.at(-1);
|
|
376
|
+
const spellings = [first + last, first, last, first[0] + last];
|
|
377
|
+
for (const spelling of spellings) {
|
|
378
|
+
const hit = logins.find((l) => squash(l) === spelling);
|
|
379
|
+
if (hit) return hit;
|
|
380
|
+
}
|
|
381
|
+
return '';
|
|
382
|
+
}
|
|
383
|
+
const problem = (text) => `<p class="muted small problem">${esc(text)} <button type="button" class="ghost" data-retry>Retry</button></p>`;
|
|
369
384
|
function renderPick() {
|
|
370
|
-
const slackFilter = $('#slackFilter').value, githubFilter = $('#githubFilter').value.trim();
|
|
371
385
|
const taken = new Set([...current, ...add.keys()].map((n) => n.toLowerCase()));
|
|
372
|
-
const
|
|
373
|
-
$('#
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
386
|
+
const list = $('#slackPeople');
|
|
387
|
+
$('#slackFilter').hidden = Boolean(picked.slack);
|
|
388
|
+
$('#loginRow').hidden = !picked.slack;
|
|
389
|
+
if (picked.slack) {
|
|
390
|
+
list.innerHTML = `<span class="chip">${esc(picked.slack.name)} <button type="button" class="ghost" data-unpick aria-label="Choose someone else">✕</button></span>`;
|
|
391
|
+
$('#pickStatus').textContent = people?.githubProblem ? `${people.githubProblem} Type their login.` : '';
|
|
392
|
+
} else if (people === 'failed') list.innerHTML = problem("Couldn't load the list.");
|
|
393
|
+
else if (!people) list.innerHTML = '<p class="muted small">Loading…</p>';
|
|
394
|
+
else if (!people.slack) list.innerHTML = `<p class="muted small">${esc(people.slackProblem || 'Slack members are not available.')} <a href="/_hub/settings">Hub settings</a></p>`;
|
|
395
|
+
else list.innerHTML = people.slack.filter((p) => fits(p.name, $('#slackFilter').value) && !taken.has(p.name.toLowerCase()))
|
|
396
|
+
.map((p) => `<button type="button" class="ghost" data-slack="${esc(p.id)}" data-name="${esc(p.name)}">${esc(p.name)}</button>`).join('') || '<p class="muted small">No one matches.</p>';
|
|
397
|
+
$('#addBtn').disabled = !picked.slack || !$('#githubFilter').value.trim();
|
|
398
|
+
}
|
|
399
|
+
function loadPeople() {
|
|
400
|
+
people = null;
|
|
401
|
+
renderPick();
|
|
402
|
+
// The wait says "Board restarting…" only when the hub marked a restart; any other wait leaves Loading… until the
|
|
403
|
+
// list arrives or the retries give out.
|
|
404
|
+
const wait = (ms, down) => { if (down?.state === 'restarting') $('#slackPeople').innerHTML = restartNotice(); };
|
|
405
|
+
get(site.url('api/reviewer-people'), wait).then(({ ok, body }) => { if (!B.alive()) return; people = ok ? { github: [], ...body } : 'failed'; renderPick(); })
|
|
406
|
+
.catch(() => { if (B.alive()) { people = 'failed'; renderPick(); } });
|
|
380
407
|
}
|
|
381
|
-
|
|
382
|
-
.catch(() => { if (B.alive()) $('#slackPeople').innerHTML = '<p class="muted small">Offline. Is the host awake?</p>'; });
|
|
408
|
+
loadPeople();
|
|
383
409
|
B.on($('#slackFilter'), 'input', renderPick);
|
|
384
410
|
B.on($('#githubFilter'), 'input', renderPick);
|
|
385
411
|
B.on($('#slackPeople'), 'click', (ev) => {
|
|
386
|
-
const b = ev.target.closest('button
|
|
387
|
-
if (b)
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
412
|
+
const b = ev.target.closest('button');
|
|
413
|
+
if (!b) return;
|
|
414
|
+
if (b.dataset.retry !== undefined) { loadPeople(); return; }
|
|
415
|
+
if (b.dataset.unpick !== undefined) { picked.slack = null; $('#githubFilter').value = ''; renderPick(); return; }
|
|
416
|
+
if (!b.dataset.slack) return;
|
|
417
|
+
picked.slack = { id: b.dataset.slack, name: b.dataset.name };
|
|
418
|
+
$('#githubFilter').value = guessLogin(picked.slack.name, people.github);
|
|
419
|
+
renderPick();
|
|
420
|
+
$('#pickStatus').textContent = $('#githubFilter').value ? 'Guessed from their name. Change it if it is wrong.' : $('#pickStatus').textContent;
|
|
421
|
+
$('#githubFilter').focus?.();
|
|
392
422
|
});
|
|
393
423
|
B.on($('#addForm'), 'submit', (ev) => {
|
|
394
424
|
ev.preventDefault();
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
picked.slack
|
|
425
|
+
const github = $('#githubFilter').value.trim();
|
|
426
|
+
if (!picked.slack || !github) return;
|
|
427
|
+
add.set(picked.slack.name, { name: picked.slack.name, slack_user: picked.slack.id, github });
|
|
428
|
+
picked.slack = null;
|
|
398
429
|
$('#slackFilter').value = ''; $('#githubFilter').value = '';
|
|
430
|
+
$('#pickStatus').textContent = '';
|
|
399
431
|
renderPick();
|
|
400
432
|
render();
|
|
401
433
|
});
|