From 134fe4fcbd4d31975a655b606c799a3083fce92c Mon Sep 17 00:00:00 2001 From: Bryan Matthew Simonson <7519963+bryanmatthewsimonson@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:05:45 -0700 Subject: [PATCH] fix(synthesis): accepting a proposal no longer jumps the scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Accept fired onChanged → onReloadCase → render(), a full portal re-render that rebuilt #xr-view (the synthesis block repopulates async), resetting the scroll to the bottom on each accept — batch-accepting was miserable. The re-render was redundant: the handler already marks the row ✓ in place and persists through the model firewalls. - drop the per-accept re-render (removed onChanged from renderProposals and its wiring in synthesis-block) - in-place feedback instead: row shows ✓, the open count ticks down, and a hint notes "N accepted — Refresh to fold them into the case view" - Dismiss now also decrements the count (already re-render-free) The graph/dossier fold accepted claims/links in on the next Refresh. Membership add/remove keeps its own onReloadCase — unchanged. Co-Authored-By: Claude Opus 4.8 --- docs/JOURNAL.md | 22 ++++++++++++++++++ src/portal/synthesis-block.js | 7 ++++-- src/portal/synthesis-review.js | 41 +++++++++++++++++++++++++++++----- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 8bdb556..df8e1d9 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -19,6 +19,28 @@ or files, and the "so-what" for future readers. --- +## 2026-07-18 — Accepting a corpus proposal jumped the scroll to the bottom + +**Tags:** bug + +Every "Accept" on a synthesis proposal fired `onChanged → onReloadCase → +render()`, a FULL portal re-render. `render()` rebuilds `#xr-view` and the +synthesis block repopulates asynchronously, so the scroll reset (landing +at the bottom) on every single accept — batch-accepting a run of proposals +was miserable. The re-render was also redundant: the accept handler already +updates the row in place (`✓`) and persists through the model firewalls +(EvidenceLinker / ClaimModel) independently of any render. + +Fix: drop the per-accept re-render. `renderProposals` now gives in-place +feedback only — the row marks `✓`, the open count ticks down, and a hint +notes "N accepted — Refresh to fold them into the case view." The graph/ +dossier reflect the accepted claims/links on the next Refresh (the batch +workflow), never mid-triage. Dismiss already used `row.remove()` (no +re-render) and was unaffected; it now also decrements the count. +`onChanged` is removed from the `renderProposals` contract. +(Membership add/remove still re-renders via its own `onReloadCase` — that +path is unchanged.) + ## 2026-07-18 — The claim-join narrowed the brief: representative digest + a map/reduce version split **Tags:** design diff --git a/src/portal/synthesis-block.js b/src/portal/synthesis-block.js index 39cd03c..6744935 100644 --- a/src/portal/synthesis-block.js +++ b/src/portal/synthesis-block.js @@ -333,8 +333,11 @@ export function renderSynthesisBlock(host, { data, dossier, callbacks = {} }) { onTriage: async (key, status) => { record.triage = { ...(record.triage || {}), [key]: status }; await saveCaseBrief(record); - }, - onChanged: () => callbacks.onReloadCase && callbacks.onReloadCase() + } + // No onChanged: accepting updates the row in place and + // persists through the model firewalls; a full re-render + // per Accept reset the scroll (jumped to the bottom). + // The case view folds accepted items in on the next Refresh. }); }; diff --git a/src/portal/synthesis-review.js b/src/portal/synthesis-review.js index b0219c9..e70ef4b 100644 --- a/src/portal/synthesis-review.js +++ b/src/portal/synthesis-review.js @@ -74,8 +74,14 @@ async function accept(p, { model, memberByHash }) { * Render the proposals list into `host`. `acceptable`/`rejected` come * from filterProposals; `claimsById` labels existing refs; * `memberByHash` (article_hash → {url, caseId}) resolves claim - * proposals; `model` stamps suggested_by; `onChanged` re-renders the - * case view after an accept. + * proposals; `model` stamps suggested_by. + * + * Accepting a proposal applies it immediately through the model + * firewalls and marks the row in place (✓). It deliberately does NOT + * re-render the whole case view: a per-accept full re-render reset the + * scroll position and threw the reader to the bottom, which made + * accepting a run of proposals painful. The graph/dossier fold the + * accepted claims/links in on the next Refresh. * * 27 S.3: `triage` is the brief record's per-proposal status map * (proposalKey → 'accepted' | 'dismissed'); `onTriage(key, status)` @@ -85,7 +91,7 @@ async function accept(p, { model, memberByHash }) { * dismissal), and a zero-proposal run says so instead of rendering * nothing. */ -export function renderProposals(host, { acceptable, rejected, claimsById, memberByHash, model, onChanged, triage = {}, onTriage }) { +export function renderProposals(host, { acceptable, rejected, claimsById, memberByHash, model, triage = {}, onTriage }) { host.replaceChildren(); const setTriage = async (key, status) => { if (typeof onTriage === 'function') { @@ -96,8 +102,9 @@ export function renderProposals(host, { acceptable, rejected, claimsById, member const { open, accepted, dismissed } = partitionProposals(acceptable, triage); - host.appendChild(el('h4', 'xr-case__heading', - `Proposals — review and accept (${open.length})`)); + const heading = el('h4', 'xr-case__heading', + `Proposals — review and accept (${open.length})`); + host.appendChild(heading); if (open.length === 0 && accepted.length === 0 && dismissed.length === 0 && (!rejected || rejected.length === 0)) { host.appendChild(el('div', 'xr-inspector__mono', @@ -105,6 +112,26 @@ export function renderProposals(host, { acceptable, rejected, claimsById, member return; } + // Live, in-place feedback so a run of Accepts never re-renders (and + // never jumps the scroll): the open count ticks down and a hint + // appears. The accepted claim/link is already persisted; the case + // view folds it in on the next Refresh. + let openRemaining = open.length; + let acceptedCount = 0; + const updateCount = () => { + heading.textContent = `Proposals — review and accept (${openRemaining})`; + }; + const refreshHint = el('div', 'xr-synth__status'); + refreshHint.hidden = true; + host.appendChild(refreshHint); + const noteAccepted = ({ wasOpen }) => { + if (wasOpen) { openRemaining = Math.max(0, openRemaining - 1); updateCount(); } + acceptedCount += 1; + refreshHint.hidden = false; + refreshHint.textContent = + `${acceptedCount} accepted — Refresh (top right) to fold them into the case view.`; + }; + const proposalRow = (p, key, { undismiss = false } = {}) => { const row = el('div', 'xr-synth__prop'); row.appendChild(el('span', 'xr-synth__prop-desc', describe(p, claimsById))); @@ -117,7 +144,7 @@ export function renderProposals(host, { acceptable, rejected, claimsById, member await accept(p, { model, memberByHash }); await setTriage(key, 'accepted'); row.replaceChildren(el('span', 'xr-synth__prop-desc', '✓ ' + describe(p, claimsById))); - if (typeof onChanged === 'function') onChanged(); + noteAccepted({ wasOpen: !undismiss }); } catch (err) { Utils.error('Proposal accept failed', err); acceptBtn.disabled = false; @@ -130,6 +157,8 @@ export function renderProposals(host, { acceptable, rejected, claimsById, member rejectBtn.type = 'button'; rejectBtn.addEventListener('click', async () => { await setTriage(key, 'dismissed'); + openRemaining = Math.max(0, openRemaining - 1); + updateCount(); row.remove(); }); row.appendChild(rejectBtn);