Merge pull request #3426 from nesquena/release/stage-p2c
Release GH — v0.51.214 (preserve loaded transcript width on same-session external refresh)
This commit is contained in:
9 files changed
+152
-13
No files matched your search
@@ -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
|
||||
|
||||
+72
-4
@@ -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);
|
||||
|
||||
+6
-2
@@ -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(){
|
||||
|
||||
@@ -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
|
||||
@@ -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 "
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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, \
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user