fix(chat): recover viewport anchor via sessionIdx when the content-derived key goes stale (desktop scroll jump-back) - #5469
Conversation
…d key goes stale (desktop scroll jump-back)
|
| Filename | Overview |
|---|---|
| static/ui.js | Moves sessionIdx extraction above the key lookup and removes the early-exit that prevented sessionIdx fallback on stale content keys; replaces the old per-identifier concede guards with a single combined check covering both channels. |
| tests/test_issue4295_midstream_scroll_anchor.py | Adds two tests: a Node.js behavioral test that runs the real function against a stub DOM and asserts the correct scrollTop compensation (1050), and a structural test asserting the old dead-end pattern is gone and the new concede condition is present. |
| tests/test_tars_scroll_reset_regressions.py | Updates structural assertions to expect the old dead-end pattern absent and the new combined concede condition present, keeping the regression suite consistent with the fix. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[_restoreMessageViewportAnchor called] --> B{container && anchor?}
B -- No --> Z[return false]
B -- Yes --> C[Compute anchorKey, sessionIdx, hasSessionIdx]
C --> D[Key lookup: querySelectorAll data-message-anchor-key]
D --> E{row found?}
E -- Yes --> F{getClientRects empty?}
F -- Yes --> G[row = null]
F -- No --> H[row is valid]
E -- No --> G
G --> I{hasSessionIdx?}
I -- Yes --> J[querySelector data-session-msg-idx]
J --> K{row found?}
K -- Yes --> H
K -- No --> L{anchorKey OR hasSessionIdx?}
I -- No --> L
L -- Yes --> Z
L -- No --> M[rawIdx fallback lookup]
H --> N[Write scrollTop relative-offset realign]
M --> O{row found?}
O -- No --> Z
O -- Yes --> N
N --> P[return true]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[_restoreMessageViewportAnchor called] --> B{container && anchor?}
B -- No --> Z[return false]
B -- Yes --> C[Compute anchorKey, sessionIdx, hasSessionIdx]
C --> D[Key lookup: querySelectorAll data-message-anchor-key]
D --> E{row found?}
E -- Yes --> F{getClientRects empty?}
F -- Yes --> G[row = null]
F -- No --> H[row is valid]
E -- No --> G
G --> I{hasSessionIdx?}
I -- Yes --> J[querySelector data-session-msg-idx]
J --> K{row found?}
K -- Yes --> H
K -- No --> L{anchorKey OR hasSessionIdx?}
I -- No --> L
L -- Yes --> Z
L -- No --> M[rawIdx fallback lookup]
H --> N[Write scrollTop relative-offset realign]
M --> O{row found?}
O -- No --> Z
O -- Yes --> N
N --> P[return true]
Reviews (1): Last reviewed commit: "fix(chat): recover viewport anchor via s..." | Re-trigger Greptile
🔬 Gate certification — GREEN ✅Certified head: What I ran (rebased worktree
|
| Gate | Result |
|---|---|
| Rebase onto current master | ✅ git apply clean |
| Codex (reproduce) | SAFE TO SHIP — gated the rebased worktree, 0 findings |
| Full pytest suite | 2 failed / 11831 passed — both non-defects (nous env flake + test_issue4536 isolation flake) |
| PR's own tests | ✅ 27/27 (test_issue4295_midstream_scroll_anchor + test_tars_scroll_reset_regressions) |
Findings
✅ Sound multi-tier recovery, jump-back compensated: _restoreMessageViewportAnchor now: find by anchorKey → treat found-but-unrendered (getClientRects().length===0) as missing → if(!row&&hasSessionIdx) recover via [data-session-msg-idx="${sessionIdx}"] (stable session-relative index, not fragile nth-child) → if(!row&&(anchorKey||hasSessionIdx)) return false → data-msg-idx fallback for targetIdx → final if(!row) return false. The old premature if(!row&&anchorKey) return false (which made the sessionIdx branch dead code) is removed. Realignment uses a RELATIVE container.scrollTop += (rect.top - containerRect.top) - targetTop that compensates above-viewport height growth (the jump-back fix), with the mobile overflow-anchor double-shift awareness intact (no regression to #5392/#5338). Codex SAFE (0 findings), 27/27 tests.
Recommendation to the next agent
Ready to merge — use branch gate-rebase/5469-viewport-anchor-sessionidx (sha:15fbcb4c), NOT the PR's stale head d4043f76. Clean dead-code fix: the sessionIdx recovery tier is now reachable, finds the correct row by stable index, and the relative scrollTop adjustment compensates the above-viewport growth that caused the desktop jump-back; multi-tier fallback concedes correctly when genuinely unrecoverable. Codex SAFE + 27/27 + suite green bar 2 known flakes. Scroll-behavior fix (test-covered) — a quick desktop mid-stream scroll-position smoke confirms, but the recovery chain + compensation are verified. concept 4/5. Credit @allenliang2022 (co-authored). crit=3. (Same scroll-anchor family as the shipped #5392/#5338; no double-shift regression.)
Gate-certifier layer (warm-up → gate → release). I do not merge/tag/deploy. Rebased onto current master; sessionIdx recovery reachability + correct-row-by-stable-index + relative jump-back compensation + no mobile-overflow-anchor regression verified, Codex SAFE + 27/27 + suite green bar 2 known flakes. Cert valid for sha:15fbcb4c.
Release v0.51.841 — desktop scroll jump-back fix (#5469)
|
Shipped in v0.51.841 — thanks @allenliang2022! 🎉 The desktop scroll jump-back fix is live: Full gate: Codex SAFE (sessionIdx fallback reachable, removed-message concede preserved, mobile overflow-anchor fix #5338/#5392 untouched, settled path unaffected), Opus ship-safe, suite green (2 unrelated pre-existing env flakes). Credited via Fast-follow (non-blocking): Opus suggested a zero-rect hardening on the sessionIdx |
…d key goes stale Clean rebase of allenliang2022's nesquena#5469 (rebase-first). Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
…ia sessionIdx (desktop scroll jump-back)
Problem
A residual scroll jump-back on desktop: while the reader is unpinned (scrolled up reading history) and a live assistant turn is streaming, the transcript occasionally yanks backward/downward on a stream chunk.
This is a distinct code path from the mobile jump-back addressed in #5338 / #5392. Those fixes target the browser's native
overflow-anchorcompensation, which only runs on mobile (touch devices rest atoverflow-anchor:auto). Desktop rests atoverflow-anchor:none, so that entire guard is a no-op here — this jump is produced by our own JS writingscrollTop, not the browser engine. (Confirmed the affected client was a landscape desktop viewport, not mobile.)Root cause
_restoreMessageViewportAnchorresolves the captured viewport anchor row by itsdata-message-anchor-key. That key is content-derived —_messageViewportAnchorKeyForMessagebuilds it asrole|ts|attachments|first-160-chars(see_compressionMessageAnchorKey, which doescontent...slice(0,160)).While an assistant message is streaming, each chunk that changes the first 160 characters recomputes that row's
data-message-anchor-key. So a scroll snapshot captured one chunk earlier now holds a stale key that no longer matches by key lookup.The old code conceded immediately on a stale key:
Because this returned
falsebefore thedata-session-msg-idxfallback, a live-stream stale key could never reach the session-index recovery. The caller then fell back to an absolutescrollTop = snapshot.top, which does not compensate the above-viewport height change from that same chunk (worklog live→settled collapse, tool-card insert, etc.) — the visible jump.Fix
Compute
sessionIdxup front and try the stabledata-session-msg-idxlookup before conceding. The anchored row is still in the DOM under its stable session-relative index even when its content key changed, so it recovers and the existing relative-offset realign (scrollTop += (rect.top - containerRect.top) - targetTop) compensates the height change correctly.false(unchanged behavior).sessionIdxis not degraded to the window-relativerawIdx(which could resolve to a different message), preserving the original per-tier guard.Desktop-and-mobile safe: the realign path is shared; this only changes which row the realign targets when the content key is stale.
Verification
Isolated controlled repro (same DOM, same snapshot, only the impl swapped):
return falsetrueTests
test_stale_content_key_recovers_via_session_index_and_compensates_height— behavior test driving the real_restoreMessageViewportAnchorwith a stale key + present sessionIdx; asserts it recovers (true) and writes the exact relative-offset compensation instead of conceding. Base-fails on the old dead-end body, head-passes on the fix (mutation-verified: reverting the fix makes both new tests FAIL).test_stale_key_does_not_dead_end_before_session_index_fallback— guards the exact dead-code regression (oldif(!row&&anchorKey) return false;must be gone; concede now requires both key and sessionIdx to have failed).test_tars_scroll_reset_regressionsstructural assertions that pinned the old dead-end string.static/ui.js+ two test files only. @nesquena