fix(chat): preserve virtualized rows and anchors during upward scrolling
Summary
Leo reported that upward scrolling causes a “weird recalculation.” A stale queued scroll frame was dropped whenever the native offset moved again before its callback. Under continuous wheel input the virtualized range stopped advancing, and newly measured rows also anchored an already parked reader from a stale offset.
Recover stale frames from the live offset, route the resulting follow state through the authoritative ref/parent callback, and anchor recent upward input from the live offset even after follow was already released. The existing 4px follow deadband and shared anchor compensator remain in use. The change adds no transcript-wide loop.
This is the focused correctness split from the incomplete rowDelta performance program. #26591 remains blocked on expanded child-list traversal per streamed chunk. The rowDelta producer/consumer work remains separate and blocked while transformed arrays lose provenance.
Testing
The 3,000-turn upward-scroll oracle (webviews/test/transcript-upward-anchor.test.ts, WebKit, 9,000 source rows, 240 fractional precise-pixel wheels plus 24 rest frames = 264 after-paint observations) measures, at every frame, how far the first visible row moved beyond the distance the wheel applied.
- Before: run 40636441298, source
eba7eaf8e50c8d6c192b3ce17eaa2f5e65d16580: 168 blank frames, 96 comparable observations, max residual drift 56.5px. - First repair: run 40637819221, source
1fa5b53fb3f7c77e42016d80f09aff44d2e5085a: 0 blank frames, 264 comparable observations, transient drift up to 42.5px. - This head, after the live-reader repair: 0 blank frames, 264/264 compared, 0 cancelled wheels, max drift 0.5px (12 of 264 frames, every one +0.5px; total 6 to 6.5px over 8,820px of wheel).
Tolerance, stated honestly: the oracle was first written with a 1/64px bound, which this branch never met: fast-checks run 41106367530 at a4b8faef fails with Expected: <= 0.015625, Received: 0.5. WebKit keeps scrollTop in whole CSS pixels (the trace's applied wheel distances are whole: -18.25 asked, -19 applied) while rows lay out on quarter pixels, and the anchor correction skips residuals of 0.5px or less (Math.abs(nextTop - top) > 0.5) rather than fight that rounding. So the assertion is now maxDrift <= 0.5, the half-pixel quantum this change actually achieves; fast-checks run 41106639072 at 060d27d8 passes (1 pass, 0 fail). A residual above half a pixel, or any blank frame, still fails.
Focused tests: 5 tests / 33 assertions in scroll-pacer.test.ts and transcript-jump-latest.test.tsx, including real VirtualTranscript stale-queued-frame recovery, the parent onFollowingChange callback, and no later snap. Reverting just the authoritative setter reproduces expected false / received true at the parent callback.
Observed / Not run
- Observed: the WebKit oracle red at
a4b8faef(0.5px vs 1/64px) and green at060d27d8, both throughcmux-ci teston Linux fast checks. - Not run: native CEF pixel proof and trusted OS wheel input; Chromium's fractional
scrollToppath is covered only by the PR's normal CI shards.
Performance
Same 3,000-turn upward-scroll oracle (9,000 source rows, 264 after-paint observations).
| metric | before | after | delta | harness or run link |
|---|---|---|---|---|
| blank frames during continuous upward wheel | 168 | 0 | -168 (-100%) | upward-anchor oracle: before https://github.com/manaflow-ai/cmux/actions/runs/40636441298, after https://github.com/manaflow-ai/cmux/actions/runs/41106639072 |
| comparable after-paint observations | 96 | 264 | +168 | same runs |
| max residual anchor drift | 56.5px | 0.5px | -56px (-99%) | same runs |
Repair for the Astra P2 at 3bd24d63
readerLeft had lost its following gate, so a parked reader's click, downward key or wheel made the live offset the anchor base and a shrink above was applied twice. A parked view now takes the live offset only after upward input (readerUpAt); a following view keeps any input as before.
- Red:
bun test test/transcript-jump-latest.test.tsxat bf2a23b3, "a parked reader's downward input does not double-apply a row above that shrank" moved the row 811.75px (https://github.com/manaflow-ai/cmux/actions/runs/41127844886). - Green at 94adf956:
bun run check, the jsdom transcript tests and the WebKit/Chromium browser tests (transcript-upward-anchormaxDrift 0.5,transcript-parked-stream) pass (https://github.com/manaflow-ai/cmux/actions/runs/41127878300). - The stale-frame test now parks on a whole pixel, as the jsdom scroller keeps since eea84b4fd25.
- Second Astra P2 at 94adf956 (a click inside an upward wheel's 1 s window still used the upward stamp): every reader input now sets
readerUpAt, upward input stamps it and any other clears it. Red at c93b3e85: the same test's "just after an upward wheel" case moved the row 811.75px (https://github.com/manaflow-ai/cmux/actions/runs/41130273608). Green at 10f1afbd:bun run check, the jsdom tests and both browser tests pass (https://github.com/manaflow-ai/cmux/actions/runs/41130737174). - Third Astra P2 at 10f1afbd (an upward wheel whose scroll event was already processed still counted for 1 s, so a shrink above in that window was applied twice):
readerUpAtnow means upward input the transcript has not processed yet. The next handled scroll event clears it, soreaderLeftuses the live offset only for unprocessed upward movement, with the 1 s window kept only as a cap. Red at 10f1afbd (test only): the same test's new no-click case, an upward wheel and its processed scroll followed by the shrink, moved the row 811.75px (https://github.com/manaflow-ai/cmux/actions/runs/41132139218). Green at 1a4883c5:bun run check,transcript-jump-latest,transcript-upward-anchor(maxDrift 0.5),transcript-parked-stream,scroll-pacerandtranscript-flingpass, 12/12 (https://github.com/manaflow-ai/cmux/actions/runs/41132265174).
Agent session: agent session