Merge branch 'master' into franksong2702/fix-gateway-terminal-persistence

This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-07-31 11:47:43 -07:00
commit 07b33505fd
9 files changed
+372 -35

No files matched your search

+6 -4
View File
@@ -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
View File
@@ -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)&&current!==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;
+7 -1
View File
@@ -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")
+267 -6
View File
@@ -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
+1
View File
@@ -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'));