fix: preserve loaded transcript width on same-session external refresh (#3326, @viraatdas)

Capture a same-session force-reload hint (loaded renderable/message counts,
known count, truncation flag) BEFORE clearing the in-memory transcript, so the
authoritative reload requests a width that preserves what was loaded instead of
collapsing a long session to the default 30-message tail window mid-read.
Same-session force reloads render with preserveScroll. Resolves conflict with
the shipped #3306 carry-forward snapshot by keeping both (complementary).
Closes #3239.

Co-authored-by: viraatdas <viraatdas@users.noreply.github.com>
This commit is contained in:
nesquena-hermes 2026-06-02 18:53:12 +00:00
parent e156cbf356
commit 7c4c8120a3
3 changed files with 121 additions and 6 deletions

View file

@ -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);

View file

@ -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(){

View file

@ -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