fix(chat): recover viewport anchor via sessionIdx when content-derived key goes stale

Clean rebase of allenliang2022's #5469 (rebase-first).

Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
This commit is contained in:
nesquena-hermesandallenliang2022 committed 2026-07-03 19:44:32 +00:00
1 parent d5e1274258
commit 15fbcb4ce0
3 files changed
+121 -6

No files matched your search

+18 -4
View File
@@ -868,13 +868,27 @@ function _restoreMessageViewportAnchor(anchor, rawIdxDelta){
const container=$('messages'); const container=$('messages');
if(!container||!anchor) return false; if(!container||!anchor) return false;
const anchorKey=String(anchor.key||''); const anchorKey=String(anchor.key||'');
let row=anchorKey?Array.from(container.querySelectorAll('[data-message-anchor-key]')).find(el=>el&&el.dataset&&el.dataset.messageAnchorKey===anchorKey):null;
if(row&&row.getClientRects&&row.getClientRects().length===0) row=null;
if(!row&&anchorKey) return false;
const sessionIdx=Number(anchor.sessionIdx); const sessionIdx=Number(anchor.sessionIdx);
const hasSessionIdx=Number.isFinite(sessionIdx); const hasSessionIdx=Number.isFinite(sessionIdx);
let row=anchorKey?Array.from(container.querySelectorAll('[data-message-anchor-key]')).find(el=>el&&el.dataset&&el.dataset.messageAnchorKey===anchorKey):null;
if(row&&row.getClientRects&&row.getClientRects().length===0) row=null;
// The anchor key is content-derived (role|ts|attachments|first-160-chars, built by
// _messageViewportAnchorKeyForMessage) so it goes STALE while a live assistant
// message is still streaming: every chunk that changes the first 160 chars
// recomputes that row's data-message-anchor-key, so a snapshot captured mid-stream
// no longer matches by key. We used to concede the moment the keyed lookup missed
// (`if(!row&&anchorKey) return false`), and the caller then fell back to an ABSOLUTE
// scrollTop=snapshot.top that does NOT compensate the above-viewport height growth
// from that same streaming chunk — the residual DESKTOP scroll jump-back. (Desktop
// rests at overflow-anchor:none, so #5392's mobile overflow-anchor guard is a no-op
// here; this is a distinct code path.) The anchored row is still in the DOM under
// its STABLE session-relative index, so recover it via sessionIdx before conceding.
// A genuinely removed anchor (message compressed/deleted away) misses key AND
// sessionIdx and still returns false. A missing sessionIdx is NOT degraded to the
// window-relative rawIdx (which could resolve to a different message), preserving
// the original per-tier guard.
if(!row&&hasSessionIdx) row=container.querySelector(`[data-session-msg-idx="${sessionIdx}"]`); if(!row&&hasSessionIdx) row=container.querySelector(`[data-session-msg-idx="${sessionIdx}"]`);
if(!row&&hasSessionIdx) return false; if(!row&&(anchorKey||hasSessionIdx)) return false;
const targetIdx=Number(anchor.rawIdx)+Number(rawIdxDelta||0); const targetIdx=Number(anchor.rawIdx)+Number(rawIdxDelta||0);
if(!row&&Number.isFinite(targetIdx)) row=container.querySelector(`[data-msg-idx="${targetIdx}"]`); if(!row&&Number.isFinite(targetIdx)) row=container.querySelector(`[data-msg-idx="${targetIdx}"]`);
if(!row) return false; if(!row) return false;
@@ -467,3 +467,99 @@ def test_same_frame_restore_uses_the_shared_anchor_remount_fallback():
assert "if(!restoredViaAnchor&&typeof_remountMessageViewportAnchor==='function'&&_remountMessageViewportAnchor(snapshot.anchor))" in compact assert "if(!restoredViaAnchor&&typeof_remountMessageViewportAnchor==='function'&&_remountMessageViewportAnchor(snapshot.anchor))" in compact
assert "_messageVirtualScrollTopForVisibleIdx" not in body assert "_messageVirtualScrollTopForVisibleIdx" not in body
def test_stale_content_key_recovers_via_session_index_and_compensates_height():
"""Desktop scroll jump-back root fix (residual, distinct from #5392 mobile path).
The viewport anchor key is content-derived (role|ts|attachments|first-160-chars),
so while a live assistant message is streaming, each chunk that changes the first
160 chars recomputes that row's data-message-anchor-key. A scroll snapshot captured
a chunk earlier now has a STALE key that no longer matches by key lookup.
The OLD code conceded immediately on a stale key (`if(!row&&anchorKey) return false`),
and the caller then fell back to an ABSOLUTE scrollTop that does not compensate the
above-viewport height change from that same chunk -> desktop jump-back. The row is
still in the DOM under its stable data-session-msg-idx, so the fix recovers it via
sessionIdx and applies the relative-offset realign (which compensates the height).
Behavior test (not a source-string check): the anchor row's on-screen top must be
held at its captured topOffset. With buggy code the keyed lookup misses, sessionIdx
is never consulted, _restoreMessageViewportAnchor returns false, and this asserts FAIL.
"""
script = f"""
const assert = require('assert');
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
// Scenario: reader is unpinned mid-transcript. A streaming chunk (a) changed the
// anchor row's content-derived key (stale key -> keyed lookup misses) and (b) grew
// content above the viewport. We assert the realign still runs (via sessionIdx) and
// writes the exact relative-offset compensation instead of conceding to an absolute
// jump. Geometry is chosen so the write is unambiguous:
// containerRect.top = 100, captured topOffset = 300 (row was 300px below container top)
// after the chunk, the row's live rect.top = 250 => (250-100) = 150 from container top
// realign write: scrollTop += (150) - 300 = -150 => 1200 - 150 = 1050
// A buggy body dead-ends on the stale key (returns false, no scrollTop write at all),
// so both asserts below FAIL on the old code.
const anchorRow = {{
getBoundingClientRect() {{ return {{ top: 250, bottom: 400 }}; }}
}};
const container = {{
scrollTop: 1200,
scrollHeight: 4600,
clientHeight: 600,
getBoundingClientRect() {{ return {{ top: 100, bottom: 700 }}; }},
querySelectorAll(selector) {{
// Stale key: NO node matches the captured (old) content key.
if (selector === '[data-message-anchor-key]') return [];
return [];
}},
querySelector(selector) {{
// The row is still present under its stable session index (and raw index).
if (selector === '[data-session-msg-idx="3"]') return anchorRow;
if (selector === '[data-msg-idx="3"]') return anchorRow;
return null;
}}
}};
function $(id) {{ return id === 'messages' ? container : null; }}
function requestAnimationFrame(fn) {{ fn(); }}
function setTimeout(fn) {{ fn(); }}
const performance = {{ now() {{ return 0; }} }};
function _suppressBrowserOverflowAnchor() {{ return null; }} // desktop no-op
function _deferClearProgrammaticScroll() {{ _programmaticScroll = false; }}
{_function_body(UI_JS, "_restoreMessageViewportAnchor")}
// Captured snapshot: anchor row was at topOffset=300, content key now STALE, but the
// row still exists at sessionIdx=3 / rawIdx=3.
const ok = _restoreMessageViewportAnchor({{
key: 'assistant|200|0|STALE_first_160_chars_from_an_earlier_chunk',
sessionIdx: 3,
rawIdx: 3,
topOffset: 300,
}}, 0);
// Must NOT concede on the stale key: sessionIdx recovers the row and the realign runs.
assert.strictEqual(ok, true, 'stale content key must recover via sessionIdx, not return false');
// Relative realign: scrollTop += (rect.top - containerRect.top) - targetTop
// = 1200 + ((250 - 100) - 300) = 1200 + (150 - 300) = 1050.
// This compensates the above-viewport height change; the buggy path would have left
// an absolute jump (or no write at all), so this exact value pins the correct behavior.
assert.strictEqual(container.scrollTop, 1050, 'realign must compensate the above-height change, not leave an absolute jump');
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_stale_key_does_not_dead_end_before_session_index_fallback():
"""Guard the exact dead-code regression: a present content key must not short-circuit
the sessionIdx fallback. The old body had `if(!row&&anchorKey) return false;` BEFORE
sessionIdx was consulted, so a stale key could never reach the session-index recovery.
This asserts that early dead-end is gone and sessionIdx is resolved before conceding.
"""
compact = _compact(_function_body(UI_JS, "_restoreMessageViewportAnchor"))
# The old dead-end (concede on stale key before trying sessionIdx) must be gone.
assert "if(!row&&anchorKey)returnfalse;" not in compact
# sessionIdx must be computed and the row recovered by it before any concede.
assert 'container.querySelector(`[data-session-msg-idx="${{sessionIdx}}"]`)'.replace("{{", "{").replace("}}", "}") in compact
# The concede now requires BOTH key AND sessionIdx to have failed.
assert "if(!row&&(anchorKey||hasSessionIdx))returnfalse;" in compact
+7 -2
View File
@@ -163,8 +163,13 @@ def test_preserve_scroll_restores_unpinned_viewport_after_dom_rebuild():
assert "row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);" in UI_JS assert "row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);" in UI_JS
assert "seg.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);" in UI_JS assert "seg.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);" in UI_JS
assert "container.querySelector(`[data-session-msg-idx=\"${sessionIdx}\"]`)" in UI_JS assert "container.querySelector(`[data-session-msg-idx=\"${sessionIdx}\"]`)" in UI_JS
assert "if(!row&&anchorKey) return false;" in UI_JS # Desktop scroll jump-back root fix: a stale content-derived anchor key must NOT
assert "if(!row&&hasSessionIdx) return false;" in UI_JS # dead-end before the sessionIdx fallback (the old `if(!row&&anchorKey) return false;`
# sat before sessionIdx was consulted, so a live-stream stale key could never reach
# the session-index recovery and the caller fell back to an absolute jump). The
# concede now requires BOTH key and sessionIdx lookups to have failed.
assert "if(!row&&anchorKey) return false;" not in UI_JS
assert "if(!row&&(anchorKey||hasSessionIdx)) return false;" in UI_JS
assert "_restoreMessageViewportAnchor(snapshot.anchor,0)" in restore assert "_restoreMessageViewportAnchor(snapshot.anchor,0)" in restore
assert "if(!restoredViaAnchor){" in restore assert "if(!restoredViaAnchor){" in restore
assert "el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop))" in restore assert "el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop))" in restore