From e67ab2e042d37d717d2761f4a7e430504cdf2fae Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 23:08:01 -0500 Subject: [PATCH] fix(desktop): stop chat scroll jumping by disabling native scroll anchoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread renders virtualized turns in natural document flow with padding spacers, and @tanstack/react-virtual already adjusts scrollTop itself when an off-screen turn is measured and its real height differs from the 220px estimate. With the browser default `overflow-anchor: auto`, native scroll anchoring corrects that SAME size delta too, so the two double-correct and the view lurches — most visibly with Windows mouse wheels, whose coarse notches mount/measure several under-estimated turns per tick (Mac trackpads scroll ~1-3px/frame, keeping it sub-perceptual). Set `overflow-anchor: none` on the thread viewport so only the virtualizer compensates. Also adds `diag-scroll-reset.mjs`, a CDP wheel-up repro that A/B tests the anchor behavior at runtime to confirm the fix. --- apps/desktop/scripts/diag-scroll-reset.mjs | 229 +++++++++++++++++++++ apps/desktop/src/styles.css | 12 ++ 2 files changed, 241 insertions(+) create mode 100644 apps/desktop/scripts/diag-scroll-reset.mjs diff --git a/apps/desktop/scripts/diag-scroll-reset.mjs b/apps/desktop/scripts/diag-scroll-reset.mjs new file mode 100644 index 000000000..11c7997cb --- /dev/null +++ b/apps/desktop/scripts/diag-scroll-reset.mjs @@ -0,0 +1,229 @@ +// Reproduce + diagnose the "scroll wheel resets position while reading" bug. +// +// The complaint (Windows, mouse wheel): scrolling UP through a chat to re-read +// older content randomly yanks the view to a different position, so you have to +// fight the scrollbar. Mac users on trackpads don't see it. +// +// Hypothesis: the thread scroller has the browser default `overflow-anchor: +// auto`, and the thread renders items in natural document flow (padding +// spacers, NOT transforms). When an item above the viewport is measured by +// @tanstack/react-virtual (its real height differs a lot from the 220px +// estimate) — or when Shiki/images/fonts reflow it — TWO mechanisms both +// adjust scrollTop for the same delta: TanStack's measurement compensation AND +// the browser's native scroll anchoring. The double-correction lurches the +// view. A mouse wheel's coarse, discrete notches mount/measure several +// under-estimated turns per tick, so the over-correction is large and visible; +// a trackpad's ~1-3px/frame keeps it sub-perceptual. +// +// This script drives synthetic mouse-wheel-UP scrolling on a long thread and +// measures how much a tracked on-screen turn jumps, first with +// `overflow-anchor: auto` (reproduce) then `overflow-anchor: none` (the fix). +// If the fix run shows dramatically fewer/smaller jumps, the hypothesis holds. +// +// Prereq: a running desktop app with remote debugging on 9222, on a thread +// with enough history to scroll (the longer / more code+tool blocks, the +// better the repro). Then: node apps/desktop/scripts/diag-scroll-reset.mjs + +const NOTCHES = 14 // wheel-up ticks per sweep +const NOTCH_PX = 120 // Windows wheel notch ≈ 120px +const NOTCH_GAP_MS = 130 // let each smooth-scroll animation settle +const REVERSE_JUMP_PX = 6 // tracked turn moving UP while scrolling up = wrong way +const LURCH_PX = 60 // single-frame on-screen jump that reads as a "reset" + +const list = await (await fetch('http://127.0.0.1:9222/json/list')).json() +const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http')) +if (!tgt) { + console.error('No page target on :9222. Is the desktop app running with --remote-debugging-port=9222?') + process.exit(1) +} +const ws = new WebSocket(tgt.webSocketDebuggerUrl) +let id = 0 +const pending = new Map() +ws.addEventListener('message', ev => { + const m = JSON.parse(ev.data) + if (m.id != null && pending.has(m.id)) { + pending.get(m.id)(m) + pending.delete(m.id) + } +}) +await new Promise(r => ws.addEventListener('open', r)) +const send = (m, p = {}) => + new Promise(r => { + const i = ++id + pending.set(i, r) + ws.send(JSON.stringify({ id: i, method: m, params: p })) + }) +const evalP = async expr => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }) + if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text) + return r.result.result.value +} +const sleep = ms => new Promise(r => setTimeout(r, ms)) + +// Install per-sweep instrumentation. `mode` is the overflow-anchor value to +// force inline so we A/B the exact same thread regardless of any CSS fix. +// Starts from ~45% down the thread so there's room to scroll up into +// not-yet-measured turns, tags the turn nearest viewport-center as the anchor, +// then records (per rAF) scrollTop + that turn's on-screen top, plus every +// scrollTop *setter* write (TanStack compensation) and ResizeObserver hit. +async function arm(mode) { + await evalP(`(() => { + const v = document.querySelector('[data-slot="aui_thread-viewport"]') + if (!v) throw new Error('thread viewport not found') + + // Force the overflow-anchor behavior under test (inline beats CSS). + v.style.overflowAnchor = ${JSON.stringify(mode)} + + // Park ~45% down so a wheel-up sweep climbs into estimated-but-unmeasured + // turns above the fold (where the measurement correction fires). + v.scrollTop = Math.round(v.scrollHeight * 0.45) + + // Tag the turn closest to viewport center; we track its on-screen top. + const vr = v.getBoundingClientRect() + const center = vr.top + v.clientHeight / 2 + let best = null, bestD = Infinity + for (const el of v.querySelectorAll('[data-index]')) { + const r = el.getBoundingClientRect() + const d = Math.abs((r.top + r.height / 2) - center) + if (d < bestD) { bestD = d; best = el } + } + document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor')) + if (best) best.setAttribute('data-se-anchor', '1') + const anchorIndex = best ? best.getAttribute('data-index') : null + + const samples = [] + const writes = [] + const ros = [] + const t0 = performance.now() + + // Intercept scrollTop writes → these are JS (TanStack) corrections. + // Native browser scroll anchoring does NOT go through this setter, so a + // scrollTop change with no write in the same frame is a native adjust. + const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop') + Object.defineProperty(v, 'scrollTop', { + configurable: true, + get() { return desc.get.call(this) }, + set(val) { + writes.push({ t: performance.now() - t0, val, sh: this.scrollHeight }) + desc.set.call(this, val) + } + }) + window.__restoreScrollTop = () => Object.defineProperty(v, 'scrollTop', desc) + + const ro = new ResizeObserver(entries => { + for (const e of entries) { + ros.push({ t: performance.now() - t0, slot: e.target.getAttribute?.('data-slot') || e.target.tagName, h: Math.round(e.contentRect.height) }) + } + }) + ro.observe(v) + if (v.firstElementChild) ro.observe(v.firstElementChild) + + let running = true + const tick = () => { + if (!running) return + const a = v.querySelector('[data-se-anchor]') + const ar = a ? a.getBoundingClientRect() : null + samples.push({ + t: performance.now() - t0, + st: Math.round(v.scrollTop * 100) / 100, + sh: v.scrollHeight, + ch: v.clientHeight, + atop: ar ? Math.round(ar.top * 100) / 100 : null, + aconn: !!a + }) + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + + window.__se = { samples, writes, ros, anchorIndex, dpr: window.devicePixelRatio, stop() { running = false; ro.disconnect(); window.__restoreScrollTop?.() } } + return true + })()`) +} + +async function wheelUpSweep() { + const { x, y } = await evalP(`(() => { + const v = document.querySelector('[data-slot="aui_thread-viewport"]') + const r = v.getBoundingClientRect() + return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } + })()`) + + for (let i = 0; i < NOTCHES; i++) { + await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX: 0, deltaY: -NOTCH_PX }) + await sleep(NOTCH_GAP_MS) + } + await sleep(400) +} + +async function collect() { + const data = JSON.parse(await evalP(`(() => { window.__se.stop(); return JSON.stringify(window.__se) })()`)) + return data +} + +function analyze(label, data) { + const { samples, writes, ros, anchorIndex, dpr } = data + let reverseJumps = 0 + let reverseSum = 0 + let lurches = 0 + let maxJump = 0 + let nativeMoves = 0 + let prev = null + for (const s of samples) { + if (prev && prev.aconn && s.aconn && prev.atop != null && s.atop != null) { + const dTop = s.atop - prev.atop // wheel-up should move content DOWN → dTop >= 0 + const dSt = s.st - prev.st + // Native (browser-anchoring) move: scrollTop changed with no setter write in this frame window. + const wroteThisFrame = writes.some(w => w.t > prev.t && w.t <= s.t) + if (Math.abs(dSt) > 0.5 && !wroteThisFrame) nativeMoves++ + if (dTop < -REVERSE_JUMP_PX) { + reverseJumps++ + reverseSum += -dTop + } + if (Math.abs(dTop) > LURCH_PX) lurches++ + if (Math.abs(dTop) > maxJump) maxJump = Math.abs(dTop) + } + prev = s + } + console.log(`\n── ${label} ──`) + console.log(` devicePixelRatio: ${dpr}${Number.isInteger(dpr) ? '' : ' (fractional — Windows scaling, worsens rounding jitter)'}`) + console.log(` tracked turn index: ${anchorIndex}`) + console.log(` rAF frames: ${samples.length}`) + console.log(` scrollTop writes: ${writes.length} (TanStack measurement corrections)`) + console.log(` ResizeObserver hits: ${ros.length}`) + console.log(` native scroll moves: ${nativeMoves} (scrollTop moved with NO JS write = browser anchoring)`) + console.log(` reverse jumps: ${reverseJumps} (tracked turn yanked UP while scrolling up; total ${reverseSum.toFixed(0)}px)`) + console.log(` big lurches (>${LURCH_PX}px): ${lurches}`) + console.log(` max single-frame jump: ${maxJump.toFixed(0)}px`) + return { reverseJumps, reverseSum, lurches, maxJump, nativeMoves } +} + +console.log(`Wheel-up repro: ${NOTCHES} notches × ${NOTCH_PX}px, anchored mid-thread.\n`) + +await arm('auto') +await sleep(150) +await wheelUpSweep() +const a = analyze('overflow-anchor: auto (current / repro)', await collect()) + +await sleep(300) + +await arm('none') +await sleep(150) +await wheelUpSweep() +const b = analyze('overflow-anchor: none (proposed fix)', await collect()) + +// Clean up our tag. +await evalP(`document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))`) + +console.log('\n══ verdict ══') +const drop = (x, y) => (x === 0 ? (y === 0 ? '0' : 'n/a') : `${Math.round((1 - y / x) * 100)}% fewer`) +console.log(` reverse jumps: auto=${a.reverseJumps} none=${b.reverseJumps} (${drop(a.reverseJumps, b.reverseJumps)})`) +console.log(` big lurches: auto=${a.lurches} none=${b.lurches} (${drop(a.lurches, b.lurches)})`) +console.log(` max jump: auto=${a.maxJump.toFixed(0)}px none=${b.maxJump.toFixed(0)}px`) +console.log(` native moves: auto=${a.nativeMoves} none=${b.nativeMoves} (browser anchoring should ~vanish at none)`) +if (a.reverseJumps + a.lurches > 0 && b.reverseJumps + b.lurches < a.reverseJumps + a.lurches) { + console.log('\n → Jumps drop sharply with overflow-anchor:none → root cause confirmed.') +} else if (a.reverseJumps + a.lurches === 0) { + console.log('\n → No jumps captured this run. Use a longer thread (many code/tool blocks),') + console.log(' raise NOTCHES, and ensure you start scrolled up from the bottom.') +} + +ws.close() diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 581036cde..e84c4f5ee 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -709,6 +709,18 @@ canvas { font-size: var(--conversation-text-font-size); } +/* The thread renders items in natural document flow (padding spacers, not + transforms) and @tanstack/react-virtual already adjusts scrollTop itself + when an off-screen turn is measured and its real height differs from the + 220px estimate. The browser's native scroll anchoring (overflow-anchor: + auto) would adjust scrollTop for that SAME size delta, so the two + double-correct and the view lurches — most visibly on Windows mouse wheels, + whose coarse notches mount/measure several under-estimated turns per tick. + Opt out of native anchoring so only the virtualizer compensates. */ +[data-slot='aui_thread-viewport'] { + overflow-anchor: none; +} + [data-slot='aui_thread-content'] { max-width: var(--composer-width); padding-inline: 1.5rem;