Merge branch 'master' into franksong2702/fix-gateway-terminal-persistence
This commit is contained in:
9 files changed
+372
-35
No files matched your search
+6
-4
@@ -566,8 +566,11 @@ def _is_agent_compression_start_status(kind: str, message: str) -> bool:
|
||||
auto-compression.
|
||||
|
||||
Positive markers below match the agent emitters in hermes-agent
|
||||
(``turn_context`` preflight, ``conversation_loop`` pre-API / 413 / too-large,
|
||||
``conversation_compression`` compaction status). Explicitly reject skip /
|
||||
(``conversation_loop`` pre-API / 413 / too-large,
|
||||
``conversation_compression`` compaction status). Preflight compression
|
||||
(``turn_context``) is intentionally excluded — the later authoritative
|
||||
``Compacting context`` marker is the signal that compression actually
|
||||
proceeded. Explicitly reject skip /
|
||||
defer notices so "Skipping preflight compression…" never surfaces as a
|
||||
running compress divider.
|
||||
"""
|
||||
@@ -587,8 +590,7 @@ def _is_agent_compression_start_status(kind: str, message: str) -> bool:
|
||||
if 'compressed' in m and 'compressing' not in m and 'compression attempt' not in m:
|
||||
return False
|
||||
return (
|
||||
'preflight compression:' in m
|
||||
or 'pre-api compression:' in m
|
||||
'pre-api compression:' in m
|
||||
or 'compacting context' in m
|
||||
or 'context too large' in m
|
||||
or '— compressing (' in m
|
||||
|
||||
+78
-18
@@ -961,6 +961,7 @@ function _captureMessageViewportAnchor(){
|
||||
// anchor's topOffset is stale and realigning to it would yank a still reader
|
||||
// backward (issue #5637).
|
||||
scrollHeightAtCapture:container.scrollHeight,
|
||||
inputGeneration:typeof _messageScrollInputGeneration==='number' ? _messageScrollInputGeneration : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5680,6 +5681,9 @@ let _lastMessageClientHeight=null; // #4702: track scroller height to ignore i
|
||||
// window after load as suppressed.
|
||||
let _lastNonMessageScrollIntentMs=-Infinity;
|
||||
let _messageUserUnpinned=false;
|
||||
// A monotonic ownership token lets delayed restores distinguish reader input
|
||||
// that happened after a snapshot from input that merely happened recently.
|
||||
let _messageScrollInputGeneration=0;
|
||||
let _bottomSettleToken=0;
|
||||
let _settleRAF=0;
|
||||
let _settleRO=null;
|
||||
@@ -5763,6 +5767,9 @@ function _recordNonMessageScrollIntent(e){
|
||||
const target=e&&e.target;
|
||||
if(!el||!target) return;
|
||||
if(!el.contains(target)){ _lastNonMessageScrollIntentMs=performance.now(); return; }
|
||||
if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY!==0)){
|
||||
if(typeof _messageScrollInputGeneration==='number') _messageScrollInputGeneration++;
|
||||
}
|
||||
// #4970: record ANY upward message-pane wheel motion as recent wheel intent,
|
||||
// including gentle low-delta trackpad wheels (e.g. deltaY:-5) that never reach
|
||||
// the decisive -30 sticky-unpin threshold below. The post-render artifact
|
||||
@@ -5966,7 +5973,10 @@ if(typeof window!=='undefined'){
|
||||
const el=document.getElementById('messages');
|
||||
if(!el) return;
|
||||
el.addEventListener('pointerdown',(e)=>{
|
||||
if(e.target===el&&e.offsetX>=el.clientWidth) _scrollbarDragActive=true;
|
||||
if(e.target===el&&e.offsetX>=el.clientWidth){
|
||||
_scrollbarDragActive=true;
|
||||
if(typeof _messageScrollInputGeneration==='number') _messageScrollInputGeneration++;
|
||||
}
|
||||
},{passive:true});
|
||||
window.addEventListener('pointerup',()=>{
|
||||
if(!_scrollbarDragActive) return;
|
||||
@@ -6012,6 +6022,7 @@ if(typeof window!=='undefined'){
|
||||
// contains the focus, or the pointer is over it (keyboard scroll w/o focus).
|
||||
if(a===el||el.contains(a)||el.matches(':hover')){
|
||||
const now=performance.now();
|
||||
if(typeof _messageScrollInputGeneration==='number') _messageScrollInputGeneration++;
|
||||
_lastMessageKeyScrollIntentMs=now;
|
||||
const bottomDistance=el.scrollHeight-el.scrollTop-el.clientHeight;
|
||||
if(bottomDistance>120) _lastMessageScrollIntentMs=now;
|
||||
@@ -12714,6 +12725,23 @@ function _prepareLiveAnchorScrollRebuildGuard(scrollSnapshot){
|
||||
},
|
||||
};
|
||||
}
|
||||
function _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot, scrollRebuildGuard){
|
||||
if(!scrollSnapshot) return;
|
||||
const hasHeightGuard=!!(scrollRebuildGuard&&scrollRebuildGuard.release);
|
||||
// Pinned renders have no height guard to release, but still need the same
|
||||
// queued ownership check: a reader can provide real input before the frame
|
||||
// settles, and that input must win over the captured tail position.
|
||||
if(!hasHeightGuard&&scrollSnapshot.pinned!==true) return;
|
||||
requestAnimationFrame(()=>{
|
||||
if(hasHeightGuard) scrollRebuildGuard.release();
|
||||
// Only re-restore the unpinned snapshot if the reader is STILL unpinned at
|
||||
// rAF time. If they re-pinned between guard-engage and this frame, the
|
||||
// stale re-restore would yank them back off the bottom (Opus gate finding).
|
||||
if(scrollSnapshot.pinned===true||_messageUserUnpinned){
|
||||
_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
|
||||
}
|
||||
});
|
||||
}
|
||||
function _resetMismatchedLiveAssistantTurnForSession(turn, sessionId){
|
||||
const sid=String(sessionId||'');
|
||||
if(!turn||!sid||!turn.dataset) return false;
|
||||
@@ -12863,15 +12891,7 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){
|
||||
_dedupeLiveProcessedWorklogAnchors(turn);
|
||||
if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd();
|
||||
_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
|
||||
if(scrollRebuildGuard&&scrollRebuildGuard.release){
|
||||
requestAnimationFrame(()=>{
|
||||
scrollRebuildGuard.release();
|
||||
// Only re-restore the unpinned snapshot if the reader is STILL unpinned at
|
||||
// rAF time. If they re-pinned between guard-engage and this frame, the
|
||||
// stale re-restore would yank them back off the bottom (Opus gate finding).
|
||||
if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
|
||||
});
|
||||
}
|
||||
_restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard);
|
||||
if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned();
|
||||
return true;
|
||||
}
|
||||
@@ -12977,12 +12997,7 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){
|
||||
if(renderedRows.length) _syncTransparentEventControls(turn);
|
||||
if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd();
|
||||
_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
|
||||
if(scrollRebuildGuard&&scrollRebuildGuard.release){
|
||||
requestAnimationFrame(()=>{
|
||||
scrollRebuildGuard.release();
|
||||
if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
|
||||
});
|
||||
}
|
||||
_restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard);
|
||||
if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned();
|
||||
return !!renderedRows.length;
|
||||
}
|
||||
@@ -14985,10 +15000,41 @@ function _captureMessageScrollSnapshot(){
|
||||
top:el.scrollTop,
|
||||
bottom,
|
||||
scrollHeight:el.scrollHeight,
|
||||
inputGeneration:typeof _messageScrollInputGeneration==='number' ? _messageScrollInputGeneration : 0,
|
||||
pinned:readerAwayFromBottom?false:_shouldFollowMessagesOnDomReplace(),
|
||||
userUnpinned:readerAwayFromBottom?true:_messageUserUnpinned,
|
||||
};
|
||||
}
|
||||
function _messageScrollSnapshotInputChanged(snapshot){
|
||||
if(!snapshot) return false;
|
||||
const captured=Number(snapshot.inputGeneration);
|
||||
const current=typeof _messageScrollInputGeneration==='number' ? _messageScrollInputGeneration : captured;
|
||||
return Number.isFinite(captured)&&Number.isFinite(current)&¤t!==captured;
|
||||
}
|
||||
function _abandonMessageScrollSnapshot(){
|
||||
const el=$('messages');
|
||||
if(!el){
|
||||
_messageUserUnpinned=true;
|
||||
_scrollPinned=false;
|
||||
_nearBottomCount=0;
|
||||
return;
|
||||
}
|
||||
_lastScrollTop=el.scrollTop||0;
|
||||
_lastMessageClientHeight=el.clientHeight||0;
|
||||
// A generation mismatch abandons only the stale snapshot write. Reconcile
|
||||
// ownership from the live viewport so a reader who moved down to the true
|
||||
// bottom is immediately re-pinned instead of being stranded sticky-unpinned.
|
||||
const bottomDistance=el.scrollHeight-el.scrollTop-el.clientHeight;
|
||||
if(bottomDistance<=80){
|
||||
_messageUserUnpinned=false;
|
||||
_scrollPinned=true;
|
||||
_nearBottomCount=2;
|
||||
}else{
|
||||
_messageUserUnpinned=true;
|
||||
_scrollPinned=false;
|
||||
_nearBottomCount=0;
|
||||
}
|
||||
}
|
||||
function _restorePinnedMessageScrollSnapshot(snapshot){
|
||||
const el=$('messages');
|
||||
if(!el||!snapshot||snapshot.pinned!==true||snapshot.userUnpinned===true) return false;
|
||||
@@ -15015,6 +15061,10 @@ function _restoreMessageScrollSnapshot(snapshot){
|
||||
// activity rebuilds can remount an older top-of-viewport anchor and yank a
|
||||
// pinned streaming transcript upward. Semantic anchors remain for manual
|
||||
// unpinned reading positions below.
|
||||
if(typeof _messageScrollSnapshotInputChanged==='function'&&_messageScrollSnapshotInputChanged(snapshot)){
|
||||
if(typeof _abandonMessageScrollSnapshot==='function') _abandonMessageScrollSnapshot();
|
||||
return;
|
||||
}
|
||||
if(_restorePinnedMessageScrollSnapshot(snapshot)) return;
|
||||
let restoredViaAnchor=(snapshot.anchor&&typeof _restoreMessageViewportAnchor==='function')
|
||||
? _restoreMessageViewportAnchor(snapshot.anchor,0)
|
||||
@@ -15233,7 +15283,14 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){
|
||||
// Same-frame live DOM updates (tool/worklog/activity rows) are the hot path for
|
||||
// streaming. Pinned followers must stay tail-relative here too; restoring the
|
||||
// semantic viewport anchor is only safe for explicitly unpinned readers.
|
||||
if(typeof _messageScrollSnapshotInputChanged==='function'&&_messageScrollSnapshotInputChanged(snapshot)){
|
||||
if(typeof _abandonMessageScrollSnapshot==='function') _abandonMessageScrollSnapshot();
|
||||
return;
|
||||
}
|
||||
if(_restorePinnedMessageScrollSnapshot(snapshot)) return;
|
||||
// A delayed rAF restore must not overwrite a position the reader changed
|
||||
// after capture. Recent-intent timestamps are lossy; the generation is
|
||||
// monotonic and therefore preserves snapshot ownership exactly.
|
||||
let restoredViaAnchor=(snapshot.anchor&&typeof _restoreMessageViewportAnchor==='function')
|
||||
? _restoreMessageViewportAnchor(snapshot.anchor,0)
|
||||
: false;
|
||||
@@ -15245,6 +15302,10 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){
|
||||
if(!restoredViaAnchor){
|
||||
const maxTop=Math.max(0,el.scrollHeight-el.clientHeight);
|
||||
const bottom=Number(snapshot.bottom);
|
||||
// Mobile/touch viewports have native overflow anchoring to hold an
|
||||
// unpinned reader across a rebuild. Desktop deliberately disables that
|
||||
// browser behavior, so it must continue into the explicit fallback below.
|
||||
const _fbTouchHold=(typeof _isTouchLikeMessageViewport==='function' && _isTouchLikeMessageViewport(el));
|
||||
// #5637: when the reader has scrolled UP into history (userUnpinned) and the
|
||||
// semantic anchor restore failed, do NOT snap scrollTop to the captured
|
||||
// ABSOLUTE snapshot.top. During streaming, the live activity-scene refresh
|
||||
@@ -15254,7 +15315,7 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){
|
||||
// untouched lets the browser's own scroll anchoring hold the reader's
|
||||
// position. Pinned / near-bottom readers still get the tail-relative restore
|
||||
// below (that path is correct and must run).
|
||||
if(snapshot.userUnpinned===true&&snapshot.pinned!==true){
|
||||
if(snapshot.userUnpinned===true&&snapshot.pinned!==true&&_fbTouchHold){
|
||||
_lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight;
|
||||
_messageUserUnpinned=true;
|
||||
_scrollPinned=false;
|
||||
@@ -15285,7 +15346,6 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){
|
||||
const _grewSinceSnap=Number.isFinite(_snapSH)&&_snapSH>0&&(el.scrollHeight-_snapSH)>4;
|
||||
const _fbActiveIntent=(typeof _recentMessageScrollIntent==='function' && _recentMessageScrollIntent())
|
||||
|| (typeof _recentMessageTouchScrollIntent==='function' && _recentMessageTouchScrollIntent());
|
||||
const _fbTouchHold=(typeof _isTouchLikeMessageViewport==='function' && _isTouchLikeMessageViewport(el));
|
||||
if(_fbTouchHold && snapshot.pinned!==true && _grewSinceSnap && !_fbActiveIntent
|
||||
&& Math.abs((Math.max(0,Math.min(target,maxTop)))-el.scrollTop)>8){
|
||||
_lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight;
|
||||
|
||||
@@ -473,7 +473,13 @@ def test_agent_status_callback_emits_compressing_and_warning_events():
|
||||
|
||||
def test_agent_compression_start_status_matches_real_emitters_only():
|
||||
# Real start notices from hermes-agent emitters
|
||||
assert _is_agent_compression_start_status(
|
||||
# Preflight compression is intentionally excluded — the later
|
||||
# authoritative ``Compacting context`` marker from
|
||||
# conversation_compression is the signal that compression
|
||||
# actually proceeded, and the preflight status can fire even
|
||||
# when compression exits before compaction (e.g. durable
|
||||
# guard refresh, Codex-Hermes mode with no active thread).
|
||||
assert not _is_agent_compression_start_status(
|
||||
"lifecycle",
|
||||
"📦 Preflight compression: ~101,000 tokens >= 96,000 threshold. This may take a moment.",
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ def test_live_anchor_worklog_rebuild_restores_snapshot_before_follow_settle():
|
||||
dedupe_idx = body.index("_dedupeLiveProcessedWorklogAnchors(turn);")
|
||||
move_status_idx = body.index("_moveLiveRunStatusToTurnEnd();")
|
||||
restore_idx = body.index("_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);")
|
||||
release_idx = body.index("if(scrollRebuildGuard&&scrollRebuildGuard.release)")
|
||||
release_idx = body.index("_restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard);")
|
||||
settle_idx = body.index("if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned();")
|
||||
|
||||
assert capture_idx < guard_idx < remove_idx < restore_detail_idx < dedupe_idx < move_status_idx < restore_idx < release_idx < settle_idx
|
||||
|
||||
@@ -311,6 +311,8 @@ eval(extractFunc('_bindTransparentFadeCleanup'));
|
||||
eval(extractFunc('_appendTransparentFadeText'));
|
||||
eval(extractFunc('_refreshTransparentFadeProseRow'));
|
||||
eval(extractFunc('_refreshTransparentLiveRow'));
|
||||
eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild'));
|
||||
eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild'));
|
||||
eval(extractFunc('_renderLiveAnchorActivitySceneTransparent'));
|
||||
|
||||
const firstScene = {{
|
||||
@@ -1264,6 +1266,7 @@ eval(extractFunc('_transparentLiveRowInteractiveState'));
|
||||
eval(extractFunc('_rehydrateTransparentLiveRow'));
|
||||
eval(extractFunc('_refreshTransparentThinkingLiveRow'));
|
||||
eval(extractFunc('_refreshTransparentLiveRow'));
|
||||
eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild'));
|
||||
eval(extractFunc('_renderLiveAnchorActivitySceneTransparent'));
|
||||
|
||||
const firstScene = { version:'activity_scene_v1', activity_rows:[{ row_id:'row-tool', role:'tool', source_event_type:'tool_delta', toolName:'old_tool' }] };
|
||||
@@ -1601,6 +1604,7 @@ eval(extractFunc('_transparentLiveRowInteractiveState'));
|
||||
eval(extractFunc('_rehydrateTransparentLiveRow'));
|
||||
eval(extractFunc('_refreshTransparentThinkingLiveRow'));
|
||||
eval(extractFunc('_refreshTransparentLiveRow'));
|
||||
eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild'));
|
||||
eval(extractFunc('_renderLiveAnchorActivitySceneTransparent'));
|
||||
global._copyEventToClipboard = (row) => {{
|
||||
const tc = row && row._tcData || {{}};
|
||||
|
||||
@@ -94,17 +94,19 @@ def test_content_visibility_scoped_to_user_rows():
|
||||
)
|
||||
|
||||
|
||||
def test_restore_same_frame_holds_position_for_unpinned_reader():
|
||||
"""The absolute-top fallback must be skipped for an unpinned reader.
|
||||
def test_restore_same_frame_holds_position_for_unpinned_touch_reader():
|
||||
"""The absolute-top fallback must be skipped when native anchoring can hold.
|
||||
|
||||
When the reader is scrolled up (userUnpinned) and the anchor restore failed,
|
||||
_restoreMessageScrollSnapshotSameFrame must NOT write an absolute snapshot.top
|
||||
(it goes stale and nudges the viewport backward as scrollHeight grows, #5637).
|
||||
It should hold position and return before the el.scrollTop write.
|
||||
It should hold position and return before the el.scrollTop write. Desktop
|
||||
intentionally falls through to the explicit fallback because it has no
|
||||
native overflow-anchor layer.
|
||||
"""
|
||||
fn = _restore_same_frame_fn()
|
||||
assert "snapshot.userUnpinned===true&&snapshot.pinned!==true" in fn, (
|
||||
"The fallback must guard the unpinned/anchor-failed case (#5637)."
|
||||
assert "snapshot.userUnpinned===true&&snapshot.pinned!==true&&_fbTouchHold" in fn, (
|
||||
"The fallback must guard the unpinned/anchor-failed touch case (#5637)."
|
||||
)
|
||||
# The guard must sit BEFORE the absolute-top scrollTop write and short-circuit it.
|
||||
guard_idx = fn.index("snapshot.userUnpinned===true&&snapshot.pinned!==true")
|
||||
|
||||
@@ -52,6 +52,185 @@ def _run_node(source: str) -> str:
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _extract_js_function(js: str, name: str) -> str:
|
||||
"""Extract a production function without runtime eval/source execution."""
|
||||
marker = f"function {name}("
|
||||
start = js.index(marker)
|
||||
brace = js.index("{", start)
|
||||
depth = 0
|
||||
quote = None
|
||||
escaped = False
|
||||
line_comment = False
|
||||
block_comment = False
|
||||
index = brace
|
||||
while index < len(js):
|
||||
char = js[index]
|
||||
next_char = js[index + 1] if index + 1 < len(js) else ""
|
||||
if line_comment:
|
||||
if char == "\n":
|
||||
line_comment = False
|
||||
index += 1
|
||||
continue
|
||||
if block_comment:
|
||||
if char == "*" and next_char == "/":
|
||||
block_comment = False
|
||||
index += 2
|
||||
continue
|
||||
index += 1
|
||||
continue
|
||||
if quote:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == quote:
|
||||
quote = None
|
||||
index += 1
|
||||
continue
|
||||
if char == "/" and next_char == "/":
|
||||
line_comment = True
|
||||
index += 2
|
||||
continue
|
||||
if char == "/" and next_char == "*":
|
||||
block_comment = True
|
||||
index += 2
|
||||
continue
|
||||
if char in "'\"`":
|
||||
quote = char
|
||||
index += 1
|
||||
continue
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[start : index + 1]
|
||||
index += 1
|
||||
raise AssertionError(f"JavaScript function {name} did not close")
|
||||
|
||||
|
||||
def _live_render_ownership_harness(*, move_to_bottom: bool) -> str:
|
||||
"""Compose real input production with the production queued live-render restore."""
|
||||
js = UI_JS_PATH.read_text(encoding="utf-8")
|
||||
functions = [
|
||||
"renderLiveAnchorActivityScene",
|
||||
"_recordNonMessageScrollIntent",
|
||||
"_captureMessageScrollSnapshot",
|
||||
"_messageScrollSnapshotInputChanged",
|
||||
"_abandonMessageScrollSnapshot",
|
||||
"_restorePinnedMessageScrollSnapshot",
|
||||
"_restoreMessageScrollSnapshotSameFrame",
|
||||
"_prepareLiveAnchorScrollRebuildGuard",
|
||||
"_restoreLiveAnchorScrollSnapshotAfterRebuild",
|
||||
]
|
||||
production = "\n".join(_extract_js_function(js, name) for name in functions)
|
||||
target_delta = 100 if move_to_bottom else -100
|
||||
final_top = 90026 if move_to_bottom else 89000
|
||||
return production + f"""
|
||||
let writes=[];
|
||||
let _messageScrollInputGeneration=0;
|
||||
let _messageUserUnpinned={str(move_to_bottom).lower()};
|
||||
let _scrollPinned={str(not move_to_bottom).lower()};
|
||||
let _nearBottomCount=0;
|
||||
let _lastScrollTop=null, _lastMessageClientHeight=null;
|
||||
let _programmaticScroll=false, _programmaticScrollSetAt=0;
|
||||
let recordWrites=true;
|
||||
const callbacks=[];
|
||||
const performance={{now(){{return 1;}}}};
|
||||
const el={{
|
||||
scrollHeight:90453, clientHeight:427,
|
||||
get scrollTop(){{return this._top;}},
|
||||
set scrollTop(value){{if(recordWrites) writes.push(Math.round(value)); this._top=value;}},
|
||||
_top:{'89577' if move_to_bottom else '90026'},
|
||||
contains(target){{return target===this;}},
|
||||
querySelector(){{return null;}},
|
||||
}};
|
||||
const document={{getElementById(id){{return id==='messages'?el:null;}}}};
|
||||
const msgInner={{style:{{}},dataset:{{}}}};
|
||||
const emptyState={{style:{{display:''}}}};
|
||||
const turn={{
|
||||
dataset:{{}},
|
||||
setAttribute(){{}},
|
||||
querySelectorAll(){{return [];}},
|
||||
}};
|
||||
const blocks={{querySelectorAll(){{return [];}}}};
|
||||
function $(id){{
|
||||
if(id==='messages') return el;
|
||||
if(id==='msgInner') return msgInner;
|
||||
if(id==='emptyState') return emptyState;
|
||||
if(id==='liveAssistantTurn') return turn;
|
||||
return null;
|
||||
}}
|
||||
function _captureMessageViewportAnchor(){{return null;}}
|
||||
function _shouldFollowMessagesOnDomReplace(){{return true;}}
|
||||
function _deferClearProgrammaticScroll(){{}}
|
||||
function _cancelBottomSettle(){{}}
|
||||
function _restoreMessageViewportAnchor(){{return false;}}
|
||||
function _remountMessageViewportAnchor(){{return false;}}
|
||||
function _desktopAnchorRealignDelta(){{return null;}}
|
||||
function _isTouchLikeMessageViewport(){{return false;}}
|
||||
function _recentMessageScrollIntent(){{return false;}}
|
||||
function _recentMessageTouchScrollIntent(){{return false;}}
|
||||
function _restoreMessageScrollSnapshotSameFrameFallback(){{}}
|
||||
function requestAnimationFrame(callback){{callbacks.push(callback);}}
|
||||
function chatActivityMode(){{return 'compact_worklog';}}
|
||||
function isSimplifiedToolCalling(){{return true;}}
|
||||
const S={{session:{{session_id:'sid-1',pending_started_at:1}},activeStreamId:'stream-1'}};
|
||||
function _anchorSceneRowsForRendering(){{return [];}}
|
||||
function _assistantTurnBlocks(){{return blocks;}}
|
||||
function _captureWorklogDetailDisclosureState(){{return null;}}
|
||||
function _anchorSceneWorklogGroup(){{return {{}};}}
|
||||
function _renderAnchorSceneRowsIntoWorklog(){{return true;}}
|
||||
function _restoreWorklogDetailDisclosureState(){{}}
|
||||
function _startActivityElapsedTimer(){{}}
|
||||
function _dedupeLiveProcessedWorklogAnchors(){{}}
|
||||
function _moveLiveRunStatusToTurnEnd(){{}}
|
||||
function scrollIfPinned(){{}}
|
||||
const rendered=renderLiveAnchorActivityScene('stream-1',{{activity_rows:[]}},{{sessionId:'sid-1'}});
|
||||
const initialWrites=writes.slice();
|
||||
writes=[];
|
||||
const guardQueued=callbacks.length>0;
|
||||
recordWrites=false;
|
||||
_recordNonMessageScrollIntent({{target:el,deltaY:{target_delta}}});
|
||||
el.scrollTop={final_top};
|
||||
recordWrites=true;
|
||||
callbacks.shift()();
|
||||
console.log(JSON.stringify({{rendered,guardQueued,initialWrites,writes, finalScrollTop:el.scrollTop,
|
||||
messageUserUnpinned:_messageUserUnpinned, scrollPinned:_scrollPinned,
|
||||
generation:_messageScrollInputGeneration}}));
|
||||
"""
|
||||
|
||||
|
||||
def test_live_render_queue_preserves_real_upward_input_from_pinned_capture():
|
||||
"""The real producer and queued live-render callback preserve upward input."""
|
||||
result = json.loads(_run_node(_live_render_ownership_harness(move_to_bottom=False)))
|
||||
assert result == {
|
||||
"rendered": True,
|
||||
"guardQueued": True,
|
||||
"initialWrites": [90026],
|
||||
"writes": [],
|
||||
"finalScrollTop": 89000,
|
||||
"messageUserUnpinned": True,
|
||||
"scrollPinned": False,
|
||||
"generation": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_live_render_queue_repins_real_downward_input_at_bottom():
|
||||
"""The real producer and queued live-render callback re-pin at true bottom."""
|
||||
result = json.loads(_run_node(_live_render_ownership_harness(move_to_bottom=True)))
|
||||
assert result == {
|
||||
"rendered": True,
|
||||
"guardQueued": True,
|
||||
"initialWrites": [89577],
|
||||
"writes": [],
|
||||
"finalScrollTop": 90026,
|
||||
"messageUserUnpinned": False,
|
||||
"scrollPinned": True,
|
||||
"generation": 1,
|
||||
}
|
||||
|
||||
|
||||
def _extract_func_script(js: str) -> str:
|
||||
prelude = "const src = " + json.dumps(js) + ";\n"
|
||||
body = r"""
|
||||
@@ -175,8 +354,12 @@ def test_realign_allows_on_desktop_no_native_anchor():
|
||||
def _fallback_harness(*, snapshot_scroll_height, cur_scroll_height, snapshot_top,
|
||||
active_intent: bool = False, touch_like: bool = True,
|
||||
init_scroll_top: int = 90030,
|
||||
snapshot_user_unpinned: bool = False,
|
||||
snapshot_pinned: bool = False,
|
||||
anchor=None, anchor_row_content_pos=None, container_top: int = 0,
|
||||
top_pad_now=None) -> str:
|
||||
top_pad_now=None, input_generation: int = 0,
|
||||
delayed_input_generation=None, delayed_scroll_top: int = 89000,
|
||||
restore_fn: str = "_restoreMessageScrollSnapshotSameFrame") -> str:
|
||||
"""Node harness for the absolute-fallback path of _restoreMessageScrollSnapshotSameFrame.
|
||||
|
||||
SCROLL-DEPENDENT geometry (round-3 gate-cert requirement): the anchor row's
|
||||
@@ -202,6 +385,7 @@ def _fallback_harness(*, snapshot_scroll_height, cur_scroll_height, snapshot_top
|
||||
return _extract_func_script(js) + f"""
|
||||
let writes = [];
|
||||
let stTop = {init_scroll_top};
|
||||
let _messageScrollInputGeneration = {input_generation};
|
||||
const CONTAINER_TOP = {container_top};
|
||||
const ROW_CONTENT_POS = {row_pos_js};
|
||||
const ROW_PRESENT = {"true" if row_present else "false"};
|
||||
@@ -230,7 +414,12 @@ function _recentMessageScrollIntent(){{ return {intent_js}; }}
|
||||
function _recentMessageTouchScrollIntent(){{ return {intent_js}; }}
|
||||
function _isTouchLikeMessageViewport(){{ return {touch_js}; }}
|
||||
// realign path fails (no anchor restore) so execution reaches the absolute fallback
|
||||
function _restorePinnedMessageScrollSnapshot(){{ return false; }}
|
||||
function _restorePinnedMessageScrollSnapshot(snapshot){{
|
||||
if(snapshot.pinned!==true) return false;
|
||||
const pinnedTarget=el.scrollHeight-el.clientHeight-(Number(snapshot.bottom)||0);
|
||||
el.scrollTop=pinnedTarget;
|
||||
return true;
|
||||
}}
|
||||
function _restoreMessageViewportAnchor(){{ return false; }}
|
||||
function _remountMessageViewportAnchor(){{ return false; }}
|
||||
let _messageUserUnpinned = false; let _scrollPinned = true; let _nearBottomCount = 5;
|
||||
@@ -241,10 +430,13 @@ function _deferClearProgrammaticScroll(){{}}
|
||||
function requestAnimationFrame(cb){{ cb(); }}
|
||||
function setTimeout(cb){{ cb(); return 1; }}
|
||||
const snapshot = {{ anchor: {anchor_js}, top: {snapshot_top}, bottom: 40,
|
||||
scrollHeight: {sh}, pinned: false, userUnpinned: false }};
|
||||
scrollHeight: {sh}, inputGeneration: {input_generation}, pinned: {str(snapshot_pinned).lower()}, userUnpinned: {str(snapshot_user_unpinned).lower()} }};
|
||||
eval(extractFunc('_desktopAnchorRealignDelta'));
|
||||
eval(extractFunc('_restoreMessageScrollSnapshotSameFrame'));
|
||||
_restoreMessageScrollSnapshotSameFrame(snapshot);
|
||||
eval(extractFunc('_messageScrollSnapshotInputChanged'));
|
||||
eval(extractFunc('_abandonMessageScrollSnapshot'));
|
||||
eval(extractFunc({json.dumps(restore_fn)}));
|
||||
{restore_fn}(snapshot);
|
||||
{f"stTop = {delayed_scroll_top}; _messageScrollInputGeneration = {delayed_input_generation}; {restore_fn}(snapshot);" if delayed_input_generation is not None else ""}
|
||||
console.log(JSON.stringify({{ wrote: writes.length, writes,
|
||||
messageUserUnpinned: _messageUserUnpinned, scrollPinned: _scrollPinned, finalScrollTop: Math.round(stTop) }}));
|
||||
"""
|
||||
@@ -312,6 +504,75 @@ def test_fallback_allows_snapshot_top_on_desktop_no_native_anchor():
|
||||
assert m["messageUserUnpinned"] is False and m["scrollPinned"] is True
|
||||
|
||||
|
||||
def test_unpinned_anchor_miss_restores_on_desktop_without_native_anchor():
|
||||
"""A desktop reader who deliberately scrolled up cannot be left to native
|
||||
anchoring: desktop disables it. If the semantic row is temporarily absent,
|
||||
the rebuild may clamp scrollTop toward zero before this function runs, so the
|
||||
fallback must restore the snapshot rather than taking the mobile no-write exit.
|
||||
"""
|
||||
m = json.loads(_run_node(_fallback_harness(
|
||||
snapshot_scroll_height=90000, cur_scroll_height=90453, snapshot_top=89577,
|
||||
active_intent=False, touch_like=False, snapshot_user_unpinned=True,
|
||||
init_scroll_top=0,
|
||||
)))
|
||||
assert m["writes"] == [89577]
|
||||
assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False
|
||||
|
||||
|
||||
def test_delayed_restore_cannot_overwrite_new_desktop_input():
|
||||
"""The synchronous restore owns generation 0, then the reader moves to 89000
|
||||
before the delayed rAF restore. The second call must abandon the stale snapshot
|
||||
instead of writing its old 89577 target."""
|
||||
m = json.loads(_run_node(_fallback_harness(
|
||||
snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577,
|
||||
active_intent=False, touch_like=False, snapshot_user_unpinned=True,
|
||||
init_scroll_top=89577, input_generation=0, delayed_input_generation=1,
|
||||
)))
|
||||
assert m["writes"] == [89577]
|
||||
assert m["finalScrollTop"] == 89000
|
||||
assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("restore_fn", [
|
||||
"_restoreMessageScrollSnapshot",
|
||||
"_restoreMessageScrollSnapshotSameFrame",
|
||||
])
|
||||
def test_delayed_pinned_restore_cannot_overwrite_new_upward_input(restore_fn):
|
||||
"""A pinned-at-capture snapshot must lose ownership before its tail write.
|
||||
|
||||
The reader moves upward before the delayed restore; the stale pinned snapshot
|
||||
must not put them back at its captured tail-relative position.
|
||||
"""
|
||||
m = json.loads(_run_node(_fallback_harness(
|
||||
snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577,
|
||||
active_intent=False, touch_like=False, snapshot_pinned=True,
|
||||
init_scroll_top=89577, input_generation=0, delayed_input_generation=1,
|
||||
delayed_scroll_top=89000,
|
||||
restore_fn=restore_fn,
|
||||
)))
|
||||
assert m["writes"] == [89986]
|
||||
assert m["finalScrollTop"] == 89000
|
||||
assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("restore_fn", [
|
||||
"_restoreMessageScrollSnapshot",
|
||||
"_restoreMessageScrollSnapshotSameFrame",
|
||||
])
|
||||
def test_delayed_unpinned_restore_repins_reader_who_reached_bottom(restore_fn):
|
||||
"""Abandoning a stale unpinned snapshot must reconcile a live bottom position."""
|
||||
m = json.loads(_run_node(_fallback_harness(
|
||||
snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577,
|
||||
active_intent=False, touch_like=False, snapshot_user_unpinned=True,
|
||||
init_scroll_top=89577, input_generation=0, delayed_input_generation=1,
|
||||
delayed_scroll_top=90026,
|
||||
restore_fn=restore_fn,
|
||||
)))
|
||||
assert m["writes"] == [89577]
|
||||
assert m["finalScrollTop"] == 90026
|
||||
assert m["messageUserUnpinned"] is False and m["scrollPinned"] is True
|
||||
|
||||
|
||||
# ---- round-3 desktop anchor-realign (PR #5742): app's own scrollTop += delta idiom -----
|
||||
# These use SCROLL-DEPENDENT rect mocks (rect.top = rowContentPos - scrollTop), so the
|
||||
# certified hold numbers are physically realizable in a real browser — the round-2
|
||||
@@ -479,6 +740,7 @@ def test_predicate_stays_true_on_touch_when_inline_anchor_clobbered_to_none():
|
||||
assert m["touchLike"] is True
|
||||
|
||||
|
||||
|
||||
def test_predicate_false_on_desktop_fine_pointer():
|
||||
"""Desktop (fine pointer): matchMedia('(pointer:coarse)') is false and the resting
|
||||
computed overflow-anchor is 'none' -> predicate reports NOT touch, so the guards do
|
||||
@@ -546,4 +808,3 @@ def test_predicate_true_on_android_not_ios():
|
||||
platform="Linux armv8l", max_touch_points=5,
|
||||
)))
|
||||
assert m["touchLike"] is True
|
||||
|
||||
@@ -476,6 +476,7 @@ for(const name of [
|
||||
'_liveAnchorReasoningRowForFallback','_updateLiveAnchorReasoningRowForFallback',
|
||||
'_anchorSceneNodeForRow','_anchorSceneWorklogGroup','_renderAnchorSceneRowsIntoWorklog',
|
||||
'isLiveAnchorActivitySceneOwner','_projectLiveAnchorActivitySceneForStream',
|
||||
'_restoreLiveAnchorScrollSnapshotAfterRebuild',
|
||||
'_renderLiveAnchorActivitySceneTransparent','renderLiveAnchorActivityScene',
|
||||
'_renderLiveAnchorActivitySceneForStream','appendThinking','updateThinking',
|
||||
]) eval(extractFunc(uiSrc,name));
|
||||
|
||||
@@ -1414,6 +1414,7 @@ global._syncToolCallGroupSummary=()=>{{}};
|
||||
eval(extractFunc('_anchorSceneLiveTokenFinalPrefix'));
|
||||
eval(extractFunc('_anchorSceneTransparentNodeForRow'));
|
||||
eval(extractFunc('renderLiveAnchorActivityScene'));
|
||||
eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild'));
|
||||
eval(extractFunc('_transparentLiveRowKey'));
|
||||
eval(extractFunc('_transparentLiveRowsCompatible'));
|
||||
eval(extractFunc('_transparentLiveRowAttributePairs'));
|
||||
|
||||
Reference in new issue
Block a user