fix(chat): prevent first response jump from snapping back (#6621)

* fix(chat): keep response jump position after session load

Cancel pending load-time bottom settling before an explicit response jump takes scroll ownership. Add a regression test covering the first-click race.

* fix: gate response jump ownership by destination

* fix: keep response jumps programmatic through smooth scroll

Native smooth-scroll frames from response jumps could reach the manual scroll listener and claim reader ownership before the final destination was known.

- own response-jump scrolling with a generation- and session-scoped lifecycle
- reconcile sticky ownership from final 80px tail geometry
- preserve current low-delta wheel takeover semantics when integrating with master
- cover visible assistant, user-row, and virtualized paths through the production scroll listener

* fix(scroll): hold reader off-bottom during jump owner + cancel jump on explicit End (#6621 gate fixes)

Codex gate found two defects in the jump-scroll ownership mechanism:
- Jump during an active stream snapped back to bottom on the next token: the
  owner suppressed the scroll listener but left the pre-jump pinned state, so
  scrollIfPinned() reclaimed the bottom. Now _beginMessageJumpScroll unpins for
  the ownership window and scrollIfPinned() no-ops while an owner exists.
- An explicit End click could be undone by the pending jump reconcile restoring
  the stale unpinned snapshot. scrollToBottom() now cancels the active jump
  owner first; _cancelMessageJumpScroll restores the preserved snapshot so a
  non-reconcile cancel doesn't leak the transient unpinned state.

* fix(scroll): drop undefined currentSid global ref in _messageJumpSessionId (#6621 brick-class scope-undef gate)

The PR's _messageJumpSessionId() referenced a bare 'currentSid' global that
does not exist in ui.js (everywhere else it's a local const from
S.session.session_id). test_static_js_scope_undef flagged it brick-class
(#3696). The S.session.session_id fallback already IS the canonical accessor;
removed the dead first line and updated the harness to drive the session-change
case via S.session.session_id.

* fix(scroll): round-2 gate fixes for jump-owner state transitions (#6621)

Re-gate found three more state-transition edge cases from the temp-unpin window:
- wheel-up interrupting an active jump owner after the programmatic latch
  expires now explicitly establishes the unpinned reader-owned state (was
  cancelling the jump but leaving pinned -> next token snapped to bottom).
- _resetStreamScrollFollow() now cancels the jump owner FIRST, before its
  pinned-state assignments, so a stream starting mid-jump can't have its pin
  undone by the snapshot restore -> auto-follow stays enabled.
- _finishMessageJumpScroll() flushes a deferred external-session refresh after
  reconciliation when the terminal state is pinned to the tail, so a refresh
  deferred during the temp-unpin window isn't stranded.

* fix(scroll): make jump-owner guards typeof-safe + widen static test window (#6621)

The two _messageJumpScrollOwner guards (scrollIfPinned, scrollToBottom) are
pulled into other scroll test harnesses (test_issue6414, test_issue4856) that
stub the scroll env without declaring the new global; bare references threw
ReferenceError. Guard with typeof (matches the PR's own jumpScrollOwned check).
Also widen test_low_delta_wheel_intent_is_tracked_separately's source-slice
window 1400->1800 to still contain the (unchanged) _lastMessageWheelIntentMs
line after the +9-line wheel-up-during-jump block was inserted.

* test(scroll): add streaming-frame-hold + wheel-during-jump regression tests (#6621 Fable finding ii)

Fable UX gate flagged that the PR's tests model only the stale load-time settle
callback, not the streaming case. Add two node-harness tests:
- a streaming render frame (scrollIfPinned) fired inside the jump-owner window
  must not snap the reader to the bottom (holds at target, 0 bottom-writes).
- a gentle wheel-up during the owner window after the programmatic latch stales
  hands ownership to the reader UNPINNED at their position, never pinned
  mid-transcript.

* fix(scroll): widen jump-owner takeover to downward + touch input (#6621 Fable S3)

Fable's re-review (on the fixed code) confirmed the streaming blocker + wheel-up
concern resolved, and probe-proved one remaining narrow regression: a gentle
wheel-DOWN or touch scroll during the owner window cancelled the owner and
restored the pinned snapshot but did NOT re-unpin (the takeover was gated on
wheelUp only), so the next streaming token yanked the reader to the bottom.
Widen the takeover to any message-pane scroll input during the owner window.
Add a wheel-down regression test; widen the #4970 static-slice window to 2000
to still contain the (unchanged) _lastMessageWheelIntentMs line.

---------

Co-authored-by: pxxD1998 <214340659+pxxD1998@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-08-13 21:17:42 -07:00
1 parent 5a68684fc7
commit 3a95d82531
4 files changed
+933 -10

No files matched your search

+148 -8
View File
@@ -1660,6 +1660,11 @@ function _highlightQuestionRow(row){
async function jumpToTurnQuestion(questionRawIdx, assistantRawIdx){
const container=$('messages');
if(!container||typeof questionRawIdx!=='number'||questionRawIdx<0) return;
const clampTargetScrollTop=(scrollTop)=>{
const maxTop=Math.max(0,container.scrollHeight-container.clientHeight);
const n=Number(scrollTop);
return Math.max(0,Math.min(Number.isFinite(n)?n:container.scrollTop,maxTop));
};
const scrollToTarget=()=>{
const hasAssistant=typeof assistantRawIdx==='number'&&assistantRawIdx>=0;
if(hasAssistant){
@@ -1682,14 +1687,18 @@ async function jumpToTurnQuestion(questionRawIdx, assistantRawIdx){
_highlightQuestionRow(row);
return true;
};
// Cancel load-time bottom settling before any visible or virtualized target
// path can return. The jump owner keeps every native smooth-scroll frame out
// of the manual-reader listener, then reconciles against the final geometry.
_cancelBottomSettle();
_beginMessageJumpScroll(container);
if(scrollToTarget()) return;
const visWithIdx=_getVisibleMessagesWithIdx();
const visibleIdx=_messageVisibleIndexForRawIdx(questionRawIdx, visWithIdx);
if(visibleIdx>=0){
_scrollPinned=false;
_messageUserUnpinned=true;
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
container.scrollTop=_messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container);
const virtualTarget=clampTargetScrollTop(_messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container));
container.scrollTop=virtualTarget;
_messageVirtualWindowKey='';
renderMessages({ preserveScroll:true });
requestAnimationFrame(()=>{
@@ -5761,6 +5770,96 @@ function _freshProgrammaticScrollActive(){
return true;
}
function _deferClearProgrammaticScroll(ms){clearTimeout(_programmaticScrollResetTimer);_programmaticScrollResetTimer=setTimeout(()=>{_programmaticScroll=false;},ms||80);}
let _messageJumpScrollGeneration=0;
let _messageJumpScrollOwner=null;
let _messageJumpScrollSettleTimer=0;
function _messageJumpSessionId(){
if(typeof S!=='undefined'&&S.session&&S.session.session_id) return String(S.session.session_id);
return '';
}
function _scheduleMessageJumpScrollReconcile(generation,ms){
if(!_messageJumpScrollOwner||_messageJumpScrollOwner.generation!==generation) return;
clearTimeout(_messageJumpScrollSettleTimer);
_messageJumpScrollSettleTimer=setTimeout(()=>_finishMessageJumpScroll(generation),ms||220);
}
function _beginMessageJumpScroll(container){
const previous=_messageJumpScrollOwner;
const preserved=previous?previous.preserved:{
scrollPinned:_scrollPinned,
messageUserUnpinned:_messageUserUnpinned,
nearBottomCount:_nearBottomCount,
};
clearTimeout(_messageJumpScrollSettleTimer);
const generation=++_messageJumpScrollGeneration;
_messageJumpScrollOwner={generation,container,sessionId:_messageJumpSessionId(),preserved};
// While the jump owner is active, temporarily release the reader pin so a
// live token arriving between smooth-scroll frames cannot let scrollIfPinned()
// reclaim the bottom and snap the reader off the jump target (#6621). The
// preserved snapshot above is what _finishMessageJumpScroll() reconciles
// against once the jump settles.
_scrollPinned=false;
_messageUserUnpinned=true;
_nearBottomCount=0;
_programmaticScroll=true;
_programmaticScrollSetAt=performance.now();
_scheduleMessageJumpScrollReconcile(generation,300);
return generation;
}
function _finishMessageJumpScroll(generation){
const owner=_messageJumpScrollOwner;
if(!owner||owner.generation!==generation) return;
if(owner.sessionId!==_messageJumpSessionId()){
_cancelMessageJumpScroll();
return;
}
clearTimeout(_messageJumpScrollSettleTimer);
_messageJumpScrollSettleTimer=0;
const container=owner.container;
const maxTop=Math.max(0,container.scrollHeight-container.clientHeight);
const top=Math.max(0,Math.min(Number(container.scrollTop)||0,maxTop));
const bottomDistance=maxTop-top;
if(bottomDistance>80){
_scrollPinned=false;
_messageUserUnpinned=true;
_nearBottomCount=0;
}else{
_scrollPinned=owner.preserved.scrollPinned;
_messageUserUnpinned=owner.preserved.messageUserUnpinned;
_nearBottomCount=owner.preserved.nearBottomCount;
}
_lastScrollTop=container.scrollTop;
_lastMessageClientHeight=container.clientHeight;
_messageJumpScrollOwner=null;
_programmaticScroll=false;
if(typeof _syncScrollToBottomCue==='function'){
_syncScrollToBottomCue(!_scrollPinned&&bottomDistance>80,{newMessage:_newMessageCueVisible});
}
if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton();
// An external-session refresh deferred while the reader was temporarily
// unpinned for the jump window (#6621) would otherwise stay stranded once the
// near-tail reconciliation restores follow mode. Flush it when the terminal
// state is genuinely pinned to the tail.
if(_scrollPinned&&!_messageUserUnpinned&&typeof _flushDeferredActiveSessionExternalRefresh==='function'){
_flushDeferredActiveSessionExternalRefresh();
}
}
function _cancelMessageJumpScroll(){
++_messageJumpScrollGeneration;
clearTimeout(_messageJumpScrollSettleTimer);
_messageJumpScrollSettleTimer=0;
// _beginMessageJumpScroll temporarily unpins the reader for the ownership
// window (#6621); a cancel that isn't a reconcile must put the pre-jump pin
// state back, or the transient unpinned state would leak. Callers that want a
// different terminal pin state (e.g. scrollToBottom) set it right after this.
const owner=_messageJumpScrollOwner;
if(owner&&owner.preserved){
_scrollPinned=owner.preserved.scrollPinned;
_messageUserUnpinned=owner.preserved.messageUserUnpinned;
_nearBottomCount=owner.preserved.nearBottomCount;
}
_messageJumpScrollOwner=null;
_programmaticScroll=false;
}
let _nearBottomCount=0;
let _lastScrollTop=null;
let _lastMessageClientHeight=null; // #4702: track scroller height to ignore iOS portrait toolbar-settle reflows (a clientHeight increase fires a scroll event with decreased scrollTop that is NOT a user scroll)
@@ -5806,7 +5905,7 @@ let _lastMessageRenderAt=-Infinity;
function _recentMessageRenderArtifactWindow(ms){
return performance.now()-_lastMessageRenderAt<(ms||1400);
}
function _cancelBottomSettle(){ _bottomSettleToken++; if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } clearTimeout(_settleTimer); clearTimeout(_settleFinalTimer); cancelAnimationFrame(_settleRAF); }
function _cancelBottomSettle(){ _cancelMessageJumpScroll(); _bottomSettleToken++; if(_settleRO){ _settleRO.disconnect(); _settleRO=null; } clearTimeout(_settleTimer); clearTimeout(_settleFinalTimer); cancelAnimationFrame(_settleRAF); }
function _markMessageTouchScrollIntent(active=true){
_messageTouchScrollActive=!!active;
_lastMessageTouchScrollIntentMs=performance.now();
@@ -5859,15 +5958,33 @@ function _recordNonMessageScrollIntent(e){
const target=e&&e.target;
if(!el||!target) return;
if(!el.contains(target)){ _lastNonMessageScrollIntentMs=performance.now(); return; }
// Capture the guards before cancelling the active owner: cancellation clears
// the programmatic flag and jump owner, but a low-delta upward wheel must still
// count as reader takeover when it interrupted an owned scroll.
const wheelUp=typeof e.deltaY==='number'&&e.deltaY<0;
const guardedWheelUp=wheelUp&&_freshProgrammaticScrollActive();
const jumpScrollOwned=typeof _messageJumpScrollOwner!=='undefined'&&!!_messageJumpScrollOwner;
if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY!==0)){
if(typeof _messageScrollInputGeneration==='number') _messageScrollInputGeneration++;
if(jumpScrollOwned||e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY< -30)||guardedWheelUp){
if(typeof _cancelBottomSettle==='function') _cancelBottomSettle();
}
}
// Any message-pane scroll input that interrupts an active jump owner is a
// reader takeover, regardless of direction or the programmatic-latch age
// (#6621): _cancelBottomSettle above restores the pre-jump snapshot, so
// without this a gentle wheel-up OR wheel-down (or a touch scroll) after the
// latch expires would leave the reader pinned and let the next token snap to
// the bottom. Establish the unpinned reader-owned state explicitly; a reader
// who wants the bottom re-pins by reaching it (<=80px) or pressing End.
if(jumpScrollOwned&&(wheelUp||e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY!==0))){
_messageUserUnpinned=true;
_scrollPinned=false;
_nearBottomCount=0;
}
if(typeof e.deltaY==='number'&&e.deltaY<0) _lastMessageWheelIntentMs=performance.now();
// Keep e.deltaY< -30 as the ordinary direct sticky-unpin threshold.
const wheelUp=typeof e.deltaY==='number'&&e.deltaY<0;
const guardedWheelUp=wheelUp&&_freshProgrammaticScrollActive();
if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY< -30)||guardedWheelUp){
_cancelBottomSettle();
if(e.type==='touchmove') _markMessageTouchScrollIntent(true);
if((typeof e.deltaY==='number'&&e.deltaY< -30)||guardedWheelUp){
_messageUserUnpinned=true;
@@ -5959,6 +6076,7 @@ if(typeof document!=='undefined'){
// prevent the new chat's first scroll comparing against the previous chat's
// scrollTop (Opus stage-302 SHOULD-FIX, #1731 follow-up).
function _resetScrollDirectionTracker(){
_cancelMessageJumpScroll();
_clearNewMessageScrollCue();
_lastScrollTop=null;
_lastMessageClientHeight=null;
@@ -5980,6 +6098,11 @@ function _resetScrollDirectionTracker(){
_deferredOlderMessagesTimer=0;
}
function _resetStreamScrollFollow(){
// Cancel any in-flight jump owner FIRST: a new stream is a definitive
// pin-to-follow, and _cancelMessageJumpScroll() restores the pre-jump snapshot
// (#6621), so it must run BEFORE the pinned-state assignments below or it would
// undo them and silently disable auto-follow for the new stream.
_cancelBottomSettle();
_clearNewMessageScrollCue();
_messageUserUnpinned=false;
_scrollPinned=true;
@@ -5992,7 +6115,6 @@ function _resetStreamScrollFollow(){
_lastMessageScrollIntentMs=-Infinity;
// #4970 review (greptile P1): same hygiene for keyboard scroll intent.
_lastMessageKeyScrollIntentMs=-Infinity;
_cancelBottomSettle();
}
if(typeof window!=='undefined'){
window._resetScrollDirectionTracker=_resetScrollDirectionTracker;
@@ -6072,6 +6194,7 @@ if(typeof window!=='undefined'){
if(!el) return;
el.addEventListener('pointerdown',(e)=>{
if(e.target===el&&e.offsetX>=el.clientWidth){
if(typeof _cancelBottomSettle==='function') _cancelBottomSettle();
_scrollbarDragActive=true;
if(typeof _messageScrollInputGeneration==='number') _messageScrollInputGeneration++;
}
@@ -6119,6 +6242,7 @@ if(typeof window!=='undefined'){
// Count only when the message pane itself is the scroll target: it is focused,
// contains the focus, or the pointer is over it (keyboard scroll w/o focus).
if(a===el||el.contains(a)||el.matches(':hover')){
if(typeof _cancelBottomSettle==='function') _cancelBottomSettle();
const now=performance.now();
if(typeof _messageScrollInputGeneration==='number') _messageScrollInputGeneration++;
_lastMessageKeyScrollIntentMs=now;
@@ -6129,6 +6253,10 @@ if(typeof window!=='undefined'){
let _scrollRaf=0;
el.addEventListener('scroll',()=>{
_scheduleMessageVirtualizedRender();
if(_messageJumpScrollOwner){
_scheduleMessageJumpScrollReconcile(_messageJumpScrollOwner.generation);
return;
}
if(_freshProgrammaticScrollActive()) return;
_markMessageVirtualScrollActive();
cancelAnimationFrame(_scrollRaf);
@@ -6987,6 +7115,11 @@ function _settleFinalScroll(token){
}
function scrollIfPinned(){
if(!window._autoScrollFollow) return;
// A jump-to-question owner is mid-flight: it deliberately holds the reader at
// the jump target across smooth-scroll frames, so never let a live token
// reclaim the bottom while it is active (#6621). _finishMessageJumpScroll()
// reconciles the pin state once the jump settles.
if(typeof _messageJumpScrollOwner!=='undefined'&&_messageJumpScrollOwner) return;
if(_messageUserUnpinned){
// Only scrollToBottom() cleared this flag, so one scroll-up permanently
// killed auto-follow. Re-pin ONLY when the reader has genuinely returned to
@@ -7011,6 +7144,13 @@ function scrollIfPinned(){
_settleMessageScrollToBottom(false);
}
function scrollToBottom(){
// An explicit scroll-to-bottom (End button, or any definitive pin-to-bottom)
// supersedes a pending jump-to-question reconciliation: cancel the active jump
// owner first so its deferred _finishMessageJumpScroll() can't restore the
// pre-jump unpinned snapshot and silently undo this pin (#6621). The jump path
// itself never calls scrollToBottom(), and while a jump owner is active the
// reader is unpinned so the internal auto-follow callers don't reach here.
if(typeof _messageJumpScrollOwner!=='undefined'&&_messageJumpScrollOwner&&typeof _cancelMessageJumpScroll==='function') _cancelMessageJumpScroll();
_clearNewMessageScrollCue();
_scrollPinned=true;
_messageUserUnpinned=false;
+3 -1
View File
@@ -39,7 +39,9 @@ def test_question_jump_expands_windowed_history_and_highlights_question():
assert "function _messageVisibleIndexForRawIdx(rawIdx, visWithIdx)" in UI_JS
assert "function _messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container)" in UI_JS
assert "const visibleIdx=_messageVisibleIndexForRawIdx(questionRawIdx, visWithIdx);" in UI_JS
assert "container.scrollTop=_messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container);" in UI_JS
assert "const virtualTarget=clampTargetScrollTop(_messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container));" in UI_JS
assert "_beginMessageJumpScroll(container);" in UI_JS
assert "container.scrollTop=virtualTarget;" in UI_JS
assert "_messageVirtualWindowKey='';" in UI_JS
assert "_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount())" in UI_JS
assert "renderMessages({ preserveScroll:true })" in UI_JS
@@ -362,7 +362,7 @@ def test_low_delta_wheel_intent_is_tracked_separately():
assert "function _recentMessageWheelIntent" in UI_JS
rec_idx = UI_JS.find("function _recordNonMessageScrollIntent")
assert rec_idx != -1, "_recordNonMessageScrollIntent not found"
rec = UI_JS[rec_idx: rec_idx + 1400]
rec = UI_JS[rec_idx: rec_idx + 2000]
assert "e.deltaY<0) _lastMessageWheelIntentMs=performance.now()" in rec, (
"#4970: _recordNonMessageScrollIntent must record low-delta upward wheel "
"intent (deltaY<0) separately from the decisive deltaY<-30 unpin."
+781
View File
@@ -0,0 +1,781 @@
"""Regression coverage for the first jump-to-answer click after session load."""
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent.resolve()
UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
SESSIONS_JS_PATH = REPO_ROOT / "static" / "sessions.js"
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
def _run_node(source: str) -> str:
assert NODE is not None
with tempfile.NamedTemporaryFile(
"w", suffix=".cjs", encoding="utf-8", dir=REPO_ROOT, delete=False
) as script:
script.write(source)
script_path = Path(script.name)
try:
result = subprocess.run(
[NODE, str(script_path)],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=30,
)
finally:
script_path.unlink(missing_ok=True)
if result.returncode != 0:
raise RuntimeError(result.stderr)
return result.stdout.strip()
def _extract_func_script(js: str) -> str:
return f"""
const src = {js!r};
function extractFunc(name) {{
const re = new RegExp('(?:async\\\\s+)?function\\\\s+' + name + '\\\\s*\\\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{{', start);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
"""
def _extract_message_scroll_listener_script(js: str) -> str:
marker = "el.addEventListener('scroll',()=>{"
marker_idx = js.index(marker)
start = js.rfind("(function(){", 0, marker_idx)
end = js.index("})();", marker_idx) + len("})();")
assert start >= 0
return f"const messageScrollListenerIife = {js[start:end]!r};\n"
def test_first_jump_to_answer_cancels_pending_load_time_bottom_settle():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
let _scrollPinned = true;
let _messageUserUnpinned = false;
let _nearBottomCount = 2;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _bottomSettleToken = 0;
let _settleRAF = 0;
let _settleRO = null;
let _settleTimer = 0;
let _settleFinalTimer = 0;
let targetScrolls = 0;
let snappedBackToBottom = false;
const container = {
scrollHeight: 900,
clientHeight: 500,
scrollTop: 400,
getBoundingClientRect(){ return {top: 100, height: 500}; },
querySelectorAll(){ return [assistantSegment]; },
};
const assistantSegment = {
getClientRects(){ return [{}]; },
getBoundingClientRect(){ return {top: -300, height: 40}; },
scrollIntoView(){ targetScrolls += 1; container.scrollTop = 0; },
};
function $(id){ return id === 'messages' ? container : null; }
function _userMessageDomId(rawIdx){ return 'msg-user-' + rawIdx; }
function _highlightQuestionRow(){}
function _getVisibleMessagesWithIdx(){ throw new Error('visible target should use fast path'); }
function cancelAnimationFrame(){}
eval(extractFunc('_cancelBottomSettle'));
eval(extractFunc('_messageJumpSessionId'));
eval(extractFunc('_scheduleMessageJumpScrollReconcile'));
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
eval(extractFunc('jumpToTurnQuestion'));
(async () => {
_settleFinalTimer = setTimeout(() => {
if (_scrollPinned && !_messageUserUnpinned) snappedBackToBottom = true;
}, 15);
await jumpToTurnQuestion(4, 5);
await new Promise(resolve => setTimeout(resolve, 340));
console.log(JSON.stringify({
targetScrolls,
snappedBackToBottom,
scrollPinned: _scrollPinned,
messageUserUnpinned: _messageUserUnpinned,
bottomSettleToken: _bottomSettleToken,
}));
})().catch(error => {
console.error(error);
process.exit(1);
});
"""
result = json.loads(_run_node(source))
assert result == {
"targetScrolls": 1,
"snappedBackToBottom": False,
"scrollPinned": False,
"messageUserUnpinned": True,
"bottomSettleToken": 1,
}
@pytest.mark.parametrize(
("scroll_range", "expect_reader_owned"),
[
(0, False),
(79, False),
(81, True),
(400, True),
],
)
def test_jump_geometry_controls_active_session_refresh_deferral(
scroll_range: int, expect_reader_owned: bool
):
ui_js = UI_JS_PATH.read_text(encoding="utf-8")
sessions_js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(ui_js + "\n" + sessions_js) + rf"""
let _scrollPinned = true;
let _messageUserUnpinned = false;
let _nearBottomCount = 2;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _bottomSettleToken = 0;
let _settleRAF = 0;
let _settleRO = null;
let _settleTimer = 0;
let _settleFinalTimer = 0;
let _activeSessionExternalRefreshInFlight = false;
let _deferredActiveSessionExternalRefreshReason = '';
let apiCalls = 0;
const scrollRange = {scroll_range};
const container = {{
scrollHeight: 500 + scrollRange,
clientHeight: 500,
scrollTop: scrollRange,
getBoundingClientRect(){{ return {{top: 100, height: 500}}; }},
querySelectorAll(){{ return [assistantSegment]; }},
}};
const assistantSegment = {{
getClientRects(){{ return [{{}}]; }},
getBoundingClientRect(){{ return {{top: 100 - scrollRange, height: 40}}; }},
scrollIntoView(){{ container.scrollTop = 0; }},
}};
const document = {{hidden: false, getElementById(){{ return null; }}}};
const window = {{}};
const S = {{
session: {{session_id: 'active', message_count: 1, last_message_at: 1}},
messages: [{{role: 'user'}}],
busy: false,
activeStreamId: null,
}};
function $(id){{ return id === 'messages' ? container : null; }}
function _userMessageDomId(rawIdx){{ return 'msg-user-' + rawIdx; }}
function _highlightQuestionRow(){{}}
function _getVisibleMessagesWithIdx(){{ throw new Error('visible target should use fast path'); }}
function cancelAnimationFrame(){{}}
function _isMessageReaderUnpinned(){{ return _messageUserUnpinned; }}
function _deferActiveSessionExternalRefresh(reason){{
_deferredActiveSessionExternalRefreshReason = reason || 'poll';
}}
function _isExternalSession(){{ return false; }}
async function api(){{
apiCalls += 1;
return {{session: {{message_count: 1, last_message_at: 1}}}};
}}
eval(extractFunc('_cancelBottomSettle'));
eval(extractFunc('_messageJumpSessionId'));
eval(extractFunc('_scheduleMessageJumpScrollReconcile'));
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
eval(extractFunc('jumpToTurnQuestion'));
eval(extractFunc('refreshActiveSessionIfExternallyUpdated'));
(async () => {{
await jumpToTurnQuestion(4, 5);
await new Promise(resolve => setTimeout(resolve, 340));
const refreshResult = await refreshActiveSessionIfExternallyUpdated('idle-reconcile', {{ignoreStreamJustFinished:true}});
console.log(JSON.stringify({{
scrollRange,
scrollTop: container.scrollTop,
scrollPinned: _scrollPinned,
messageUserUnpinned: _messageUserUnpinned,
refreshResult,
deferredReason: _deferredActiveSessionExternalRefreshReason,
apiCalls,
}}));
}})().catch(error => {{
console.error(error);
process.exit(1);
}});
"""
result = json.loads(_run_node(source))
assert result["scrollRange"] == scroll_range
assert result["scrollTop"] == 0
assert result["scrollPinned"] is (not expect_reader_owned)
assert result["messageUserUnpinned"] is expect_reader_owned
assert result["refreshResult"] == ("skipped" if expect_reader_owned else "unchanged")
assert result["deferredReason"] == ("idle-reconcile" if expect_reader_owned else "")
assert result["apiCalls"] == (0 if expect_reader_owned else 1)
@pytest.mark.parametrize("jump_path", ["assistant", "user", "virtualized"])
@pytest.mark.parametrize(
("scroll_range", "expect_reader_owned"),
[(0, False), (79, False), (80, False), (81, True), (400, True)],
)
def test_response_jump_owns_native_smooth_scroll_until_final_reconciliation(
jump_path: str, scroll_range: int, expect_reader_owned: bool
):
ui_js = UI_JS_PATH.read_text(encoding="utf-8")
sessions_js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = (
_extract_func_script(ui_js + "\n" + sessions_js)
+ _extract_message_scroll_listener_script(ui_js)
+ rf"""
let _scrollPinned = true;
let _messageUserUnpinned = false;
let _nearBottomCount = 2;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _lastScrollTop = {scroll_range};
let _lastMessageClientHeight = 500;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _programmaticScrollResetTimer = 0;
let _bottomSettleToken = 0;
let _settleRAF = 0;
let _settleRO = null;
let _settleTimer = 0;
let _settleFinalTimer = 0;
let _activeSessionExternalRefreshInFlight = false;
let _deferredActiveSessionExternalRefreshReason = '';
let _scrollbarDragActive = false;
let _newMessageCueVisible = false;
let _messageVirtualWindowKey = '';
let _messageRenderWindowSize = 0;
let _messagesTruncated = false;
let rendered = {str(jump_path != "virtualized").lower()};
let cueShown = null;
let apiCalls = 0;
const scrollListeners = [];
const jumpPath = {jump_path!r};
const scrollRange = {scroll_range};
const dispatchScroll = () => scrollListeners.forEach(listener => listener());
let scrollTopValue = scrollRange;
const container = {{
scrollHeight: 500 + scrollRange,
clientHeight: 500,
clientWidth: 320,
contains(){{ return false; }},
matches(){{ return false; }},
addEventListener(type, listener){{ if(type === 'scroll') scrollListeners.push(listener); }},
querySelectorAll(selector){{
if(selector.includes('data-msg-idx') && rendered && jumpPath !== 'user') return [assistantSegment];
return [];
}},
getBoundingClientRect(){{ return {{top: 100, bottom: 600, height: 500}}; }},
get scrollTop(){{ return scrollTopValue; }},
set scrollTop(value){{ scrollTopValue = value; dispatchScroll(); }},
}};
function animateToTop(){{
if(scrollRange === 0) return;
setTimeout(() => {{ scrollTopValue = Math.ceil(scrollRange / 2); dispatchScroll(); }}, 180);
setTimeout(() => {{ scrollTopValue = 0; dispatchScroll(); }}, 230);
}}
const assistantSegment = {{
getClientRects(){{ return [{{}}]; }},
getBoundingClientRect(){{ return {{top: 100 - scrollTopValue, height: 40}}; }},
scrollIntoView(){{ animateToTop(); }},
}};
const userRow = {{
getClientRects(){{ return [{{}}]; }},
getBoundingClientRect(){{ return {{top: 100 - scrollTopValue, height: 40}}; }},
scrollIntoView(){{ animateToTop(); }},
classList: {{remove(){{}}, add(){{}}}},
offsetWidth: 1,
}};
const document = {{
hidden: false,
visibilityState: 'visible',
activeElement: null,
getElementById(id){{
if(id === 'messages') return container;
if(id === 'msg-user-4' && jumpPath === 'user') return userRow;
return null;
}},
addEventListener(){{}},
}};
const window = {{addEventListener(){{}}, setTimeout}};
function requestAnimationFrame(callback){{ return setTimeout(callback, 0); }}
function cancelAnimationFrame(handle){{ clearTimeout(handle); }}
const S = {{
session: {{session_id: 'active', message_count: 1, last_message_at: 1}},
messages: [{{role: 'user'}}],
busy: false,
activeStreamId: null,
}};
function $(id){{ return id === 'messages' ? container : null; }}
function _userMessageDomId(rawIdx){{ return 'msg-user-' + rawIdx; }}
function _getVisibleMessagesWithIdx(){{ return [{{rawIdx: 4}}]; }}
function _messageVisibleIndexForRawIdx(){{ return 0; }}
function _messageVirtualScrollTopForVisibleIdx(){{ return 0; }}
function _messageHiddenBeforeCount(){{ return 0; }}
function _currentMessageRenderWindowSize(){{ return 1; }}
function _messageRenderableMessageCount(){{ return 1; }}
function renderMessages(){{ rendered = true; }}
function _deferClearProgrammaticScroll(ms){{
clearTimeout(_programmaticScrollResetTimer);
_programmaticScrollResetTimer = setTimeout(() => {{ _programmaticScroll = false; }}, ms || 80);
}}
function _scheduleMessageVirtualizedRender(){{}}
function _markMessageVirtualScrollActive(){{}}
function _recentMessageRenderArtifactWindow(){{ return false; }}
function _recentMessageTouchScrollIntent(){{ return false; }}
function _recentNonMessageScrollIntent(){{ return false; }}
function _recentMessageWheelIntent(){{ return false; }}
function _recentMessageKeyScrollIntent(){{ return false; }}
function _clearNewMessageScrollCue(){{}}
function _syncScrollToBottomCue(show){{ cueShown = show; }}
function _updateSessionStartJumpButton(){{}}
function _isSessionEndlessScrollEnabled(){{ return false; }}
function _isMessageReaderUnpinned(){{ return _messageUserUnpinned; }}
function _deferActiveSessionExternalRefresh(reason){{
_deferredActiveSessionExternalRefreshReason = reason || 'poll';
}}
function _isExternalSession(){{ return false; }}
async function api(){{
apiCalls += 1;
return {{session: {{message_count: 1, last_message_at: 1}}}};
}}
eval(extractFunc('_cancelBottomSettle'));
eval(extractFunc('_messageJumpSessionId'));
eval(extractFunc('_scheduleMessageJumpScrollReconcile'));
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
eval(extractFunc('_highlightQuestionRow'));
eval(extractFunc('jumpToTurnQuestion'));
eval(extractFunc('refreshActiveSessionIfExternallyUpdated'));
eval(messageScrollListenerIife);
(async () => {{
await jumpToTurnQuestion(4, jumpPath === 'user' ? -1 : 5);
await new Promise(resolve => setTimeout(resolve, 560));
const refreshResult = await refreshActiveSessionIfExternallyUpdated(
'idle-reconcile', {{ignoreStreamJustFinished:true}}
);
console.log(JSON.stringify({{
jumpPath,
scrollRange,
bottomDistance: container.scrollHeight - container.scrollTop - container.clientHeight,
scrollPinned: _scrollPinned,
messageUserUnpinned: _messageUserUnpinned,
nearBottomCount: _nearBottomCount,
cueShown,
refreshResult,
deferredReason: _deferredActiveSessionExternalRefreshReason,
apiCalls,
}}));
}})().catch(error => {{
console.error(error);
process.exit(1);
}});
"""
)
result = json.loads(_run_node(source))
assert result["jumpPath"] == jump_path
assert result["bottomDistance"] == scroll_range
assert result["scrollPinned"] is (not expect_reader_owned)
assert result["messageUserUnpinned"] is expect_reader_owned
assert result["nearBottomCount"] == (0 if expect_reader_owned else 2)
assert result["cueShown"] is expect_reader_owned
assert result["refreshResult"] == ("skipped" if expect_reader_owned else "unchanged")
assert result["deferredReason"] == ("idle-reconcile" if expect_reader_owned else "")
assert result["apiCalls"] == (0 if expect_reader_owned else 1)
def test_jump_owner_replacement_and_session_change_cancel_stale_generation():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
let _scrollPinned = true;
let _messageUserUnpinned = false;
let _nearBottomCount = 2;
let _lastScrollTop = 400;
let _lastMessageClientHeight = 500;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _newMessageCueVisible = false;
let currentSid = 'active';
const S = {session:{session_id:'active'}};
const container = {scrollHeight:900,clientHeight:500,scrollTop:0};
function _syncScrollToBottomCue(){}
eval(extractFunc('_messageJumpSessionId'));
eval(extractFunc('_scheduleMessageJumpScrollReconcile'));
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
const first = _beginMessageJumpScroll(container);
const second = _beginMessageJumpScroll(container);
_finishMessageJumpScroll(first);
const staleIgnored = _messageJumpScrollOwner && _messageJumpScrollOwner.generation === second;
const preservedFromFirst = _messageJumpScrollOwner.preserved.scrollPinned === true
&& _messageJumpScrollOwner.preserved.messageUserUnpinned === false
&& _messageJumpScrollOwner.preserved.nearBottomCount === 2;
S.session.session_id = 'other';
_finishMessageJumpScroll(second);
console.log(JSON.stringify({
staleIgnored,
preservedFromFirst,
ownerCleared: _messageJumpScrollOwner === null,
programmaticCleared: _programmaticScroll === false,
scrollPinned: _scrollPinned,
messageUserUnpinned: _messageUserUnpinned,
nearBottomCount: _nearBottomCount,
}));
"""
result = json.loads(_run_node(source))
assert result == {
"staleIgnored": True,
"preservedFromFirst": True,
"ownerCleared": True,
"programmaticCleared": True,
"scrollPinned": True,
"messageUserUnpinned": False,
"nearBottomCount": 2,
}
def test_near_tail_jump_preserves_preexisting_reader_unpin():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
let _scrollPinned = false;
let _messageUserUnpinned = true;
let _nearBottomCount = 0;
let _lastScrollTop = 79;
let _lastMessageClientHeight = 500;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _newMessageCueVisible = false;
let currentSid = 'active';
const S = {session:{session_id:'active'}};
const container = {scrollHeight:579,clientHeight:500,scrollTop:0};
let cueShown = null;
function _syncScrollToBottomCue(show){ cueShown = show; }
eval(extractFunc('_messageJumpSessionId'));
eval(extractFunc('_scheduleMessageJumpScrollReconcile'));
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
const generation = _beginMessageJumpScroll(container);
_finishMessageJumpScroll(generation);
console.log(JSON.stringify({
scrollPinned: _scrollPinned,
messageUserUnpinned: _messageUserUnpinned,
nearBottomCount: _nearBottomCount,
cueShown,
}));
"""
result = json.loads(_run_node(source))
assert result == {
"scrollPinned": False,
"messageUserUnpinned": True,
"nearBottomCount": 0,
"cueShown": False,
}
def test_manual_wheel_input_cancels_active_jump_owner():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
let _scrollPinned = true;
let _messageUserUnpinned = false;
let _nearBottomCount = 2;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
const PROGRAMMATIC_SCROLL_VALID_MS = 150;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _bottomSettleToken = 0;
let _settleRAF = 0;
let _settleRO = null;
let _settleTimer = 0;
let _settleFinalTimer = 0;
let _lastNonMessageScrollIntentMs = -Infinity;
let _lastMessageScrollIntentMs = -Infinity;
let _lastMessageWheelIntentMs = -Infinity;
let _newMessageCueVisible = false;
let currentSid = 'active';
const S = {session:{session_id:'active'}};
const child = {};
const container = {
scrollHeight:900,
clientHeight:500,
scrollTop:0,
contains(node){ return node === child; },
};
const document = {getElementById(id){ return id === 'messages' ? container : null; }};
function cancelAnimationFrame(){}
eval(extractFunc('_messageJumpSessionId'));
eval(extractFunc('_scheduleMessageJumpScrollReconcile'));
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
eval(extractFunc('_cancelBottomSettle'));
eval(extractFunc('_freshProgrammaticScrollActive'));
eval(extractFunc('_recordNonMessageScrollIntent'));
_beginMessageJumpScroll(container);
_recordNonMessageScrollIntent({target:child,type:'wheel',deltaY:-5});
console.log(JSON.stringify({
ownerCleared: _messageJumpScrollOwner === null,
programmaticCleared: _programmaticScroll === false,
bottomSettleToken: _bottomSettleToken,
scrollPinned: _scrollPinned,
messageUserUnpinned: _messageUserUnpinned,
intentRecorded: Number.isFinite(_lastMessageScrollIntentMs),
}));
"""
result = json.loads(_run_node(source))
assert result == {
"ownerCleared": True,
"programmaticCleared": True,
"bottomSettleToken": 1,
# Current master treats even a low-delta upward wheel as reader takeover
# when it interrupts an active programmatic scroll (#6414).
"scrollPinned": False,
"messageUserUnpinned": True,
"intentRecorded": True,
}
def test_streaming_frame_during_jump_owner_does_not_snap_to_bottom():
"""#6621 (Fable finding ii): a streaming render frame that fires INSIDE the
jump-owner window must not let scrollIfPinned() reclaim the bottom. Reproduces
the exact live-token snap the fix prevents."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
let _scrollPinned = true;
let _messageUserUnpinned = false;
let _nearBottomCount = 0;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _lastScrollTop = null;
let _lastMessageClientHeight = 0;
let _newMessageCueVisible = false;
let bottomWrites = 0;
const performance = { now: () => Date.now() };
const TARGET_TOP = 707;
const container = { scrollHeight: 3510, clientHeight: 745, scrollTop: 2765, contains: () => true };
const window = { _autoScrollFollow: true };
function _messageJumpSessionId(){ return 'S1'; }
function clearTimeout(){} function setTimeout(){ return 0; }
function _scheduleMessageJumpScrollReconcile(){}
function _syncScrollToBottomCue(){} function _updateSessionStartJumpButton(){}
function _flushDeferredActiveSessionExternalRefresh(){}
function _recentNonMessageScrollIntent(){ return false; }
function _recentMessageScrollIntent(){ return false; }
function _recentMessageTouchScrollIntent(){ return false; }
function _recentMessageWheelIntent(){ return false; }
function _recentMessageKeyScrollIntent(){ return false; }
function _messageBottomDistance(){ return container.scrollHeight - container.scrollTop - container.clientHeight; }
function _setMessageScrollToBottom(){ bottomWrites++; container.scrollTop = container.scrollHeight - container.clientHeight; }
function _settleMessageScrollToBottom(){ if (_scrollPinned){ bottomWrites++; container.scrollTop = container.scrollHeight - container.clientHeight; } }
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
eval(extractFunc('scrollIfPinned'));
_beginMessageJumpScroll(container);
container.scrollTop = TARGET_TOP;
const before = container.scrollTop;
scrollIfPinned();
scrollIfPinned();
const after = container.scrollTop;
console.log(JSON.stringify({ before, after, bottomWrites }));
"""
result = json.loads(_run_node(source))
assert result == {"before": 707, "after": 707, "bottomWrites": 0}, (
"A streaming frame inside the jump-owner window must not snap the reader "
"back to the bottom (#6621)."
)
def test_wheel_up_during_jump_owner_takes_reader_ownership_not_pinned_midtranscript():
"""#6621 (Fable robustness): a gentle upward wheel during the jump-owner window
(after the programmatic latch stales) must leave the reader explicitly unpinned
at their position, NOT pinned mid-transcript where the next frame would yank them."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
let _scrollPinned = true;
let _messageUserUnpinned = false;
let _nearBottomCount = 2;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _lastScrollTop = null;
let _lastMessageClientHeight = 0;
let _newMessageCueVisible = false;
let _messageScrollInputGeneration = 0;
let _lastMessageWheelIntentMs = -Infinity;
let _lastMessageScrollIntentMs = -Infinity;
let _lastNonMessageScrollIntentMs = -Infinity;
let _touchStartY = null;
const performance = { now: () => 1000 };
const PROGRAMMATIC_SCROLL_VALID_MS = 150;
const container = { scrollHeight: 3510, clientHeight: 745, scrollTop: 707, contains: (t) => t === container };
const messages = container;
const document = { getElementById: (id) => (id === 'messages' ? messages : null) };
const window = { _autoScrollFollow: true };
function _messageJumpSessionId(){ return 'S1'; }
function _cancelBottomSettle(){ _cancelMessageJumpScroll(); }
function clearTimeout(){} function setTimeout(){ return 0; }
function _scheduleMessageJumpScrollReconcile(){}
function _syncScrollToBottomCue(){} function _updateSessionStartJumpButton(){}
function _flushDeferredActiveSessionExternalRefresh(){}
function _markMessageTouchScrollIntent(){}
function _freshProgrammaticScrollActive(){
if (!_programmaticScroll) return false;
const a = performance.now() - _programmaticScrollSetAt;
if (!Number.isFinite(a) || a < 0 || a > PROGRAMMATIC_SCROLL_VALID_MS){ _programmaticScroll = false; return false; }
return true;
}
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
eval(extractFunc('_recordNonMessageScrollIntent'));
_beginMessageJumpScroll(container);
container.scrollTop = 707;
_programmaticScrollSetAt = performance.now() - 200; // latch stale
_recordNonMessageScrollIntent({ type: 'wheel', target: messages, deltaY: -5 });
console.log(JSON.stringify({
ownerCleared: _messageJumpScrollOwner === null,
messageUserUnpinned: _messageUserUnpinned,
scrollPinned: _scrollPinned,
scrollTop: container.scrollTop,
}));
"""
result = json.loads(_run_node(source))
assert result == {
"ownerCleared": True,
"messageUserUnpinned": True,
"scrollPinned": False,
"scrollTop": 707,
}, "A wheel-up during the jump owner window must hand ownership to the reader unpinned, not leave them pinned mid-transcript (#6621)."
def test_wheel_down_during_jump_owner_also_takes_reader_ownership():
"""#6621 (Fable finding S3): a wheel-DOWN (or touch scroll) during the owner
window must ALSO hand ownership to the reader unpinned — not restore the
pinned snapshot and let the next streaming token yank to the bottom."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
let _scrollPinned = true;
let _messageUserUnpinned = false;
let _nearBottomCount = 2;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _lastScrollTop = null;
let _lastMessageClientHeight = 0;
let _newMessageCueVisible = false;
let _messageScrollInputGeneration = 0;
let _lastMessageWheelIntentMs = -Infinity;
let _lastMessageScrollIntentMs = -Infinity;
let _lastNonMessageScrollIntentMs = -Infinity;
let _touchStartY = null;
const performance = { now: () => 1000 };
const PROGRAMMATIC_SCROLL_VALID_MS = 150;
const container = { scrollHeight: 3510, clientHeight: 745, scrollTop: 707, contains: (t) => t === container };
const messages = container;
const document = { getElementById: (id) => (id === 'messages' ? messages : null) };
const window = { _autoScrollFollow: true };
function _messageJumpSessionId(){ return 'S1'; }
function _cancelBottomSettle(){ _cancelMessageJumpScroll(); }
function clearTimeout(){} function setTimeout(){ return 0; }
function _scheduleMessageJumpScrollReconcile(){}
function _syncScrollToBottomCue(){} function _updateSessionStartJumpButton(){}
function _flushDeferredActiveSessionExternalRefresh(){}
function _markMessageTouchScrollIntent(){}
function _freshProgrammaticScrollActive(){
if (!_programmaticScroll) return false;
const a = performance.now() - _programmaticScrollSetAt;
if (!Number.isFinite(a) || a < 0 || a > PROGRAMMATIC_SCROLL_VALID_MS){ _programmaticScroll = false; return false; }
return true;
}
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
eval(extractFunc('_recordNonMessageScrollIntent'));
_beginMessageJumpScroll(container);
container.scrollTop = 707;
_programmaticScrollSetAt = performance.now() - 200; // latch stale
_recordNonMessageScrollIntent({ type: 'wheel', target: messages, deltaY: 5 }); // DOWNWARD
console.log(JSON.stringify({
ownerCleared: _messageJumpScrollOwner === null,
messageUserUnpinned: _messageUserUnpinned,
scrollPinned: _scrollPinned,
}));
"""
result = json.loads(_run_node(source))
assert result == {
"ownerCleared": True,
"messageUserUnpinned": True,
"scrollPinned": False,
}, "A wheel-down during the jump owner window must also unpin the reader (#6621 S3)."