diff --git a/static/sessions.js b/static/sessions.js index d69d1aee..ee092023 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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; diff --git a/tests/test_inflight_stream_reuse.py b/tests/test_inflight_stream_reuse.py index 36b7f521..4e25b403 100644 --- a/tests/test_inflight_stream_reuse.py +++ b/tests/test_inflight_stream_reuse.py @@ -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(): diff --git a/tests/test_issue4756_session_visit_model_refresh.py b/tests/test_issue4756_session_visit_model_refresh.py index 88c131b5..2ff73270 100644 --- a/tests/test_issue4756_session_visit_model_refresh.py +++ b/tests/test_issue4756_session_visit_model_refresh.py @@ -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(): diff --git a/tests/test_issue_new_chat_draft_restore.py b/tests/test_issue_new_chat_draft_restore.py index c1f0f798..50d02465 100644 --- a/tests/test_issue_new_chat_draft_restore.py +++ b/tests/test_issue_new_chat_draft_restore.py @@ -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(): diff --git a/tests/test_parallel_session_switch.py b/tests/test_parallel_session_switch.py index 66840709..14259173 100644 --- a/tests/test_parallel_session_switch.py +++ b/tests/test_parallel_session_switch.py @@ -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." ) diff --git a/tests/test_profile_switch_ux.py b/tests/test_profile_switch_ux.py index 723ca96f..e6ce1f9f 100644 --- a/tests/test_profile_switch_ux.py +++ b/tests/test_profile_switch_ux.py @@ -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: diff --git a/tests/test_run_journal_frontend_static.py b/tests/test_run_journal_frontend_static.py index 68b8b548..be82535e 100644 --- a/tests/test_run_journal_frontend_static.py +++ b/tests/test_run_journal_frontend_static.py @@ -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