Defer non-critical session switch refreshes

This commit is contained in:
santastabber committed 2026-06-29 00:53:41 +10:00
1 parent e24e6c54a6
commit 93d8907a3e
7 files changed
+192 -50

No files matched your search

+108 -20
View File
@@ -32,6 +32,7 @@ let _pendingCarryForwardSnapshot = null;
let _draftSaveTimer = null;
const _DRAFT_SAVE_DELAY_MS = 400;
const NEW_CHAT_DRAFT_SESSION_KEY = 'hermes-new-chat-draft-session';
const _composerDraftKnownPayloadSessions = new Set();
function _profileMatchesActiveProfile(profile, activeProfile){
const eventName = (typeof profile === 'string' && profile.trim()) ? profile.trim() : 'default';
@@ -98,21 +99,64 @@ async function _restoreRememberedNewChatDraftSession() {
function _saveComposerDraft(sid, text, files) {
if (!sid) return;
clearTimeout(_draftSaveTimer);
const normalizedText = String(text || '');
const normalizedFiles = Array.isArray(files) ? files.filter(Boolean) : [];
if (_composerDraftHasPayload(normalizedText, normalizedFiles)) {
_composerDraftKnownPayloadSessions.add(sid);
}
_draftSaveTimer = setTimeout(() => {
api('/api/session/draft', {
method: 'POST',
body: JSON.stringify({ session_id: sid, text: text || '', files: files || [] }),
body: JSON.stringify({ session_id: sid, text: normalizedText, files: normalizedFiles }),
}).then(() => {
_rememberComposerDraftPayloadState(sid, normalizedText, normalizedFiles);
}).catch(() => {});
}, _DRAFT_SAVE_DELAY_MS);
}
function _composerDraftHasPayload(text, files) {
return !!(String(text || '') || (Array.isArray(files) && files.filter(Boolean).length));
}
function _sessionComposerDraftHasPayload(session) {
const draft = session && session.composer_draft;
return !!(draft && _composerDraftHasPayload(draft.text, draft.files));
}
function _rememberComposerDraftPayloadState(sid, text, files) {
if (!sid) return;
const normalizedText = String(text || '');
const normalizedFiles = Array.isArray(files) ? files.filter(Boolean) : [];
if (_composerDraftHasPayload(normalizedText, normalizedFiles)) {
_composerDraftKnownPayloadSessions.add(sid);
} else {
_composerDraftKnownPayloadSessions.delete(sid);
}
if (S.session && S.session.session_id === sid) {
S.session.composer_draft = { text: normalizedText, files: normalizedFiles };
}
}
// Immediate save used before session switches.
function _saveComposerDraftNow(sid, text, files) {
if (!sid) return;
if (!sid) return Promise.resolve();
clearTimeout(_draftSaveTimer);
const normalizedText = String(text || '');
const normalizedFiles = Array.isArray(files) ? files.filter(Boolean) : [];
// Most chat switches leave an empty composer. Avoid putting the switch path
// behind a network POST unless there is new local draft content or an existing
// server draft that must be cleared.
if (!_composerDraftHasPayload(normalizedText, normalizedFiles)
&& S.session && S.session.session_id === sid
&& !_sessionComposerDraftHasPayload(S.session)
&& !_composerDraftKnownPayloadSessions.has(sid)) {
return Promise.resolve();
}
return api('/api/session/draft', {
method: 'POST',
body: JSON.stringify({ session_id: sid, text: text || '', files: files || [] }),
body: JSON.stringify({ session_id: sid, text: normalizedText, files: normalizedFiles }),
}).then(() => {
_rememberComposerDraftPayloadState(sid, normalizedText, normalizedFiles);
}).catch(() => {});
}
@@ -161,9 +205,11 @@ function _clearComposerDraft(sid) {
if (!sid) return;
clearTimeout(_draftSaveTimer);
_clearRememberedNewChatDraftSession(sid);
api('/api/session/draft', {
return api('/api/session/draft', {
method: 'POST',
body: JSON.stringify({ session_id: sid, text: '' }),
}).then(() => {
_rememberComposerDraftPayloadState(sid, '', []);
}).catch(() => {});
}
@@ -1021,11 +1067,17 @@ async function newSession(flash, options={}){
}
updateQueueBadge(S.session.session_id);
syncTopbar();renderMessages();
const dirLoad=loadDir('.');
// loadDir('.') is fire-and-forget while the workspace panel is closed:
// waiting would block new-chat/profile-switch flow for users who never open
// the file tree. When visible, wait so the file list lands with the session.
if(options&&options.awaitWorkspaceLoad) await dirLoad;
// Keep new-chat first paint instant. The workspace tree / git badge can
// refresh right after paint unless this caller explicitly needs it loaded
// before continuing (profile/default-workspace binding path).
if(options&&options.awaitWorkspaceLoad){
await loadDir('.');
}else if(typeof _deferWorkspaceRefreshForSession==='function'){
_deferWorkspaceRefreshForSession(S.session.session_id);
}else{
const _dirP=loadDir('.');
if(_dirP&&typeof _dirP.catch==='function') _dirP.catch(()=>{});
}
// don't call renderSessionList here - callers do it when needed
})();
try{
@@ -1308,14 +1360,15 @@ async function loadSession(sid){
S._pendingSessionToolsets=null;
if(typeof populateModelDropdown==='function'){
const modelRefreshSid=sid;
const isActiveModelRefreshSession=()=>!!(S.session&&S.session.session_id===modelRefreshSid);
if(!S._bootReady&&typeof window!=='undefined'&&typeof window._startBootModelDropdown==='function'){
Promise.resolve().then(()=>{
if(_loadingSessionId!==modelRefreshSid) return;
if(!isActiveModelRefreshSession()) return undefined;
return window._startBootModelDropdown();
}).catch(()=>{});
}else{
const modelRefreshPromise=Promise.resolve().then(()=>{
if(_loadingSessionId!==modelRefreshSid) return;
const modelRefreshPromise=_deferSessionSideEffect(modelRefreshSid,()=>{
if(!isActiveModelRefreshSession()) return undefined;
return populateModelDropdown({freshness:'session_visit'});
}).catch(()=>{});
if(typeof window!=='undefined') window._modelDropdownReady=modelRefreshPromise;
@@ -1521,7 +1574,7 @@ async function loadSession(sid){
const liveTurn=document.getElementById('liveAssistantTurn');
if(!liveTurn||!liveTurn.querySelector('.tool-call-group[data-tool-worklog-group="1"]')) ensureLiveWorklogShell();
}
loadDir('.');
_deferWorkspaceRefreshForSession(sid);
setBusy(true);setComposerStatus('');
startApprovalPolling(sid);
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
@@ -1600,7 +1653,7 @@ async function loadSession(sid){
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
else appendThinking();
}
loadDir('.');
_deferWorkspaceRefreshForSession(sid);
updateQueueBadge(sid);
startApprovalPolling(sid);
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
@@ -1614,11 +1667,10 @@ async function loadSession(sid){
updateQueueBadge(sid);
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
// block session-load completion, draft restore, or model resolution on
// file-tree IO for users focused on the chat.
if(_dirP&&typeof _dirP.catch==='function') _dirP.catch(()=>{});
// Workspace refresh is guarded by session id inside loadDir(); keep it
// after the transcript's first paint so chat switching is not competing
// with file-tree / git badge IO.
_deferWorkspaceRefreshForSession(sid);
}
}
@@ -2116,9 +2168,45 @@ async function _generateHandoffSummary(sid, rounds) {
// retry.
}
function _afterSessionFirstPaint(fn, delayMs=0){
return new Promise((resolve)=>{
const invoke=()=>{
try{ resolve(typeof fn==='function' ? fn() : undefined); }
catch(_){ resolve(undefined); }
};
const run=()=>{
if(typeof requestIdleCallback==='function'){
requestIdleCallback(invoke,{timeout:1500});
}else{
setTimeout(invoke, delayMs);
}
};
if(typeof requestAnimationFrame==='function'){
requestAnimationFrame(()=>requestAnimationFrame(run));
}else{
setTimeout(run, delayMs);
}
});
}
function _deferSessionSideEffect(sid, fn, delayMs=0){
if(!sid||typeof fn!=='function') return Promise.resolve();
return _afterSessionFirstPaint(()=>{
if(!S.session||S.session.session_id!==sid) return undefined;
return fn();
},delayMs);
}
function _deferWorkspaceRefreshForSession(sid, opts={}){
_deferSessionSideEffect(sid,()=>{
const load=loadDir('.', opts);
if(load&&typeof load.catch==='function') load.catch(()=>{});
},150);
}
function _resolveSessionModelForDisplaySoon(sid){
if(!sid) return;
setTimeout(async()=>{
_deferSessionSideEffect(sid,async()=>{
try{
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=1`);
const model=data&&data.session&&data.session.model;
+10 -4
View File
@@ -290,13 +290,15 @@ def test_load_session_attaches_sse_before_auxiliary_work():
# appendThinking, and renderMessages now takes a preserveScroll arg — so the
# old contiguous "syncTopbar();renderMessages();appendThinking();loadDir('.');"
# literal no longer exists. Assert each auxiliary call individually; all must
# still run AFTER attachLiveStream (the invariant this test protects).
# still run AFTER attachLiveStream (the invariant this test protects). The
# workspace refresh is now scheduled through a first-paint deferral helper
# rather than calling loadDir('.') inline.
for marker in (
"updateSendBtn();",
"syncTopbar();",
"renderMessages(",
"appendThinking();",
"loadDir('.');",
"_deferWorkspaceRefreshForSession(sid);",
"updateQueueBadge(sid);",
"startApprovalPolling(sid)",
):
@@ -876,7 +878,9 @@ def test_load_session_restores_worklog_shell_before_reattach_replay():
body = _function_body(SESSIONS_JS, "loadSession")
fallback_pos = body.find("if(!restoredLiveTurn){")
assert fallback_pos != -1, "loadSession must have a live-turn fallback branch"
fallback_block = body[fallback_pos:body.find("loadDir('.')", fallback_pos)]
refresh_pos = body.find("_deferWorkspaceRefreshForSession(sid);", fallback_pos)
assert refresh_pos != -1, "fallback path should still schedule workspace refresh after first paint"
fallback_block = body[fallback_pos:refresh_pos]
clear_pos = fallback_block.find("clearLiveToolCards();")
shell_pos = fallback_block.find("ensureLiveWorklogShell()")
legacy_pos = fallback_block.find("else appendThinking();")
@@ -939,7 +943,9 @@ def test_restore_succeeded_reconnect_skips_unkeyed_restored_tool_duplicates():
assert "hasRestoredLiveToolRows&&!liveToolReplayId(tc)" in helper_block
restore_block = body[restore_replay_pos:fallback_pos]
assert "replayPersistedLiveToolCards({skipUnkeyedRestoredDuplicates:true});" in restore_block
assert "replayPersistedLiveToolCards();" in body[fallback_pos:body.find("loadDir('.')", fallback_pos)]
refresh_pos = body.find("_deferWorkspaceRefreshForSession(sid);", fallback_pos)
assert refresh_pos != -1, "fallback path should still schedule workspace refresh after first paint"
assert "replayPersistedLiveToolCards();" in body[fallback_pos:refresh_pos]
def test_merge_inflight_tail_preserves_all_segmented_live_progress():
@@ -670,22 +670,39 @@ def test_populate_model_dropdown_accepts_session_visit_freshness_and_guards_stal
assert live_tail.count("requestSeq!==null&&requestSeq!==_modelDropdownRequestSeq") >= 4
def test_load_session_schedules_async_session_visit_model_refresh_after_metadata_load():
def test_load_session_schedules_session_visit_model_refresh_before_message_load():
body = _extract_function_body(_read_static("sessions.js"), "async function loadSession(")
assign_idx = body.index("S.session=data.session")
boot_guard_idx = body.index("if(!S._bootReady&&typeof window!=='undefined'&&typeof window._startBootModelDropdown==='function'){")
boot_promise_idx = body.index("Promise.resolve().then(()=>{", boot_guard_idx)
boot_refresh_idx = body.index("return window._startBootModelDropdown();")
else_idx = body.index("}else{", boot_refresh_idx)
promise_idx = body.index("const modelRefreshPromise=Promise.resolve().then(", else_idx)
ready_idx = body.index("window._modelDropdownReady=modelRefreshPromise")
refresh_idx = body.index("populateModelDropdown({freshness:'session_visit'})")
stale_guard_idx = body.index("_loadingSessionId!==modelRefreshSid")
message_load_idx = body.index("await _ensureMessagesLoaded(sid)", assign_idx)
failure_return_idx = body.index("return;", message_load_idx)
model_block_idx = body.index("if(typeof populateModelDropdown==='function')", assign_idx)
guard_helper_idx = body.index("const isActiveModelRefreshSession", model_block_idx)
promise_idx = body.index("const modelRefreshPromise=_deferSessionSideEffect", model_block_idx)
ready_idx = body.index("window._modelDropdownReady=modelRefreshPromise", promise_idx)
refresh_idx = body.index("populateModelDropdown({freshness:'session_visit'})", promise_idx)
assert assign_idx < boot_guard_idx < boot_promise_idx < boot_refresh_idx < else_idx < promise_idx < refresh_idx < ready_idx
assert boot_promise_idx < stale_guard_idx < boot_refresh_idx
assert promise_idx < body.index("_loadingSessionId!==modelRefreshSid", promise_idx) < refresh_idx
assert assign_idx < model_block_idx < message_load_idx < failure_return_idx
assert model_block_idx < promise_idx < refresh_idx < ready_idx
assert guard_helper_idx < promise_idx
assert "_loadingSessionId!==modelRefreshSid" not in body[model_block_idx:ready_idx], (
"deferred model refresh must guard on the active session, not _loadingSessionId, "
"because loadSession clears _loadingSessionId when the first paint is complete"
)
def test_session_visit_model_refresh_is_deferred_until_after_first_paint():
sessions = _read_static("sessions.js")
defer_helper = _extract_function_body(sessions, "function _afterSessionFirstPaint(")
side_effect_helper = _extract_function_body(sessions, "function _deferSessionSideEffect(")
load_body = _extract_function_body(sessions, "async function loadSession(")
assert "requestAnimationFrame(()=>requestAnimationFrame(run))" in defer_helper
assert "requestIdleCallback(invoke,{timeout:1500})" in defer_helper
assert "return _afterSessionFirstPaint(()=>" in side_effect_helper
assert "const modelRefreshPromise=_deferSessionSideEffect" in load_body
assert "isActiveModelRefreshSession()" in load_body
assert "return populateModelDropdown({freshness:'session_visit'});" in load_body
def test_boot_model_dropdown_clears_cached_ready_on_401():
@@ -76,6 +76,26 @@ def test_session_switch_awaits_immediate_draft_flush_before_loading_target():
)
def test_immediate_empty_draft_flush_clears_locally_known_server_draft():
assert "const _composerDraftKnownPayloadSessions = new Set();" in SESSIONS_JS
save_start = SESSIONS_JS.find("function _saveComposerDraft(")
save_end = SESSIONS_JS.find("function _composerDraftHasPayload", save_start)
now_start = SESSIONS_JS.find("function _saveComposerDraftNow(")
now_end = SESSIONS_JS.find("// Restore composer draft", now_start)
assert save_start != -1 and save_end != -1
assert now_start != -1 and now_end != -1
save_body = SESSIONS_JS[save_start:save_end]
now_body = SESSIONS_JS[now_start:now_end]
assert "_composerDraftKnownPayloadSessions.add(sid);" in save_body, (
"debounced non-empty draft saves must mark the session as having server-side draft payload"
)
assert "_rememberComposerDraftPayloadState(sid, normalizedText, normalizedFiles);" in save_body
assert "!_composerDraftKnownPayloadSessions.has(sid)" in now_body, (
"empty immediate switch-away saves may only be skipped when no local/server draft payload is known"
)
assert "_rememberComposerDraftPayloadState(sid, normalizedText, normalizedFiles);" in now_body
def test_pre_switch_draft_flush_rechecks_stale_loading_guard():
"""The awaited draft-save in loadSession yields the event loop. On a rapid
session switch (B then quickly C) the stale B continuation must bail out
@@ -117,6 +137,12 @@ def test_clear_composer_draft_forgets_same_new_chat_candidate():
assert "_clearRememberedNewChatDraftSession(sid);" in body, (
"sending a draft must stop New Chat from restoring that now-cleared candidate"
)
assert "return api('/api/session/draft'" in body, (
"clear path should return its POST promise for callers/tests that need to await it"
)
assert "_rememberComposerDraftPayloadState(sid, '', []);" in body, (
"clear path must also clear local draft-payload tracking so send-then-switch can skip redundant empty flushes"
)
def test_boot_restore_preserves_zero_message_session_with_composer_draft():
+11 -11
View File
@@ -80,30 +80,30 @@ class TestLoadSessionIdleOverlap:
found = False
for pos in positions:
# 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
# Window widened 600→950 (#3326 added a reload-width-hint comment +
# conditional preserveScroll arg in the idle branch; the workspace
# refresh now routes through a first-paint deferral helper instead of
# a direct loadDir('.') call).
block = SESSIONS_JS[pos : pos + 950]
has_deferred_workspace = "_deferWorkspaceRefreshForSession(sid);" 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:
if has_deferred_workspace and has_render:
found = True
assert "highlightCode()" not in block, (
"The idle path should rely on renderMessages()'s consolidated "
"post-render pass instead of running a second highlight pass."
)
assert "_dirP" in block and "await _dirP" not in block, (
"loadDir() should refresh the workspace without blocking "
"session-load completion."
assert block.index("renderMessages(") < block.index("_deferWorkspaceRefreshForSession(sid);"), (
"workspace refresh should be deferred until after the session "
"transcript render starts."
)
assert "_dirP.catch" in block
break
assert found, (
"Could not find the idle path in loadSession that calls both "
"renderMessages and loadDir."
"renderMessages and _deferWorkspaceRefreshForSession."
)
+6 -2
View File
@@ -133,8 +133,12 @@ class TestParallelizedFetches:
fn = self._get_switch_fn()
assert "awaitWorkspaceLoad: workspaceVisible" in fn
sessions_js = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
assert "if(options&&options.awaitWorkspaceLoad) await dirLoad" in sessions_js
assert "loadDir('.') is fire-and-forget while the workspace panel is closed" in sessions_js
assert "if(options&&options.awaitWorkspaceLoad){" in sessions_js
assert "await loadDir('.')" in sessions_js
assert "typeof _deferWorkspaceRefreshForSession==='function'" in sessions_js
assert "_deferWorkspaceRefreshForSession(S.session.session_id)" in sessions_js
assert "const _dirP=loadDir('.')" in sessions_js
assert "Keep new-chat first paint instant" in sessions_js
class TestSpinnerCss:
+2 -1
View File
@@ -319,7 +319,8 @@ def test_server_runtime_journal_snapshot_restores_structured_inflight_state():
helper_pos = SESSIONS_SRC.index("function _serverLiveSnapshotToolId")
helper_block = SESSIONS_SRC[helper_pos : helper_pos + 3600]
load_pos = SESSIONS_SRC.index("async function loadSession")
load_block = SESSIONS_SRC[load_pos : load_pos + 20000]
load_end = SESSIONS_SRC.index("// ── Handoff hint logic", load_pos)
load_block = SESSIONS_SRC[load_pos:load_end]
assert "runtime_journal_snapshot" in load_block
assert "_serverLiveSnapshotInflight(S.session.runtime_journal_snapshot" in load_block