From 6dbf060eb9fa0471e025d4d1563caf469cdaf6e5 Mon Sep 17 00:00:00 2001 From: Its-My-Work Date: Tue, 22 Sep 2026 11:44:11 +0000 Subject: [PATCH] fix(plan): a restored plan_review card drew checkboxes as checked but counted 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found live: a reloaded chat showed every task checked, yet the footer said "0 of 26 selected" and "Approve to Backlog (0)" stayed disabled — clicking a checkbox did nothing. Cause: the default selection ("everything without a lint error of its own") was only ever computed in _prRender (the live WS path). planReviewCardHtml's per-task checkbox and its footer/button read that same default two DIFFERENT ways when _planSelections[messageId] didn't exist yet: a checkbox fell back to `true`, the count/button fell back to an empty Set — checked boxes, zero count. The DB-restore render path (a page reload) never went through _prRender at all, so it always hit this inconsistent fallback; worse, it also never set _planReviews[messageId], so a click's _prTogglePlanTask found no review to look up dependents in and silently no-op'd. - planReviewCardHtml now computes and STORES the default selection itself, once, and every place in the function reads that one value — no more two fallbacks to drift apart. Self-healing: any caller gets a consistent card, restored or live. - The DB-restore call site now sets _planReviews[id] BEFORE rendering, same as _prRender always did, so a click on a restored card works. - Fixed alongside: _prRerenderTasks (runs after every click) updated the checkboxes and the button but never the "N of M selected" line next to it — it went stale after the very first click. Now updates all three. Tests: render/plan-review.test.mjs — a card rendered with NO pre-seeded selection (the exact restore scenario) now has checkboxes, count and button agreeing, and _planSelections gets populated as a side effect; a toggle right after a restore-only render actually mutates the Set. Checked live in real headless Chrome against a real seeded DB row and a real page load: before the fix reproduced verbatim (2 checked boxes, "0 of 2 selected", button disabled(0)); after the fix, consistent from the first render and correct after a click ("1 of 2 selected", button (1), unchecked box stays unchecked) — screenshots in the 2026-09-22 session scratchpad. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Y2ZSHUdJHqVsvSNQ5ZeQ3f --- public/index.html | 27 +++++++++++++++++------- test/render/plan-review.test.mjs | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/public/index.html b/public/index.html index 98279fc..e686c04 100644 --- a/public/index.html +++ b/public/index.html @@ -9491,17 +9491,27 @@

Delegate to Agent

if (!card) return; const review = _planReviews[messageId], sel = _planSelections[messageId]; card.querySelectorAll('.prc-task input[type=checkbox]').forEach(cb => { cb.checked = sel.has(cb.dataset.taskId); }); + const n = sel.size; + const count = card.querySelector('.prc-count'); + if (count) count.textContent = t('plan.selected.count').replace('{n}', n).replace('{total}', review ? review.tasks.length : n); const btn = card.querySelector('.prc-approve-btn'); - if (btn) { const n = sel.size; btn.disabled = n === 0; btn.textContent = n ? t('plan.approve.btn').replace('{n}', n) : t('plan.approve.none'); } + if (btn) { btn.disabled = n === 0; btn.textContent = n ? t('plan.approve.btn').replace('{n}', n) : t('plan.approve.none'); } } function planReviewCardHtml(review, messageId, imported) { + // The one place a selection is defaulted: every task without a lint error of its own, starts + // picked. Self-healing on purpose — a card restored from the DB never went through _prRender, + // so nothing else guarantees _planSelections[messageId] exists yet; computing (and STORING) the + // default here, once, is what makes a checkbox drawn as checked and the "N selected" count + // agree, and what makes clicking that checkbox work on the very first render. + if (!_planSelections[messageId]) _planSelections[messageId] = new Set(review.tasks.filter(t => !t.errors.length).map(t => t.id)); + const sel = _planSelections[messageId]; const errBlock = review.errors.length ? `
⚠️ ${escH(t('plan.errors.title'))}
    ${review.errors.map(e => `
  • ${escH(e)}
  • `).join('')}
` : ''; const warnBlock = (!imported && review.warnings.length) ? `
${escH(t('plan.warnings.title'))}
    ${review.warnings.slice(0, 8).map(w => `
  • ${escH(w)}
  • `).join('')}
` : ''; const depsTitle = (ids) => ids.length ? `${t('plan.deps')} ${ids.join(', ')}` : ''; const rows = review.tasks.map(tk => { - const checked = imported ? true : (_planSelections[messageId] ? _planSelections[messageId].has(tk.id) : true); + const checked = imported ? true : sel.has(tk.id); const issues = [...tk.errors.map(m => `⚠ ${escH(m)}`), ...tk.warnings.map(m => `${escH(m)}`)]; return `