diff --git a/static/ui.js b/static/ui.js index ed53d87e..c1b8b57e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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; diff --git a/tests/test_issue2246_question_jump.py b/tests/test_issue2246_question_jump.py index d14a885c..915aff41 100644 --- a/tests/test_issue2246_question_jump.py +++ b/tests/test_issue2246_question_jump.py @@ -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 diff --git a/tests/test_issue4856_android_scroll_regression.py b/tests/test_issue4856_android_scroll_regression.py index 4b1cc246..52a46a9e 100644 --- a/tests/test_issue4856_android_scroll_regression.py +++ b/tests/test_issue4856_android_scroll_regression.py @@ -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." diff --git a/tests/test_jump_to_answer_scroll_settle.py b/tests/test_jump_to_answer_scroll_settle.py new file mode 100644 index 00000000..49091538 --- /dev/null +++ b/tests/test_jump_to_answer_scroll_settle.py @@ -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)."