diff --git a/CHANGELOG.md b/CHANGELOG.md index c601537c..5bb4b6bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +## [v0.51.214] — 2026-06-02 — Release GH (stage-p2c — preserve loaded transcript width on same-session external refresh) + +### Fixed +- A same-session external refresh (e.g. a background poll triggering a force-reload of the conversation you're reading) no longer collapses a long transcript back to the default 30-message tail window and jumps the viewport to a different slice. The already-loaded transcript width and scroll position are now captured before the in-memory transcript is cleared and preserved across the authoritative reload (#3326, @viraatdas, closes #3239). + ## [v0.51.213] — 2026-06-02 — Release GG (stage-p2b — keep gateway context visible in chat transcripts) ### Fixed diff --git a/static/sessions.js b/static/sessions.js index 354609fc..7f80ec12 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -597,6 +597,7 @@ async function loadSession(sid){ } const forceReload = !!opts.force; const currentSid = S.session ? S.session.session_id : null; + const sameSessionForceReload = forceReload && currentSid===sid; // Clicking the already-open session in the sidebar is a no-op. Reloading it // tears down active pane state and can reset the long-session scroll window // to the top even though the user did not navigate anywhere. Explicit @@ -631,6 +632,11 @@ async function loadSession(sid){ _pendingCarryForwardSnapshot = (currentSid === sid && forceReload) ? (S.messages || []).slice() : null; + // #3239: also capture a reload-width hint BEFORE clearing so the + // authoritative reload preserves the already-loaded transcript width + // instead of collapsing a long session back to the default tail window. + if (sameSessionForceReload) _captureSameSessionForceReloadHint(sid); + else _clearSameSessionForceReloadHint(); S.messages = []; S.toolCalls = []; _messagesTruncated = false; @@ -672,12 +678,14 @@ async function loadSession(sid){ if(typeof showToast==='function') showToast('Failed to load session',3000,'error'); } } + _clearSameSessionForceReloadHint(sid); if (_loadingSessionId === sid) _loadingSessionId = null; return; } // Guard: api() may have redirected (401) and returned undefined; in that case // the browser is already navigating away, so abort the rest of this flow. if (!data) { + _clearSameSessionForceReloadHint(sid); if (_loadingSessionId === sid) _loadingSessionId = null; return; } @@ -759,7 +767,7 @@ async function loadSession(sid){ // replaying persisted live tools so the compact Activity count survives // switching away from and back to an active chat (#1715). S.activeStreamId=activeStreamId; - syncTopbar();renderMessages(); + syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined); const restoredLiveTurn=typeof restoreLiveTurnHtmlForSession==='function'&&restoreLiveTurnHtmlForSession(sid); if(!restoredLiveTurn){ appendThinking(); @@ -843,7 +851,7 @@ async function loadSession(sid){ updateSendBtn(); setStatus(''); setComposerStatus(''); - syncTopbar();renderMessages();appendThinking();loadDir('.'); + syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);appendThinking();loadDir('.'); updateQueueBadge(sid); startApprovalPolling(sid); if(typeof startClarifyPolling==='function') startClarifyPolling(sid); @@ -857,7 +865,7 @@ async function loadSession(sid){ setStatus(''); setComposerStatus(''); updateQueueBadge(sid); - syncTopbar();renderMessages(); + syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined); if(typeof resumeManualCompressionForSession==='function') resumeManualCompressionForSession(sid); const _dirP=loadDir('.'); // Workspace refresh is guarded by session id inside loadDir(); do not @@ -1337,6 +1345,57 @@ let _messagesTruncated = false; // msg_limit (default 30): only fetch the last N messages for fast switching. // Older messages are loaded on-demand via _loadOlderMessages(). const _INITIAL_MSG_LIMIT = 30; +let _sameSessionForceReloadHint = null; + +function _currentLoadedRenderableMessageCount(){ + if(typeof _messageRenderableMessageCount==='function'){ + try{return Math.max(0,Number(_messageRenderableMessageCount())||0);} + catch(_){} + } + let count=0; + for(const m of (S.messages||[])){ + if(m&&m.role&&m.role!=='tool') count++; + } + return count; +} + +function _captureSameSessionForceReloadHint(sid){ + const loadedRenderableCount=_currentLoadedRenderableMessageCount(); + const loadedMessageCount=Array.isArray(S.messages)?S.messages.length:0; + const knownMessageCount=Number(S.session&&S.session.session_id===sid&&S.session.message_count)||loadedMessageCount; + if(!sid || (loadedRenderableCount<=0 && loadedMessageCount<=0)){ + _sameSessionForceReloadHint=null; + return; + } + _sameSessionForceReloadHint={ + session_id:sid, + loaded_renderable_count:loadedRenderableCount, + loaded_message_count:loadedMessageCount, + message_count:knownMessageCount, + truncated:!!_messagesTruncated, + }; +} + +function _clearSameSessionForceReloadHint(sid){ + if(!_sameSessionForceReloadHint) return; + if(!sid || _sameSessionForceReloadHint.session_id===sid) _sameSessionForceReloadHint=null; +} + +function _messageReloadLimitForSession(sid){ + const hint=_sameSessionForceReloadHint; + if(hint&&hint.session_id===sid){ + const loadedRenderableCount=Math.max(0,Number(hint.loaded_renderable_count)||0); + const loadedMessageCount=Math.max(0,Number(hint.loaded_message_count)||0); + if(loadedRenderableCount>0 || loadedMessageCount>0){ + if(!hint.truncated) return null; + const previousMessageCount=Math.max(0,Number(hint.message_count)||0); + const currentMessageCount=Math.max(0,Number(S.session&&S.session.session_id===sid&&S.session.message_count)||0); + const appendedMessageCount=Math.max(0,currentMessageCount-previousMessageCount); + return Math.max(_INITIAL_MSG_LIMIT,loadedRenderableCount,loadedMessageCount+appendedMessageCount); + } + } + return _INITIAL_MSG_LIMIT; +} function _syncToolCallsForLoadedMessages(messages, sessionToolCalls){ const msgs=Array.isArray(messages)?messages:[]; @@ -1356,10 +1415,18 @@ function _syncToolCallsForLoadedMessages(messages, sessionToolCalls){ async function _ensureMessagesLoaded(sid) { // Already have messages? (e.g. from INFLIGHT restore path, already set) if (S.messages && S.messages.length > 0 && S.messages[0] && S.messages[0].role) { + _clearSameSessionForceReloadHint(sid); return; } // Fetch session messages with a tail window for fast initial load. - const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=${_INITIAL_MSG_LIMIT}`); + const reloadLimit = _messageReloadLimitForSession(sid); // defaults to _INITIAL_MSG_LIMIT + const reloadLimitParam = reloadLimit ? `&msg_limit=${reloadLimit}` : ''; + let data; + try { + data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0${reloadLimitParam}`); + } finally { + _clearSameSessionForceReloadHint(sid); + } // Guard: api() may have redirected (401) and returned undefined. if (!data || !data.session) return; _messagesTruncated = !!data.session._messages_truncated; @@ -1384,6 +1451,7 @@ async function _ensureMessagesLoaded(sid) { msgs=window._carryForwardEphemeralTurnFields(_prev, msgs); _pendingCarryForwardSnapshot = null; } + if(typeof clearVisibleMessageRowCache==='function') clearVisibleMessageRowCache(); S.messages = msgs; if(S.session&&S.session.session_id===sid){ S.session.message_count=Number(data.session.message_count || msgs.length); diff --git a/static/ui.js b/static/ui.js index 1ae24245..0fc55263 100644 --- a/static/ui.js +++ b/static/ui.js @@ -268,12 +268,15 @@ let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT; // Cached visWithIdx array — invalidated when S.messages.length changes. let _visWithIdxCache=null; let _visWithIdxCacheLen=0; +function clearVisibleMessageRowCache(){ + _visWithIdxCache=null; + _visWithIdxCacheLen=0; +} function _resetMessageRenderWindow(sid){ _messageRenderWindowSid=sid||null; _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT; _clearRenderCache(); - _visWithIdxCache=null; - _visWithIdxCacheLen=0; + clearVisibleMessageRowCache(); } // ── renderMd / _renderUserFencedBlocks cache ────────────────────────────── @@ -6260,6 +6263,7 @@ let _sessionHtmlCacheSid=null; // session_id currently rendered in the DOM function clearMessageRenderCache(){ _sessionHtmlCache.clear(); _sessionHtmlCacheSid=null; + clearVisibleMessageRowCache(); } function _messageRenderCacheSignature(){ diff --git a/tests/test_issue1690_scroll_completion.py b/tests/test_issue1690_scroll_completion.py index 66233b65..afd601a1 100644 --- a/tests/test_issue1690_scroll_completion.py +++ b/tests/test_issue1690_scroll_completion.py @@ -72,5 +72,13 @@ def test_session_switch_and_idle_session_load_keep_default_bottom_pin_behavior() load_session = _function_body(SESSIONS_JS, "loadSession") idle_branch = load_session[load_session.index("}else{\n S.busy=false;") : load_session.index("// Sync context usage indicator")] - assert "syncTopbar();renderMessages();" in idle_branch - assert "preserveScroll:true" not in idle_branch + # #3326: the idle branch now renders with a CONDITIONAL preserveScroll — + # `renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined)`. + # For a normal cross-session idle load (currentSid!==sid) sameSessionForceReload + # is false, so the arg is undefined and the default bottom-pin behavior (#1690) + # is unchanged. preserveScroll only applies to a same-session external + # force-refresh (#3239), which is a different code path from #1690's scenario. + assert "syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);" in idle_branch + # The idle path must NOT unconditionally preserveScroll — it stays bottom-pinned + # for cross-session loads. Guard against a regression to an always-on preserve. + assert "renderMessages({preserveScroll:true})" not in idle_branch diff --git a/tests/test_issue2341_pending_user_reattach.py b/tests/test_issue2341_pending_user_reattach.py index b0a4faf3..5e171094 100644 --- a/tests/test_issue2341_pending_user_reattach.py +++ b/tests/test_issue2341_pending_user_reattach.py @@ -25,7 +25,9 @@ def test_load_session_inflight_reattach_merges_pending_user_message_before_rende block = _load_session_inflight_branch() merge_pos = block.find("_mergePendingSessionMessage") - render_pos = block.find("renderMessages();") + # #3326 added an optional {preserveScroll} arg to the INFLIGHT-branch render + # call; match the call form rather than the bare `renderMessages();`. + render_pos = block.find("renderMessages(") assert merge_pos != -1, ( "loadSession's INFLIGHT reattach branch must merge pending_user_message " diff --git a/tests/test_issue3162_ensure_messages_loaded.py b/tests/test_issue3162_ensure_messages_loaded.py index cb44a394..823d7cd0 100644 --- a/tests/test_issue3162_ensure_messages_loaded.py +++ b/tests/test_issue3162_ensure_messages_loaded.py @@ -15,7 +15,9 @@ SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") def _ensure_messages_loaded_body() -> str: start = SESSIONS_JS.index("async function _ensureMessagesLoaded") - return SESSIONS_JS[start: start + 2000] + # Window widened (#3326 added reload-width-hint handling inside this function, + # pushing the carry-forward reassignment further down). + return SESSIONS_JS[start: start + 2600] def test_ensure_messages_loaded_declares_msgs_with_let(): diff --git a/tests/test_parallel_session_switch.py b/tests/test_parallel_session_switch.py index bd01d4eb..c81ec230 100644 --- a/tests/test_parallel_session_switch.py +++ b/tests/test_parallel_session_switch.py @@ -80,9 +80,14 @@ class TestLoadSessionIdleOverlap: found = False for pos in positions: - block = SESSIONS_JS[pos : pos + 600] + # Window widened 600→850 (#3326 added a reload-width-hint comment + + # conditional preserveScroll arg in the idle branch, pushing the + # _dirP.catch line further from the S.busy=false anchor). + block = SESSIONS_JS[pos : pos + 850] has_loaddir = "loadDir('.')" in block - has_render = "renderMessages()" in block + # #3326 added an optional {preserveScroll} arg to the idle-path render + # call; match the call form rather than the bare `renderMessages()`. + has_render = "renderMessages(" in block if has_loaddir and has_render: found = True assert "highlightCode()" not in block, ( diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 428b3b46..3d6c565f 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -620,7 +620,9 @@ def test_loadSession_inflight_sets_busy_before_renderMessages(cleanup_test_sessi assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession" inflight_block = src[inflight_idx:inflight_idx+1600] busy_pos = inflight_block.find("S.busy=true;") - render_pos = inflight_block.find("renderMessages();") + # #3326 added an optional {preserveScroll} arg to the INFLIGHT-branch render + # call, so match the call form rather than the bare `renderMessages();`. + render_pos = inflight_block.find("renderMessages(") assert busy_pos >= 0, "loadSession INFLIGHT branch must set S.busy=true" assert render_pos >= 0, "loadSession INFLIGHT branch must call renderMessages()" assert busy_pos < render_pos, \ diff --git a/tests/test_webui_external_refresh_frontend.py b/tests/test_webui_external_refresh_frontend.py index 5bbc2bd7..263402c0 100644 --- a/tests/test_webui_external_refresh_frontend.py +++ b/tests/test_webui_external_refresh_frontend.py @@ -77,3 +77,46 @@ def test_same_session_force_reload_preserves_non_empty_composer_input(): assert "const preserveActiveInput = !!(opts && opts.preserveActiveInput);" in SESSIONS_JS assert "if (preserveActiveInput && current && current !== text) return;" in SESSIONS_JS assert "_restoreComposerDraft(_draft, sid, {preserveActiveInput:currentSid===sid&&forceReload});" in SESSIONS_JS + + +def test_same_session_force_reload_keeps_loaded_transcript_width_hint(): + """Same-session force refresh must not collapse a long transcript to the tail.""" + assert "let _sameSessionForceReloadHint = null;" in SESSIONS_JS + assert "function _captureSameSessionForceReloadHint(sid)" in SESSIONS_JS + assert "loaded_renderable_count:loadedRenderableCount" in SESSIONS_JS + assert "message_count:knownMessageCount" in SESSIONS_JS + assert "truncated:!!_messagesTruncated" in SESSIONS_JS + assert "function _messageReloadLimitForSession(sid)" in SESSIONS_JS + assert "if(!hint.truncated) return null;" in SESSIONS_JS + assert "const appendedMessageCount=Math.max(0,currentMessageCount-previousMessageCount);" in SESSIONS_JS + assert "return Math.max(_INITIAL_MSG_LIMIT,loadedRenderableCount,loadedMessageCount+appendedMessageCount);" in SESSIONS_JS + assert "const reloadLimit = _messageReloadLimitForSession(sid);" in SESSIONS_JS + assert "const reloadLimitParam = reloadLimit ? `&msg_limit=${reloadLimit}` : '';" in SESSIONS_JS + assert "finally {\n _clearSameSessionForceReloadHint(sid);\n }" in SESSIONS_JS + + load_start = SESSIONS_JS.index("async function loadSession(sid)") + load_end = SESSIONS_JS.index("// ── Handoff hint logic", load_start) + load_body = SESSIONS_JS[load_start:load_end] + capture_pos = load_body.index("if (sameSessionForceReload) _captureSameSessionForceReloadHint(sid);") + clear_pos = load_body.index("else _clearSameSessionForceReloadHint();", capture_pos) + reset_pos = load_body.index("S.messages = [];", clear_pos) + assert capture_pos < clear_pos < reset_pos + assert "const sameSessionForceReload = forceReload && currentSid===sid;" in load_body + assert "renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined)" in load_body + + +def test_same_width_force_reload_invalidates_visible_message_cache(): + """Replacing a transcript with the same length must still refresh cached rows.""" + clear_start = UI_JS.index("function clearVisibleMessageRowCache()") + clear_end = UI_JS.index("function _resetMessageRenderWindow", clear_start) + clear_body = UI_JS[clear_start:clear_end] + assert "_visWithIdxCache=null;" in clear_body + assert "_visWithIdxCacheLen=0;" in clear_body + assert "clearVisibleMessageRowCache();" in UI_JS[UI_JS.index("function clearMessageRenderCache()") :] + + ensure_start = SESSIONS_JS.index("async function _ensureMessagesLoaded(sid)") + ensure_end = SESSIONS_JS.index("function _messageComparableText", ensure_start) + ensure_body = SESSIONS_JS[ensure_start:ensure_end] + invalidate_pos = ensure_body.index("if(typeof clearVisibleMessageRowCache==='function') clearVisibleMessageRowCache();") + replace_pos = ensure_body.index("S.messages = msgs;") + assert invalidate_pos < replace_pos