fix(stream): preserve scroll position after live-to-final settlement (#6385) - #6390
2 commits merged into
Conversation
…nt collapse Issue nesquena#6385: when a streaming turn settles, the two-render sequence (keep-open expanded worklog → collapsed worklog) could displace the reader's viewport because the second render captured its scroll snapshot from the intermediate expanded state, not from the original live DOM. Root cause ---------- The STREAM_DONE handler in messages.js: 1. Arms keep-settled-worklog-open token → renderMessages({preserveScroll:true}) → worklog rendered EXPANDED (height-stable swap preventing shrink jump) 2. Disarms token → _renderMessagesWithScrollSnapshot() → This function called _captureMessageScrollSnapshot() which captured the scroll anchor from the expanded-worklog DOM (step 1 output), then called renderMessages with the worklog COLLAPSED (keep-open gone), then tried to restore from the expanded-state snapshot. The snapshot's semantic anchor (row key, session idx, top offset) was captured from a DOM where the worklog was expanded. After the collapse render the worklog is no longer at that position — anchor keys don't match, the semantic restore fails, and the viewport jumps to a unrelated scrollTop. Fix --- - Capture the scroll snapshot from the LIVE DOM (before any settlement renders) and pass it as to the second render. - Modify _renderMessagesWithScrollSnapshot() to accept a pre-captured snapshot via options._prescrollSnapshot, using it instead of capturing a fresh one from the current DOM state. This ensures the collapse render anchors to the content the reader was actually viewing, not to a stale intermediate expanded-worklog position. Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
|
| Filename | Overview |
|---|---|
| static/messages.js | Captures the live scroll state before the temporary worklog expansion and passes it to the final render. |
| static/ui.js | Allows the scroll-preserving render helper to reuse a supplied snapshot while keeping existing fallback behavior. |
| tests/test_issue4970_stream_done_shrink_regression.py | Adds checks for snapshot ordering, forwarding, restoration, and calls without a supplied snapshot. |
Reviews (2): Last reviewed commit: "fix tests for #6385 scroll snapshot: upd..." | Re-trigger Greptile
SummaryReading both changed files in full at 25d39da against origin/master, the timing change is coherent: static/messages.js captures while the live DOM still exists, before the one-shot keep-open render, and static/ui.js lets the collapse pass restore that earlier anchor. I do not see a product-path contradiction in the two-file change. The branch is not ready as submitted, however, because it deterministically breaks the existing #4970 regression test and adds no replacement coverage for the new pre-captured-snapshot contract. Code referenceThe new done path at static/messages.js:5944-5953 changes the exact collapse call to: const _doneLiveScrollSnapshot=typeof _captureMessageScrollSnapshot==='function'
? _captureMessageScrollSnapshot()
: null;
if(typeof _armKeepSettledWorklogOpen==='function') _armKeepSettledWorklogOpen(_settledStreamId);It later calls _renderMessagesWithScrollSnapshot with an argument. The unchanged assertion at tests/test_issue4970_stream_done_shrink_regression.py:177-185 still requires the literal no-argument call and then indexes that exact string: assert "_renderMessagesWithScrollSnapshot()" in after
collapse_pos = after.index("_renderMessagesWithScrollSnapshot()")
follow_pos = after.index("shouldFollowOnDone")That literal no longer exists in the STREAM_DONE slice, so this test fails before it can check ordering. The helper change at static/ui.js:15088-15097 is also currently uncovered: tests/test_issue3479_ios_stream_scroll_jump.py:99-110 verifies capture, render, and restore exist, but does not prove that a supplied snapshot is used instead of a fresh capture. Diagnosis / recommendationUpdate the #4970 test to look for the argument-bearing call and retain the important ordering assertion: disarm, collapse render with the live snapshot, then shouldFollowOnDone. Better, avoid pinning the whole call spelling and assert the pre-capture variable is created before arm and threaded into the collapse pass after disarm. Add a small behavioral harness for _renderMessagesWithScrollSnapshot that supplies a sentinel snapshot, stubs _captureMessageScrollSnapshot with a counter, and proves the sentinel reaches _restoreMessageScrollSnapshotSameFrame without a second capture. Also prove the no-option callers at static/ui.js:9565 and static/ui.js:14416-14424 still capture normally. One cleanup would make the helper contract less leaky: remove _prescrollSnapshot from the object passed to renderMessages rather than forwarding an internal transport option into the general renderer. That is not the primary blocker, but it keeps the options surface explicit. Verification stepPlease run the focused #4970 and #3479 scroll suites plus the browser reproduction from #6385 after updating the tests. I did not execute PR-authored code because scheduled review treats PR worktrees as untrusted; the failing assertion is established directly from the unchanged test and changed call site. |
…ertion + add behavioral coverage
- Fix test_stream_done_runs_scroll_preserving_collapse_pass_after_disarm:
assert _doneLiveScrollSnapshot is captured before arm, and the
argument-bearing _renderMessagesWithScrollSnapshot({_prescrollSnapshot:_doneLiveScrollSnapshot})
call is used after disarm (the no-arg literal no longer exists).
- Add test_prescroll_snapshot_bypasses_capture_no_option_fallback_still_captures:
Node.js behavioral harness that supplies a sentinel _prescrollSnapshot,
stubs _captureMessageScrollSnapshot with a counter, and proves:
1) sentinel reaches _restoreMessageScrollSnapshotSameFrame without a capture
2) no-option / empty-{} calls still capture normally (callers at
static/ui.js:9565, 14416, 14424).
|
@nesquena-hermes ✅ Fixed:
15/15 tests passing. Ready for re-review! |
🔬 Gate certification — GREEN ✅Certified head: What I ran (isolated exact-head worktree
|
| Gate | Result |
|---|---|
| Threat / identity | CLEAN (score 0); exact contributor delta and rebased artifact have the same stable patch-id; live head rechecked unchanged before publication |
| Codex (reproduce) | SAFE TO SHIP — no regression risk; independently drove desktop + 390×844 settlement with 0px anchor drift |
| Senior correctness review | APPROVE — pinned, unpinned, missing-anchor, virtualization, double-render, cache, and mobile-native-anchor paths reviewed; no blocker |
| Fable UX | SHIP-UX — no new chrome; behavior matches ChatGPT/Claude's preserve-reader-position contract; 20-cell desktop/mobile regression probe clean |
| Full serial pytest | Completed to the end: 13,506 passed, 82 skipped, 1 xfailed, 2 xpassed, 34 subtests. The 15 failures + 2 collection errors are byte-for-byte the existing frozen-master approval/keyless-onboarding isolation baseline (13,505 passed there); this PR adds one pass and no new failure |
| Focused / test bite | 15/15 targeted scroll tests passed. Replacing the production JS with frozen-master behavior makes both new contract tests fail, proving the coverage discriminates the fix |
| Static / private gates | git diff --check, Node syntax, runtime ESLint, scope-undefined, browser smoke, and threat scan clean |
| Real browser / live drive | Real Chromium lifecycle on desktop 1280×800 and mobile 390×844: normal settlement passed for both unpinned and pinned readers; unpinned semantic-anchor drift was 0px, pinned readers remained at bottom. Terminal-error settlement also passed unpinned desktop and pinned mobile, including reload parity and no product console errors |
Findings
- ✅
static/messages.js:5962-5969now captures the reader's geometry from the live DOM before arming the intermediate keep-open render, then threads that exact snapshot into the collapse restore. - ✅
static/ui.js:15309-15318preserves the supplied snapshot by identity and retains fresh-capture behavior for every no-option caller. - ✅ The prior coverage blocker is closed: sentinel identity, zero recapture, fallback callers, and capture-before-arm ordering are executable assertions and go red on master behavior.
⚠️ The new test docstring's caller line numbers drifted by one during rebase; cosmetic only.⚠️ This PR fixes the reader-displacement half of Live-to-final settlement reorders visible content and displaces the reader viewport #6385. The separate semantic segment-order/identity acceptance criterion is not implemented here, so Live-to-final settlement reorders visible content and displaces the reader viewport #6385 should remain open or that remainder should be tracked separately.⚠️ This is motion on the crown-jewel chat surface. Sparse stills would not prove it. Before merge, Nathan should review realistic tall-worklog video for desktop/mobile pinned + unpinned states and a real iOS Safari pass; Chromium emulation cannot certify WebKit's inertoverflow-anchorexception.
Recommendation to the next agent
Technically green — cert fresh for sha:f35081683af2. Add to Tier 1, but merge only after Nathan's crown-jewel visual sign-off and real iOS Safari motion check. Preserve @webtecnica attribution, keep #6385's remaining semantic-order criterion open, and re-gate if feature code changes.
Gate-certifier layer (warm-up → gate → release). I do not merge/tag/deploy — that's the release agent's call. This cert is valid only while the head stays at sha:f35081683af2.
9e1b8a1
|
Shipped in exp-v0.52.143 (experimental channel) — thanks @franksong2702. Your scroll-anchor fix is live: the transcript now stays put when a streaming reply settles while you're reading history. Full gate before ship — Codex (SAFE), full 3-shard suite (0 failures), Fable UI/UX (SHIP-UX, confirmed the bottom-pinned auto-follow path is preserved), plus a maintainer live-drive that measured the anchor held (scrollTop 4000→4068, no jump). |
Summary\n\nLive-to-final settlement reorders visible content and displaces the reader's scroll position.\n\n## Root Cause\n\nSTREAM_DONE handler does a two-render sequence: first expanded worklog, then collapsed. _renderMessagesWithScrollSnapshot captured the scroll anchor from the expanded DOM, then rendered collapsed — anchor lookup fails, viewport jumps.\n\n## Change\n\nModified _renderMessagesWithScrollSnapshot() to accept an optional prescrollSnapshot parameter. When provided, uses pre-captured state instead of re-capturing from the (potentially expanded) current DOM.\n\n## Verification\n\nESLint runtime guard clean.