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:
4 files changed
+933
-10
No files matched your search
+148
-8
@@ -1660,6 +1660,11 @@ function _highlightQuestionRow(row){
|
|||||||
async function jumpToTurnQuestion(questionRawIdx, assistantRawIdx){
|
async function jumpToTurnQuestion(questionRawIdx, assistantRawIdx){
|
||||||
const container=$('messages');
|
const container=$('messages');
|
||||||
if(!container||typeof questionRawIdx!=='number'||questionRawIdx<0) return;
|
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 scrollToTarget=()=>{
|
||||||
const hasAssistant=typeof assistantRawIdx==='number'&&assistantRawIdx>=0;
|
const hasAssistant=typeof assistantRawIdx==='number'&&assistantRawIdx>=0;
|
||||||
if(hasAssistant){
|
if(hasAssistant){
|
||||||
@@ -1682,14 +1687,18 @@ async function jumpToTurnQuestion(questionRawIdx, assistantRawIdx){
|
|||||||
_highlightQuestionRow(row);
|
_highlightQuestionRow(row);
|
||||||
return true;
|
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;
|
if(scrollToTarget()) return;
|
||||||
const visWithIdx=_getVisibleMessagesWithIdx();
|
const visWithIdx=_getVisibleMessagesWithIdx();
|
||||||
const visibleIdx=_messageVisibleIndexForRawIdx(questionRawIdx, visWithIdx);
|
const visibleIdx=_messageVisibleIndexForRawIdx(questionRawIdx, visWithIdx);
|
||||||
if(visibleIdx>=0){
|
if(visibleIdx>=0){
|
||||||
_scrollPinned=false;
|
|
||||||
_messageUserUnpinned=true;
|
|
||||||
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
|
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
|
||||||
container.scrollTop=_messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container);
|
const virtualTarget=clampTargetScrollTop(_messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container));
|
||||||
|
container.scrollTop=virtualTarget;
|
||||||
_messageVirtualWindowKey='';
|
_messageVirtualWindowKey='';
|
||||||
renderMessages({ preserveScroll:true });
|
renderMessages({ preserveScroll:true });
|
||||||
requestAnimationFrame(()=>{
|
requestAnimationFrame(()=>{
|
||||||
@@ -5761,6 +5770,96 @@ function _freshProgrammaticScrollActive(){
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function _deferClearProgrammaticScroll(ms){clearTimeout(_programmaticScrollResetTimer);_programmaticScrollResetTimer=setTimeout(()=>{_programmaticScroll=false;},ms||80);}
|
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 _nearBottomCount=0;
|
||||||
let _lastScrollTop=null;
|
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)
|
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){
|
function _recentMessageRenderArtifactWindow(ms){
|
||||||
return performance.now()-_lastMessageRenderAt<(ms||1400);
|
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){
|
function _markMessageTouchScrollIntent(active=true){
|
||||||
_messageTouchScrollActive=!!active;
|
_messageTouchScrollActive=!!active;
|
||||||
_lastMessageTouchScrollIntentMs=performance.now();
|
_lastMessageTouchScrollIntentMs=performance.now();
|
||||||
@@ -5859,15 +5958,33 @@ function _recordNonMessageScrollIntent(e){
|
|||||||
const target=e&&e.target;
|
const target=e&&e.target;
|
||||||
if(!el||!target) return;
|
if(!el||!target) return;
|
||||||
if(!el.contains(target)){ _lastNonMessageScrollIntentMs=performance.now(); 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(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY!==0)){
|
||||||
if(typeof _messageScrollInputGeneration==='number') _messageScrollInputGeneration++;
|
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();
|
if(typeof e.deltaY==='number'&&e.deltaY<0) _lastMessageWheelIntentMs=performance.now();
|
||||||
// Keep e.deltaY< -30 as the ordinary direct sticky-unpin threshold.
|
// 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){
|
if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY< -30)||guardedWheelUp){
|
||||||
_cancelBottomSettle();
|
|
||||||
if(e.type==='touchmove') _markMessageTouchScrollIntent(true);
|
if(e.type==='touchmove') _markMessageTouchScrollIntent(true);
|
||||||
if((typeof e.deltaY==='number'&&e.deltaY< -30)||guardedWheelUp){
|
if((typeof e.deltaY==='number'&&e.deltaY< -30)||guardedWheelUp){
|
||||||
_messageUserUnpinned=true;
|
_messageUserUnpinned=true;
|
||||||
@@ -5959,6 +6076,7 @@ if(typeof document!=='undefined'){
|
|||||||
// prevent the new chat's first scroll comparing against the previous chat's
|
// prevent the new chat's first scroll comparing against the previous chat's
|
||||||
// scrollTop (Opus stage-302 SHOULD-FIX, #1731 follow-up).
|
// scrollTop (Opus stage-302 SHOULD-FIX, #1731 follow-up).
|
||||||
function _resetScrollDirectionTracker(){
|
function _resetScrollDirectionTracker(){
|
||||||
|
_cancelMessageJumpScroll();
|
||||||
_clearNewMessageScrollCue();
|
_clearNewMessageScrollCue();
|
||||||
_lastScrollTop=null;
|
_lastScrollTop=null;
|
||||||
_lastMessageClientHeight=null;
|
_lastMessageClientHeight=null;
|
||||||
@@ -5980,6 +6098,11 @@ function _resetScrollDirectionTracker(){
|
|||||||
_deferredOlderMessagesTimer=0;
|
_deferredOlderMessagesTimer=0;
|
||||||
}
|
}
|
||||||
function _resetStreamScrollFollow(){
|
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();
|
_clearNewMessageScrollCue();
|
||||||
_messageUserUnpinned=false;
|
_messageUserUnpinned=false;
|
||||||
_scrollPinned=true;
|
_scrollPinned=true;
|
||||||
@@ -5992,7 +6115,6 @@ function _resetStreamScrollFollow(){
|
|||||||
_lastMessageScrollIntentMs=-Infinity;
|
_lastMessageScrollIntentMs=-Infinity;
|
||||||
// #4970 review (greptile P1): same hygiene for keyboard scroll intent.
|
// #4970 review (greptile P1): same hygiene for keyboard scroll intent.
|
||||||
_lastMessageKeyScrollIntentMs=-Infinity;
|
_lastMessageKeyScrollIntentMs=-Infinity;
|
||||||
_cancelBottomSettle();
|
|
||||||
}
|
}
|
||||||
if(typeof window!=='undefined'){
|
if(typeof window!=='undefined'){
|
||||||
window._resetScrollDirectionTracker=_resetScrollDirectionTracker;
|
window._resetScrollDirectionTracker=_resetScrollDirectionTracker;
|
||||||
@@ -6072,6 +6194,7 @@ if(typeof window!=='undefined'){
|
|||||||
if(!el) return;
|
if(!el) return;
|
||||||
el.addEventListener('pointerdown',(e)=>{
|
el.addEventListener('pointerdown',(e)=>{
|
||||||
if(e.target===el&&e.offsetX>=el.clientWidth){
|
if(e.target===el&&e.offsetX>=el.clientWidth){
|
||||||
|
if(typeof _cancelBottomSettle==='function') _cancelBottomSettle();
|
||||||
_scrollbarDragActive=true;
|
_scrollbarDragActive=true;
|
||||||
if(typeof _messageScrollInputGeneration==='number') _messageScrollInputGeneration++;
|
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,
|
// 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).
|
// contains the focus, or the pointer is over it (keyboard scroll w/o focus).
|
||||||
if(a===el||el.contains(a)||el.matches(':hover')){
|
if(a===el||el.contains(a)||el.matches(':hover')){
|
||||||
|
if(typeof _cancelBottomSettle==='function') _cancelBottomSettle();
|
||||||
const now=performance.now();
|
const now=performance.now();
|
||||||
if(typeof _messageScrollInputGeneration==='number') _messageScrollInputGeneration++;
|
if(typeof _messageScrollInputGeneration==='number') _messageScrollInputGeneration++;
|
||||||
_lastMessageKeyScrollIntentMs=now;
|
_lastMessageKeyScrollIntentMs=now;
|
||||||
@@ -6129,6 +6253,10 @@ if(typeof window!=='undefined'){
|
|||||||
let _scrollRaf=0;
|
let _scrollRaf=0;
|
||||||
el.addEventListener('scroll',()=>{
|
el.addEventListener('scroll',()=>{
|
||||||
_scheduleMessageVirtualizedRender();
|
_scheduleMessageVirtualizedRender();
|
||||||
|
if(_messageJumpScrollOwner){
|
||||||
|
_scheduleMessageJumpScrollReconcile(_messageJumpScrollOwner.generation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if(_freshProgrammaticScrollActive()) return;
|
if(_freshProgrammaticScrollActive()) return;
|
||||||
_markMessageVirtualScrollActive();
|
_markMessageVirtualScrollActive();
|
||||||
cancelAnimationFrame(_scrollRaf);
|
cancelAnimationFrame(_scrollRaf);
|
||||||
@@ -6987,6 +7115,11 @@ function _settleFinalScroll(token){
|
|||||||
}
|
}
|
||||||
function scrollIfPinned(){
|
function scrollIfPinned(){
|
||||||
if(!window._autoScrollFollow) return;
|
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){
|
if(_messageUserUnpinned){
|
||||||
// Only scrollToBottom() cleared this flag, so one scroll-up permanently
|
// Only scrollToBottom() cleared this flag, so one scroll-up permanently
|
||||||
// killed auto-follow. Re-pin ONLY when the reader has genuinely returned to
|
// killed auto-follow. Re-pin ONLY when the reader has genuinely returned to
|
||||||
@@ -7011,6 +7144,13 @@ function scrollIfPinned(){
|
|||||||
_settleMessageScrollToBottom(false);
|
_settleMessageScrollToBottom(false);
|
||||||
}
|
}
|
||||||
function scrollToBottom(){
|
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();
|
_clearNewMessageScrollCue();
|
||||||
_scrollPinned=true;
|
_scrollPinned=true;
|
||||||
_messageUserUnpinned=false;
|
_messageUserUnpinned=false;
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ def test_question_jump_expands_windowed_history_and_highlights_question():
|
|||||||
assert "function _messageVisibleIndexForRawIdx(rawIdx, visWithIdx)" in UI_JS
|
assert "function _messageVisibleIndexForRawIdx(rawIdx, visWithIdx)" in UI_JS
|
||||||
assert "function _messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container)" in UI_JS
|
assert "function _messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container)" in UI_JS
|
||||||
assert "const visibleIdx=_messageVisibleIndexForRawIdx(questionRawIdx, visWithIdx);" 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 "_messageVirtualWindowKey='';" in UI_JS
|
||||||
assert "_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount())" in UI_JS
|
assert "_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount())" in UI_JS
|
||||||
assert "renderMessages({ preserveScroll:true })" 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
|
assert "function _recentMessageWheelIntent" in UI_JS
|
||||||
rec_idx = UI_JS.find("function _recordNonMessageScrollIntent")
|
rec_idx = UI_JS.find("function _recordNonMessageScrollIntent")
|
||||||
assert rec_idx != -1, "_recordNonMessageScrollIntent not found"
|
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, (
|
assert "e.deltaY<0) _lastMessageWheelIntentMs=performance.now()" in rec, (
|
||||||
"#4970: _recordNonMessageScrollIntent must record low-delta upward wheel "
|
"#4970: _recordNonMessageScrollIntent must record low-delta upward wheel "
|
||||||
"intent (deltaY<0) separately from the decisive deltaY<-30 unpin."
|
"intent (deltaY<0) separately from the decisive deltaY<-30 unpin."
|
||||||
|
|||||||
@@ -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)."
|
||||||
Reference in new issue
Block a user