fix: stop streaming from recapturing a parked transcript reader
Leo: "Oh my God. It's flickering way too often right now."
Earlier: "im still getting flickering rerenderings on certain weird scroll things when you're streaming text and im on some in between zone".
A reader who scrolls up even 3px now stays parked while a reply streams, including after the 1000ms input window expires. Follow resumes when the reader scrolls down into the existing 4px bottom deadband or explicitly jumps/sends. Layout snapshots preserve follow intent instead of inferring it from proximity, and the delayed 24px stray-snap path is removed. Long pointer/touch gestures refresh the existing input timestamp so their trip back to bottom still resumes follow.
Fixed head: e12f4b97362abe6978906bd54b49a0415f8bb9aa.
Standalone P0 from 8711a8ca8333d9ba9f149999fdd49e4c62b846eb (origin/feat-cmux-next), not stacked on #25770. The regression was pushed first as d144274e9b7272cfbd0ddc3318e6a7071eecfed7. Owner: rail-opus reviews and lands; this helper will not merge.
Real Chromium, same VirtualTranscript harness, 20 streaming chunks after a real wheel parks the reader and the input window expires:
| Distance | Base flips / writes / movement | Fixed flips / writes / movement |
|---|---|---|
| 3px | 1 / 19 / 701px | 0 / 0 / 0px |
| 10px | 0 / 0 / 0px | 0 / 0 / 0px |
| 20px | 0 / 0 / 0px | 0 / 0 / 0px |
| 40px | 0 / 0 / 0px | 0 / 0 / 0px |
| 80px | 0 / 0 / 0px | 0 / 0 / 0px |
| Pinned | 0 / 19 / 698px, expected follow | 0 / 19 / 698px, expected follow |
The reproduced failure is one recapture followed by per-chunk writes, not repeated alternating follow flips. The actual writer is settleFollowBottom: VirtualTranscript's layout-follow path geometrically restores scrolledTo.atLatest while followingRef is false, then queueScroll enables following and scroll-layout keeps it pinned. The stray-snap branch did not fire in this reproduction, but its expired-input recapture is removed too. Shared preserveScrollAnchorTop and FOLLOW_BOTTOM_DEADBAND_PX are unchanged. nx-chat was sent the caller evidence (transport queued receipt 01a17caf-0fe8-78f1-8c5b-1c66d5664edf).
The test logs every token's followingRef/atLatest state, queueScroll, layout-follow/layout-anchor/scroll-layout branches, and every scrollTop setter stack. Base 3px per-token write counts: [0,1,0,1,0,1,1,1,1,1,1,2,1,1,1,1,1,2,1,1]; fixed: twenty zeros. All probes are test-only. It also checks that a downward wheel, a long held pointer drag, and touch movement resume following. Chromium screenshot inspected locally; recordings were explicitly stopped for this P0. Reference notes reviewed: Claude/Codex live captures do not contain a running transcript to compare; this change adds no visual styling or motion.
Expanded painted-frame diagnostic (real Chromium; markdown paragraph → open/close code fence → list, tool running→completed, and above-viewport row height revisions; 65 painted frames per distance):
| Parked distance | Base: scroll writes / row jitter / remounts / commits / layout shifts / false 56px fade frames | Fixed: scroll writes / row jitter / remounts / commits / layout shifts / false 56px fade frames |
|---|---|---|
| 10px | 5 / 1 (92.5px thinking row enter) / 1 / 44 / 2 / 0 | 5 / 1 (same expected enter) / 1 / 49 / 1 / 0 |
| 20px | 5 / 1 (92.5px thinking row enter) / 0 / 45 / 2 / 0 | 5 / 1 (same expected enter) / 1 / 52 / 2 / 0 |
| 40px | 3 / 0 / 0 / 39 / 1 / 1 | 3 / 0 / 0 / 42 / 1 / 0 |
| 80px | 5 / 0 / 0 / 45 / 1 / 0 | 5 / 0 / 0 / 52 / 1 / 0 |
| 150px | 3 / 0 / 0 / 39 / 0 / 0 | 3 / 0 / 0 / 42 / 0 / 0 |
The 40px base flash was caused by preserveScrollAnchorTop's caller writing the corrected DOM offset (1774→1842) while React's renderScroll.top still held 1774 until the later scroll event. The computed gap temporarily crossed the 80px Jump threshold, changing the mask 18→56→18px for one painted frame. The fix reads the rounded DOM scrollTop, cancels the stale pacer, and calls setScrollAt in that same layout pass. The anchor contract is unchanged. No stable existing text moved; the 10/20px 92.5px row movement was a newly mounted thinking-live row enter, with one remount and layout-shift entries, not scroll drift.
Validation:
- New real-Chromium regression fails on base and passes with the fix (33 assertions); the pinned case continues following with a 0px final gap.
- Existing Chromium transcript-follow tests pass (stream/next turn, touch reserved room, upward-wheel/snapshot race).
- Anchor, hidden-follow, Find-follow and fold-anchor tests: 16 passed, 86 assertions.
- Existing transcript-pinned-bottom: 1 passed, 2 failed on both unchanged base and fixed code. Baseline failures are its per-resize newest-line assertion (>1px) and post-send frame gap assertion (3–4px). WebKit is unavailable in this environment.
tsc --noEmitand changed-file formatting pass. Changed-file vp lint reports only the same two hook-dependency warnings present on unchanged base (unlift,follow).bun run check: browser-lanes, fixed-waits, design tokens and layers pass; stops at existing raw font size in unchangedSendScrollSetting.gallery.tsx:71on base8711a8ca8333d9ba9f149999fdd49e4c62b846eb. Push uses the documented base-debt exception. Prepush gate: no new violations (2 own commits over the recorded base).
No new production timers or per-token row walks. New touchmove and document pointermove/pointerup/pointercancel listeners are all removed by the existing effect cleanup; drag state is local to that effect. Review found and resolved the long-drag input-expiry edge case.
Agent session: agent session
Touch/pointer review fix at e12f4b97362abe6978906bd54b49a0415f8bb9aa: the existing input effect now compares pointer/touch movement direction and releases follow synchronously before the delayed scroll event. Chromium regression covers mouse pointer, touch pointer (pointerType: "touch"), and TouchEvent 3px upward moves: INPUT_RACE [{"input":"pointer","movement":0},{"input":"touch","movement":0},{"input":"touch-pointer","movement":0}]. Base pointer movement moved 40px back toward bottom before the delayed event.