Merge pull request #5485 from nesquena/release/stage-5469

Release v0.51.841 — desktop scroll jump-back fix (#5469)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-07-03 15:45:43 -07:00
commit 8f86ac73cc
4 files changed
+122 -6

No files matched your search

+1
View File
@@ -14,6 +14,7 @@
### Fixed
- **Desktop: the transcript no longer jumps back up while the assistant is streaming a reply.** On desktop (which doesn't use the browser's overflow-anchoring), the scroll-position restore keyed off a content-derived anchor that a streaming chunk kept invalidating, so it silently fell back to an absolute scroll position that didn't account for the content growing above the viewport — yanking you upward mid-read. Restore now recovers via the stable per-message index when the content key goes stale, so your reading position holds steady as the answer grows. A genuinely removed message still concedes cleanly (no forced jump to the wrong row), and the separate mobile overflow-anchor fix is untouched. Thanks @allenliang2022. (#5469)
- **The live streaming view stays smooth on long answers instead of getting progressively heavier.** With Transparent Stream / compact worklog, the live response area re-rendered the entire growing answer on every streamed frame (and re-serialized the whole turn for its restore snapshot, and re-parsed the full text on every token), so a long reply got quadratically more expensive as it grew — visible as rising CPU and jank late in a response. The live prose row now renders incrementally (the same streaming markdown parser the main chat body already uses, fed only the new text), the restore snapshot is throttled, and the redundant per-token parse is skipped once the answer row exists. Settled messages still render through the canonical full-markdown path (unchanged final output), and every fallback path (parser unavailable, mid-stream edit, session switch) reverts to it cleanly. Part of the response to "WebUI feels laggy." Thanks @ai-ag2026. (#5466, #5455)
- **The sidebar no longer keeps polling and rebuilding itself in a hidden background tab.** The 30-second session-list poll and the 60-second relative-time refresh ran unconditionally, so a backgrounded window kept fetching `/api/sessions` and rebuilding a sidebar it couldn't show. Both now pause while the tab is hidden and refresh immediately when you return to it (each poll has its own `visibilitychange` handler), so nothing goes stale — you just stop paying for updates you can't see. (One small timing change: while a tab is hidden *and* a session is streaming, the in-app attention chime / unread badge now fires when you return to the tab rather than mid-hidden; OS-level notifications are unaffected, and browsers already throttled the old hidden-tab poll anyway.) Part of the response to "WebUI feels laggy." Thanks @ai-ag2026. (#5465, #5455)
- **The sidebar session list stops holding a write-capable database handle just to read.** Building the sidebar opened a read-write SQLite connection on the live (potentially multi-GB, WAL-mode) state database and re-ran a defensive index check on every refresh — needless lock/checkpoint contention while the agent is streaming into the same database. The listing now opens the database read-only, and only touches it with a separate short-lived writable connection on the rare occasion the index is actually missing. Output is unchanged. Part of the response to "WebUI feels laggy." Thanks @ai-ag2026. (#5464, #5455)
+18 -4
View File
@@ -868,13 +868,27 @@ function _restoreMessageViewportAnchor(anchor, rawIdxDelta){
const container=$('messages');
if(!container||!anchor) return false;
const anchorKey=String(anchor.key||'');
let row=anchorKey?Array.from(container.querySelectorAll('[data-message-anchor-key]')).find(el=>el&&el.dataset&&el.dataset.messageAnchorKey===anchorKey):null;
if(row&&row.getClientRects&&row.getClientRects().length===0) row=null;
if(!row&&anchorKey) return false;
const sessionIdx=Number(anchor.sessionIdx);
const hasSessionIdx=Number.isFinite(sessionIdx);
let row=anchorKey?Array.from(container.querySelectorAll('[data-message-anchor-key]')).find(el=>el&&el.dataset&&el.dataset.messageAnchorKey===anchorKey):null;
if(row&&row.getClientRects&&row.getClientRects().length===0) row=null;
// The anchor key is content-derived (role|ts|attachments|first-160-chars, built by
// _messageViewportAnchorKeyForMessage) so it goes STALE while a live assistant
// message is still streaming: every chunk that changes the first 160 chars
// recomputes that row's data-message-anchor-key, so a snapshot captured mid-stream
// no longer matches by key. We used to concede the moment the keyed lookup missed
// (`if(!row&&anchorKey) return false`), and the caller then fell back to an ABSOLUTE
// scrollTop=snapshot.top that does NOT compensate the above-viewport height growth
// from that same streaming chunk — the residual DESKTOP scroll jump-back. (Desktop
// rests at overflow-anchor:none, so #5392's mobile overflow-anchor guard is a no-op
// here; this is a distinct code path.) The anchored row is still in the DOM under
// its STABLE session-relative index, so recover it via sessionIdx before conceding.
// A genuinely removed anchor (message compressed/deleted away) misses key AND
// sessionIdx and still returns false. A missing sessionIdx is NOT degraded to the
// window-relative rawIdx (which could resolve to a different message), preserving
// the original per-tier guard.
if(!row&&hasSessionIdx) row=container.querySelector(`[data-session-msg-idx="${sessionIdx}"]`);
if(!row&&hasSessionIdx) return false;
if(!row&&(anchorKey||hasSessionIdx)) return false;
const targetIdx=Number(anchor.rawIdx)+Number(rawIdxDelta||0);
if(!row&&Number.isFinite(targetIdx)) row=container.querySelector(`[data-msg-idx="${targetIdx}"]`);
if(!row) return false;
@@ -467,3 +467,99 @@ def test_same_frame_restore_uses_the_shared_anchor_remount_fallback():
assert "if(!restoredViaAnchor&&typeof_remountMessageViewportAnchor==='function'&&_remountMessageViewportAnchor(snapshot.anchor))" in compact
assert "_messageVirtualScrollTopForVisibleIdx" not in body
def test_stale_content_key_recovers_via_session_index_and_compensates_height():
"""Desktop scroll jump-back root fix (residual, distinct from #5392 mobile path).
The viewport anchor key is content-derived (role|ts|attachments|first-160-chars),
so while a live assistant message is streaming, each chunk that changes the first
160 chars recomputes that row's data-message-anchor-key. A scroll snapshot captured
a chunk earlier now has a STALE key that no longer matches by key lookup.
The OLD code conceded immediately on a stale key (`if(!row&&anchorKey) return false`),
and the caller then fell back to an ABSOLUTE scrollTop that does not compensate the
above-viewport height change from that same chunk -> desktop jump-back. The row is
still in the DOM under its stable data-session-msg-idx, so the fix recovers it via
sessionIdx and applies the relative-offset realign (which compensates the height).
Behavior test (not a source-string check): the anchor row's on-screen top must be
held at its captured topOffset. With buggy code the keyed lookup misses, sessionIdx
is never consulted, _restoreMessageViewportAnchor returns false, and this asserts FAIL.
"""
script = f"""
const assert = require('assert');
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
// Scenario: reader is unpinned mid-transcript. A streaming chunk (a) changed the
// anchor row's content-derived key (stale key -> keyed lookup misses) and (b) grew
// content above the viewport. We assert the realign still runs (via sessionIdx) and
// writes the exact relative-offset compensation instead of conceding to an absolute
// jump. Geometry is chosen so the write is unambiguous:
// containerRect.top = 100, captured topOffset = 300 (row was 300px below container top)
// after the chunk, the row's live rect.top = 250 => (250-100) = 150 from container top
// realign write: scrollTop += (150) - 300 = -150 => 1200 - 150 = 1050
// A buggy body dead-ends on the stale key (returns false, no scrollTop write at all),
// so both asserts below FAIL on the old code.
const anchorRow = {{
getBoundingClientRect() {{ return {{ top: 250, bottom: 400 }}; }}
}};
const container = {{
scrollTop: 1200,
scrollHeight: 4600,
clientHeight: 600,
getBoundingClientRect() {{ return {{ top: 100, bottom: 700 }}; }},
querySelectorAll(selector) {{
// Stale key: NO node matches the captured (old) content key.
if (selector === '[data-message-anchor-key]') return [];
return [];
}},
querySelector(selector) {{
// The row is still present under its stable session index (and raw index).
if (selector === '[data-session-msg-idx="3"]') return anchorRow;
if (selector === '[data-msg-idx="3"]') return anchorRow;
return null;
}}
}};
function $(id) {{ return id === 'messages' ? container : null; }}
function requestAnimationFrame(fn) {{ fn(); }}
function setTimeout(fn) {{ fn(); }}
const performance = {{ now() {{ return 0; }} }};
function _suppressBrowserOverflowAnchor() {{ return null; }} // desktop no-op
function _deferClearProgrammaticScroll() {{ _programmaticScroll = false; }}
{_function_body(UI_JS, "_restoreMessageViewportAnchor")}
// Captured snapshot: anchor row was at topOffset=300, content key now STALE, but the
// row still exists at sessionIdx=3 / rawIdx=3.
const ok = _restoreMessageViewportAnchor({{
key: 'assistant|200|0|STALE_first_160_chars_from_an_earlier_chunk',
sessionIdx: 3,
rawIdx: 3,
topOffset: 300,
}}, 0);
// Must NOT concede on the stale key: sessionIdx recovers the row and the realign runs.
assert.strictEqual(ok, true, 'stale content key must recover via sessionIdx, not return false');
// Relative realign: scrollTop += (rect.top - containerRect.top) - targetTop
// = 1200 + ((250 - 100) - 300) = 1200 + (150 - 300) = 1050.
// This compensates the above-viewport height change; the buggy path would have left
// an absolute jump (or no write at all), so this exact value pins the correct behavior.
assert.strictEqual(container.scrollTop, 1050, 'realign must compensate the above-height change, not leave an absolute jump');
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_stale_key_does_not_dead_end_before_session_index_fallback():
"""Guard the exact dead-code regression: a present content key must not short-circuit
the sessionIdx fallback. The old body had `if(!row&&anchorKey) return false;` BEFORE
sessionIdx was consulted, so a stale key could never reach the session-index recovery.
This asserts that early dead-end is gone and sessionIdx is resolved before conceding.
"""
compact = _compact(_function_body(UI_JS, "_restoreMessageViewportAnchor"))
# The old dead-end (concede on stale key before trying sessionIdx) must be gone.
assert "if(!row&&anchorKey)returnfalse;" not in compact
# sessionIdx must be computed and the row recovered by it before any concede.
assert 'container.querySelector(`[data-session-msg-idx="${{sessionIdx}}"]`)'.replace("{{", "{").replace("}}", "}") in compact
# The concede now requires BOTH key AND sessionIdx to have failed.
assert "if(!row&&(anchorKey||hasSessionIdx))returnfalse;" in compact
+7 -2
View File
@@ -163,8 +163,13 @@ def test_preserve_scroll_restores_unpinned_viewport_after_dom_rebuild():
assert "row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);" in UI_JS
assert "seg.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);" in UI_JS
assert "container.querySelector(`[data-session-msg-idx=\"${sessionIdx}\"]`)" in UI_JS
assert "if(!row&&anchorKey) return false;" in UI_JS
assert "if(!row&&hasSessionIdx) return false;" in UI_JS
# Desktop scroll jump-back root fix: a stale content-derived anchor key must NOT
# dead-end before the sessionIdx fallback (the old `if(!row&&anchorKey) return false;`
# sat before sessionIdx was consulted, so a live-stream stale key could never reach
# the session-index recovery and the caller fell back to an absolute jump). The
# concede now requires BOTH key and sessionIdx lookups to have failed.
assert "if(!row&&anchorKey) return false;" not in UI_JS
assert "if(!row&&(anchorKey||hasSessionIdx)) return false;" in UI_JS
assert "_restoreMessageViewportAnchor(snapshot.anchor,0)" in restore
assert "if(!restoredViaAnchor){" in restore
assert "el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop))" in restore