diff --git a/static/boot.js b/static/boot.js index 8767dd67..a85df0c5 100644 --- a/static/boot.js +++ b/static/boot.js @@ -42,6 +42,17 @@ async function cancelSessionStream(session){ if(typeof renderSessionList==='function') renderSessionList(); } +async function _savedSessionShouldStaySidebarOnly(sid){ + if(!sid) return false; + try{ + const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`); + const session = data&&data.session; + return !!(session&&(session.active_stream_id||session.pending_user_message)); + }catch(e){ + return false; + } +} + // ── Mobile navigation ────────────────────────────────────────────────────── let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview' @@ -1346,9 +1357,18 @@ function applyBotName(){ // Initialize reasoning chip on boot (fixes #1103 — chip hidden until session load) if(typeof fetchReasoningChip==='function') fetchReasoningChip(); const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null; - const saved=urlSession||localStorage.getItem('hermes-webui-session'); + const savedLocal=localStorage.getItem('hermes-webui-session'); + const saved=urlSession||savedLocal; if(saved){ try{ + if(!urlSession&&savedLocal&&await _savedSessionShouldStaySidebarOnly(savedLocal)){ + S.session=null; S.messages=[]; S.activeStreamId=null; S.busy=false; + S._bootReady=true; + syncTopbar();syncWorkspacePanelState(); + $('emptyState').style.display=''; + await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE(); + return; + } await loadSession(saved); // If the restored session has no messages it is an ephemeral scratch pad — // treat the page as a fresh start rather than resuming a blank conversation. diff --git a/tests/test_1694_root_saved_running_policy.py b/tests/test_1694_root_saved_running_policy.py new file mode 100644 index 00000000..ca4a13e7 --- /dev/null +++ b/tests/test_1694_root_saved_running_policy.py @@ -0,0 +1,91 @@ +"""Regression tests for #1694 root boot policy around saved running sessions. + +The active pane is only a projection. A root `/` tab restored from +``localStorage['hermes-webui-session']`` should not automatically project into a +saved session that is still running, because that makes the new tab inherit the +running pane's busy/stream state even though the user did not explicitly open +that session. + +Explicit `/session/` reload remains different: it should still restore and +reattach to the requested running session. +""" + +from pathlib import Path + + +REPO = Path(__file__).parent.parent +BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + + +def _boot_saved_session_block() -> str: + marker = "const urlSession=" + start = BOOT_JS.find(marker) + assert start > 0, "boot saved-session restore block not found" + end_marker = "// no saved session" + end = BOOT_JS.find(end_marker, start) + assert end > start, "no-saved-session marker not found after restore block" + return BOOT_JS[start:end] + + +def test_root_boot_distinguishes_url_session_from_localstorage_saved_session(): + """Root restore and explicit URL restore must be separate decisions.""" + block = _boot_saved_session_block() + assert "const savedLocal=" in block, ( + "boot must keep the localStorage session separate from urlSession so " + "root `/` policy can differ from explicit `/session/` reload" + ) + compact = block.replace(" ", "") + assert "constsaved=urlSession||savedLocal" in compact, ( + "boot should still prefer explicit URL sessions over saved localStorage sessions" + ) + + +def test_root_saved_running_session_is_checked_before_load_session_projection(): + """A saved running localStorage session should be detected before loadSession().""" + block = _boot_saved_session_block() + guard = "!urlSession&&savedLocal" + guard_pos = block.replace(" ", "").find(guard) + load_pos = block.find("await loadSession(saved)") + assert guard_pos >= 0, ( + "root `/` boot must have a !urlSession && savedLocal guard for saved " + "running sessions before projecting them into the active pane" + ) + assert load_pos >= 0, "loadSession(saved) call not found" + assert guard_pos < load_pos, ( + "saved running-session root guard must run before loadSession(saved), " + "otherwise loadSession already projects the session into the active pane" + ) + assert "_savedSessionShouldStaySidebarOnly" in block, ( + "boot should delegate the saved-running metadata check to a named helper" + ) + + +def test_saved_running_session_helper_uses_metadata_only_and_runtime_markers(): + """The helper should inspect metadata without loading messages or attaching SSE.""" + helper_idx = BOOT_JS.find("async function _savedSessionShouldStaySidebarOnly") + assert helper_idx > 0, "saved-running root policy helper not found" + helper = BOOT_JS[helper_idx:helper_idx + 1200] + assert "/api/session?session_id=" in helper, ( + "helper should inspect session metadata via /api/session before deciding" + ) + assert "messages=0" in helper, "helper must avoid loading full messages" + assert "resolve_model=0" in helper, "helper must avoid unnecessary model resolution" + assert "active_stream_id" in helper, "helper must treat active_stream_id as running" + assert "pending_user_message" in helper, "helper must treat pending_user_message as running" + assert "loadSession(" not in helper, ( + "helper must not call loadSession(), because that would already project " + "the saved session into the active pane" + ) + + +def test_root_saved_running_sidebar_only_path_renders_empty_state_and_sidebar(): + """Skipping projection should still leave the app usable and sidebar visible.""" + block = _boot_saved_session_block() + helper_pos = block.find("_savedSessionShouldStaySidebarOnly") + render_pos = block.find("await renderSessionList()", helper_pos) + empty_pos = block.find("$('emptyState').style.display=''", helper_pos) + return_pos = block.find("return;", helper_pos) + assert helper_pos >= 0, "saved-running helper call not found" + assert empty_pos > helper_pos, "sidebar-only path must show the empty state" + assert render_pos > helper_pos, "sidebar-only path must render the session list" + assert return_pos > render_pos, "sidebar-only path should return before loadSession(saved)" diff --git a/tests/test_session_cross_tab_sync.py b/tests/test_session_cross_tab_sync.py index 38cf81b2..419db30a 100644 --- a/tests/test_session_cross_tab_sync.py +++ b/tests/test_session_cross_tab_sync.py @@ -41,7 +41,9 @@ def test_session_switch_updates_url_path_for_tab_local_anchor(): def test_boot_prefers_url_session_over_local_storage_session(): assert "const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;" in BOOT_JS - assert "const saved=urlSession||localStorage.getItem('hermes-webui-session');" in BOOT_JS + assert "const savedLocal=localStorage.getItem('hermes-webui-session');" in BOOT_JS + assert "const saved=urlSession||savedLocal;" in BOOT_JS + assert "if(!urlSession&&savedLocal&&await _savedSessionShouldStaySidebarOnly(savedLocal))" in BOOT_JS def test_api_helper_resolves_against_document_base_not_session_path():