From 5e5b44e733cf0095d4e570e3acbf9d7c9860a854 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 3 Jul 2026 20:49:51 +0000 Subject: [PATCH] fix(webui): replace cross-profile empty sessions on profile switch + open all-profile rows under owning profile Clean rebase of ruizanthony's #5460 (rebase-first). Co-authored-by: ruizanthony --- static/panels.js | 63 ++++++++++++-- static/sessions.js | 87 ++++++++++++++----- .../test_firefox_sidebar_scroll_stability.py | 4 +- ...est_issue1611_session_profile_filtering.py | 45 +++++++++- .../test_issue1700_parallel_profile_switch.py | 7 +- ..._issue3603_external_session_import_gate.py | 43 +++++---- ...ssue4662_profile_switch_skeleton_static.py | 22 +++-- tests/test_profile_switch_ux.py | 68 +++++++++++++++ tests/test_session_lineage_collapse.py | 4 +- tests/test_session_touch_actions.py | 6 +- 10 files changed, 288 insertions(+), 61 deletions(-) diff --git a/static/panels.js b/static/panels.js index 1b87de1c..3b2d936e 100644 --- a/static/panels.js +++ b/static/panels.js @@ -6383,6 +6383,21 @@ window.addEventListener('resize',()=>{ if(dd&&dd.classList.contains('open')) _positionProfileDropdown(); }); +function _openProfileSwitchSessionBrowser(){ + try{ + const isDesktop = (typeof _isDesktopWidth === 'function') ? _isDesktopWidth() : true; + if(isDesktop){ + if(typeof expandSidebar === 'function') expandSidebar(); + return; + } + const sidebar=document.querySelector('.sidebar'); + if(!sidebar)return; + try{if(typeof _syncMobileSidebarPanelFromMainView==='function')_syncMobileSidebarPanelFromMainView();}catch(_){} + sidebar.classList.remove('mobile-session-page'); + sidebar.classList.add('mobile-panel-drawer','mobile-open'); + }catch(_){} +} + async function switchToProfile(name) { // ── #4671 profile-switch loading-skeleton — FOUR-GUARD CONTRACT ─────────────── // The skeleton must never be clobbered by the OLD profile's content and must never @@ -6422,6 +6437,7 @@ async function switchToProfile(name) { const _titlebarLabel = $('titlebarProfileLabel'); const _prevProfileName = S.activeProfile || 'default'; const _switchGen = ++_profileSwitchGeneration; + const _openingExistingSidebarSession = !!(typeof _profileSwitchOpeningExistingSession !== 'undefined' && _profileSwitchOpeningExistingSession); if (_chip) { _chip.classList.add('switching'); _chip.disabled = true; } if (_titlebarBtn) { _titlebarBtn.classList.add('switching'); _titlebarBtn.disabled = true; } // Optimistic name update — shows the target name right away @@ -6442,14 +6458,22 @@ async function switchToProfile(name) { // context change where dismissing those transient affordances is correct. if (typeof _renamingSid !== 'undefined' && _renamingSid) _renamingSid = null; if (typeof closeSessionActionMenu === 'function') closeSessionActionMenu(); - // Determine whether the current session has any messages. - // A session with messages is "in progress" and belongs to the current profile — - // we must not retag it. We'll start a fresh session for the new profile instead. - const sessionInProgress = S.session && ( + // Determine whether the current session must be replaced instead of being + // retagged in place. A session with messages/active runtime belongs to the + // current profile. After the profile-switch POST returns, we also treat an + // otherwise-empty session whose recorded profile does not match the target + // profile as replace-only: uploads send S.session.session_id and the backend + // correctly rejects old-profile sessions under the new profile cookie. + let sessionInProgress = !!(S.session && ( (S.messages && S.messages.length > 0) || S.session.active_stream_id || S.session.pending_user_message - ); + )); + if (_openingExistingSidebarSession && S.session) { + // A cross-profile sidebar click is about to load a concrete existing session. + // Do not create or retag a blank intermediary session in the destination profile. + sessionInProgress = true; + } const _workspaceVisibleAtStart = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed'; // #4671 CORE: the skeleton/embargo/generation setup is INSIDE the try so the @@ -6482,6 +6506,19 @@ async function switchToProfile(name) { if (_switchGen !== _profileSwitchGeneration) return; S.activeProfile = data.active || name; S.activeProfileIsDefault = !!data.is_default; + const targetActiveProfile = S.activeProfile || 'default'; + let sessionProfileMatchesTarget = true; + if (!sessionInProgress && S.session) { + const currentSessionProfile = (typeof S.session.profile === 'string' && S.session.profile.trim()) + ? S.session.profile.trim() + : 'default'; + sessionProfileMatchesTarget = (typeof _profileMatchesActiveProfile === 'function') + ? _profileMatchesActiveProfile(currentSessionProfile, targetActiveProfile) + : (currentSessionProfile === targetActiveProfile || (currentSessionProfile === 'default' && !!S.activeProfileIsDefault)); + if (!sessionProfileMatchesTarget) { + sessionInProgress = true; + } + } // #4650 review: a profile switch can change agent.reasoning_effort (and other // reasoning inputs like base_url) WITHOUT changing the default model/provider, // which is all the reasoning-chip cache key tracks. Force exactly one reasoning @@ -6577,10 +6614,20 @@ async function switchToProfile(name) { } // ── Session ──────────────────────────────────────────────────────────── - _showAllProfiles = false; + // Keep the all-profiles sidebar scope sticky across profile switches. It is + // a navigation preference shared by the browser session, not a per-profile flag. if (typeof animateNextSessionListRefresh === 'function') animateNextSessionListRefresh(); - if (sessionInProgress) { + if (sessionInProgress && _openingExistingSidebarSession) { + // The caller will immediately load the clicked session after this profile + // cookie switch. Avoid creating/retagging an intermediate blank chat. + const workspaceVisible = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed'; + if (typeof _setProfileSwitchListEmbargo === 'function') _setProfileSwitchListEmbargo(false); + await renderSessionList(); + if (_switchGen !== _profileSwitchGeneration) return; + if (workspaceVisible && typeof clearWorkspaceTreeSkeleton === 'function') clearWorkspaceTreeSkeleton(); + showToast(t('profile_switched', name)); + } else if (sessionInProgress) { // The current session has messages and belongs to the previous profile. // Start a new session for the new profile so nothing gets cross-tagged. const workspaceVisible = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed'; @@ -6600,6 +6647,7 @@ async function switchToProfile(name) { // and pop a stale toast. Mirrors the no-messages branch guard below. // (@rodboev/greptile review, #4662) if (_switchGen !== _profileSwitchGeneration) return; + if (typeof _openProfileSwitchSessionBrowser === 'function') _openProfileSwitchSessionBrowser(); // Safety net: if the new session has no workspace, newSession() won't have // painted the file tree — clear the up-front skeleton so it can't strand // (#4662 Opus gate). No-op when a real tree already rendered. @@ -6622,6 +6670,7 @@ async function switchToProfile(name) { if (typeof _setProfileSwitchListEmbargo === 'function') _setProfileSwitchListEmbargo(false); await renderSessionList(); if (_switchGen !== _profileSwitchGeneration) return; + if (typeof _openProfileSwitchSessionBrowser === 'function') _openProfileSwitchSessionBrowser(); syncTopbar(); // Refresh workspace file tree so the right panel shows the new // profile's workspace, not the previous one (#1214). diff --git a/static/sessions.js b/static/sessions.js index 73560c49..d4737283 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1854,6 +1854,37 @@ function _externalImportPayload(session) { return payload; } +function _sidebarSessionProfileName(session){ + const raw=session&&typeof session.profile==='string'?session.profile.trim():''; + return raw||''; +} + +async function _ensureSidebarSessionProfile(session){ + const targetProfile=_sidebarSessionProfileName(session); + if(!_showAllProfiles||!targetProfile) return false; + const activeProfile=S.activeProfile||'default'; + if(_profileMatchesActiveProfile(targetProfile,activeProfile)) return false; + if(typeof switchToProfile!=='function') return false; + _profileSwitchOpeningExistingSession=true; + try{ + await switchToProfile(targetProfile); + }finally{ + _profileSwitchOpeningExistingSession=false; + } + return _profileMatchesActiveProfile(targetProfile,S.activeProfile||'default'); +} + +async function _openSidebarSession(session, loadOpts={}){ + if(!session||!session.session_id) return; + if(_isExternalSession(session)){ + try{await api('/api/session/import_cli',{method:'POST',body:JSON.stringify(_externalImportPayload(session))});} + catch(_e){ /* import failed -- fall through to read-only view */ } + } + await _ensureSidebarSessionProfile(session); + await loadSession(session.session_id, loadOpts); + renderSessionListFromCache(); +} + function _isReadOnlySession(session) { return !!(session && (session.read_only || session.is_read_only)); } @@ -3161,7 +3192,9 @@ let _allProjects = []; // cached project list // double-underscore prefixes provide. const NO_PROJECT_FILTER = '__none__'; let _activeProject = null; // project_id filter (null = show all, NO_PROJECT_FILTER = unassigned only) +const SHOW_ALL_PROFILES_STORAGE_KEY = 'hermes-show-all-profiles'; let _showAllProfiles = false; // false = filter to active profile only +let _profileSwitchOpeningExistingSession = false; // true while cross-profile sidebar click switches profile before loadSession() let _otherProfileCount = 0; // count of sessions from other profiles (server-reported) let _archivedWebuiCount = 0; // archived WebUI sessions not fetched until requested let _archivedCliCount = 0; // archived non-WebUI sessions not fetched until requested @@ -3169,6 +3202,20 @@ let _archivedRowsLoadedLimit = SESSION_ARCHIVED_PAGE_SIZE; let _serverWebuiSessionCount = null; // explicit server count for WebUI sessions let _serverCliSessionCount = null; // explicit server count for CLI sessions let _sessionSourceFilter = 'webui'; // 'webui' keeps WebUI chats separate from read-only CLI sessions + +function _restoreShowAllProfiles(){ + try{ + const raw=localStorage.getItem(SHOW_ALL_PROFILES_STORAGE_KEY); + _showAllProfiles = raw === '1' || raw === 'true'; + }catch(_e){ _showAllProfiles = false; } +} + +function _setShowAllProfiles(enabled){ + _showAllProfiles=!!enabled; + try{ localStorage.setItem(SHOW_ALL_PROFILES_STORAGE_KEY,_showAllProfiles?'1':'0'); }catch(_e){} +} + +_restoreShowAllProfiles(); _restoreSessionSourceFilter(); let _sessionActionMenu = null; let _sessionActionAnchor = null; @@ -6607,13 +6654,13 @@ function renderSessionListFromCache(){ const pfToggle=document.createElement('div'); pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;'; pfToggle.textContent='Show '+otherProfileCount+' from other profiles'; - pfToggle.onclick=()=>{_showAllProfiles=true;renderSessionList();}; + pfToggle.onclick=()=>{_setShowAllProfiles(true);renderSessionList({deferWhileInteracting:false});}; list.appendChild(pfToggle); } else if(_showAllProfiles){ const pfToggle=document.createElement('div'); pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;'; pfToggle.textContent='Show active profile only'; - pfToggle.onclick=()=>{_showAllProfiles=false;renderSessionList();}; + pfToggle.onclick=()=>{_setShowAllProfiles(false);renderSessionList({deferWhileInteracting:false});}; list.appendChild(pfToggle); } // Show/hide archived toggle if there are archived sessions. Archived rows @@ -7027,12 +7074,7 @@ function renderSessionListFromCache(){ row.title=t('session_lineage_segment_open'); row.onclick=async(e)=>{ e.stopPropagation(); - if(_isExternalSession(seg)){ - try{await api('/api/session/import_cli',{method:'POST',body:JSON.stringify(_externalImportPayload(seg))});} - catch(_e){ /* read-only fallback */ } - } - await loadSession(seg.session_id, {skipLineageResolve:true}); - renderSessionListFromCache(); + await _openSidebarSession(seg, {skipLineageResolve:true}); }; lineageList.appendChild(row); } @@ -7044,12 +7086,7 @@ function renderSessionListFromCache(){ ['pointerdown','pointerup','click','touchstart','touchmove','touchend','touchcancel'].forEach(ev=>childList.addEventListener(ev,e=>e.stopPropagation())); const sortedChildren=[...s._child_sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a)); const openChildSession=async(childSession)=>{ - if(_isExternalSession(childSession)){ - try{await api('/api/session/import_cli',{method:'POST',body:JSON.stringify(_externalImportPayload(childSession))});} - catch(_e){ /* read-only fallback */ } - } - await loadSession(childSession.session_id, {skipLineageResolve:true}); - renderSessionListFromCache(); + await _openSidebarSession(childSession, {skipLineageResolve:true}); }; const childLabelFor=(child)=>{ const childTitle=_sessionDisplayTitle(child)||'Untitled child session'; @@ -7671,16 +7708,9 @@ function renderSessionListFromCache(){ _tapTimer=null; _lastTapTime=0; if(_renamingSid) return; - // For external sessions (CLI, Discord, Telegram, Slack), import into - // WebUI store first so /api/chat/start finds a persisted session. - if(_isExternalSession(s)){ - try{ - await api('/api/session/import_cli',{method:'POST',body:JSON.stringify(_externalImportPayload(s))}); - }catch(e){ /* import failed -- fall through to read-only view */ } - } try{ if(($('sessionSearch').value||'').trim()) _hideSearchPreviewsAfterSelect=true; - await loadSession(s.session_id);renderSessionListFromCache(); + await _openSidebarSession(s); if(typeof closeMobileSidebar==='function')closeMobileSidebar(); }finally{ el.classList.remove('loading'); @@ -7756,8 +7786,19 @@ async function _handleActiveSessionStorageEvent(e){ if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); } +async function _handleShowAllProfilesStorageEvent(e){ + if(!e || e.key !== SHOW_ALL_PROFILES_STORAGE_KEY) return; + const next=e.newValue==='1'||e.newValue==='true'; + if(_showAllProfiles===next) return; + _showAllProfiles=next; + if(typeof renderSessionList==='function') await renderSessionList({deferWhileInteracting:false}); +} + if(typeof window!=='undefined'){ - window.addEventListener('storage', (e) => { void _handleActiveSessionStorageEvent(e); }); + window.addEventListener('storage', (e) => { + void _handleActiveSessionStorageEvent(e); + void _handleShowAllProfilesStorageEvent(e); + }); window.addEventListener('popstate', () => { const sid=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null; if(!sid || (S.session && S.session.session_id===sid)) return; diff --git a/tests/test_firefox_sidebar_scroll_stability.py b/tests/test_firefox_sidebar_scroll_stability.py index 1df3cc8c..281b01dc 100644 --- a/tests/test_firefox_sidebar_scroll_stability.py +++ b/tests/test_firefox_sidebar_scroll_stability.py @@ -61,8 +61,8 @@ def test_only_background_refreshes_defer_while_sidebar_is_interacting(): assert "renderSessionList({deferWhileInteracting:true})" in streaming_poll_block assert "renderSessionList({deferWhileInteracting:true})" in gateway_poll_block assert "renderSessionList({deferWhileInteracting:true}); // re-fetch and re-render" in gateway_sse_block - assert "pfToggle.onclick=()=>{_showAllProfiles=true;renderSessionList();};" in SESSIONS_JS - assert "pfToggle.onclick=()=>{_showAllProfiles=false;renderSessionList();};" in SESSIONS_JS + assert "pfToggle.onclick=()=>{_setShowAllProfiles(true);renderSessionList({deferWhileInteracting:false});};" in SESSIONS_JS + assert "pfToggle.onclick=()=>{_setShowAllProfiles(false);renderSessionList({deferWhileInteracting:false});};" in SESSIONS_JS def test_session_list_pointer_hover_and_scroll_activity_are_tracked(): interaction_block = _block("function _isSessionListUserInteracting()", "function _schedulePendingSessionListApply") diff --git a/tests/test_issue1611_session_profile_filtering.py b/tests/test_issue1611_session_profile_filtering.py index 76468bcc..01fc0f35 100644 --- a/tests/test_issue1611_session_profile_filtering.py +++ b/tests/test_issue1611_session_profile_filtering.py @@ -182,7 +182,50 @@ def test_static_sessions_js_marks_all_profiles_imports_with_profile(): assert "function _externalImportPayload(session)" in src assert "payload.all_profiles = true;" in src assert "payload.profile = session.profile;" in src - assert "JSON.stringify(_externalImportPayload(s))" in src + assert "JSON.stringify(_externalImportPayload(s))" in src or "JSON.stringify(_externalImportPayload(session))" in src + + +def test_static_sessions_js_switches_profile_before_opening_all_profiles_row(): + """Clicking a cross-profile sidebar row must switch the active profile first. + + /api/session intentionally rejects a foreign-profile session_id. The UI must + use the row's profile metadata from ?all_profiles=1 before calling loadSession(). + """ + from pathlib import Path + + repo_root = Path(__file__).parent.parent + src = (repo_root / 'static' / 'sessions.js').read_text(encoding='utf-8') + + ensure_idx = src.index("async function _ensureSidebarSessionProfile(session)") + open_idx = src.index("async function _openSidebarSession(session, loadOpts={})") + ensure_body = src[ensure_idx:open_idx] + open_body = src[open_idx:src.index("function _isReadOnlySession", open_idx)] + + assert "await switchToProfile(targetProfile);" in ensure_body + assert "_profileSwitchOpeningExistingSession=true;" in ensure_body + assert open_body.index("await _ensureSidebarSessionProfile(session);") < open_body.index("await loadSession(session.session_id, loadOpts);") + assert "await _openSidebarSession(s);" in src + assert "await _openSidebarSession(seg, {skipLineageResolve:true});" in src + assert "await _openSidebarSession(childSession, {skipLineageResolve:true});" in src + + +def test_static_all_profiles_toggle_is_persisted_and_not_reset_by_profile_switch(): + """The all-profiles toggle is a shared navigation preference, not per-profile state.""" + from pathlib import Path + + repo_root = Path(__file__).parent.parent + sessions_src = (repo_root / 'static' / 'sessions.js').read_text(encoding='utf-8') + panels_src = (repo_root / 'static' / 'panels.js').read_text(encoding='utf-8') + + assert "const SHOW_ALL_PROFILES_STORAGE_KEY = 'hermes-show-all-profiles';" in sessions_src + assert "localStorage.setItem(SHOW_ALL_PROFILES_STORAGE_KEY" in sessions_src + assert "_restoreShowAllProfiles();" in sessions_src + assert "_setShowAllProfiles(true);renderSessionList({deferWhileInteracting:false});" in sessions_src + assert "_setShowAllProfiles(false);renderSessionList({deferWhileInteracting:false});" in sessions_src + + switch_start = panels_src.index("async function switchToProfile(name) {") + switch_body = panels_src[switch_start:panels_src.index("function openProfileCreate", switch_start)] + assert "_showAllProfiles = false" not in switch_body # ── SHOULD-FIX #2: profile filter must run BEFORE messaging-source dedupe ── diff --git a/tests/test_issue1700_parallel_profile_switch.py b/tests/test_issue1700_parallel_profile_switch.py index 7928b9a7..2cb25cf8 100644 --- a/tests/test_issue1700_parallel_profile_switch.py +++ b/tests/test_issue1700_parallel_profile_switch.py @@ -4,6 +4,7 @@ A WebUI profile switch uses cookie/thread-local profile state, so it should be allowed while another session is streaming. Only process-wide profile switches must remain blocked because they mutate global Hermes runtime state. """ +import re from pathlib import Path import pytest @@ -88,7 +89,11 @@ def test_frontend_profile_switch_no_longer_blocks_on_busy_state(): def test_frontend_treats_active_or_pending_session_as_in_progress(): fn = _extract_switch_to_profile() - session_block = fn[fn.find("const sessionInProgress") : fn.find("try {", fn.find("const sessionInProgress"))] + session_decl = re.search(r"\b(?:let|const)\s+sessionInProgress\b", fn) + assert session_decl, "sessionInProgress declaration not found" + try_idx = fn.find("try {", session_decl.start()) + assert try_idx != -1, "switchToProfile() try block not found after sessionInProgress declaration" + session_block = fn[session_decl.start() : try_idx] assert "S.session.active_stream_id" in session_block assert "S.session.pending_user_message" in session_block diff --git a/tests/test_issue3603_external_session_import_gate.py b/tests/test_issue3603_external_session_import_gate.py index fd923ec1..e82daa48 100644 --- a/tests/test_issue3603_external_session_import_gate.py +++ b/tests/test_issue3603_external_session_import_gate.py @@ -6,9 +6,9 @@ sessions, making messaging sessions click-to-open-but-can't-send. This test pins: - `_isExternalSession` exists in sessions.js -- The open-path gate at line ~5024 uses `_isExternalSession` (not just is_cli_session) -- The gateway-refresh gate at line ~3116 uses `_isExternalSession` -- The lineage-segment and child-session open gates use `_isExternalSession` +- The shared sidebar open helper uses `_isExternalSession` before import_cli +- The gateway-refresh gate uses `_isExternalSession` +- Main, lineage-segment, and child-session opens route through that helper """ import re @@ -45,14 +45,21 @@ def test_is_external_session_covers_messaging(): ) +def _open_sidebar_session_body(js): + start = js.index('async function _openSidebarSession(session, loadOpts={})') + end = js.index('function _isReadOnlySession', start) + return js[start:end] + + def test_open_path_uses_is_external_session(): - """Session open handler must use _isExternalSession for import gate.""" + """Shared sidebar open helper must use _isExternalSession for import gate.""" js = _read_js() - # The import gate in the session-click handler should use _isExternalSession + body = _open_sidebar_session_body(js) assert re.search( - r'if\s*\(\s*_isExternalSession\s*\(\s*s\s*\)\s*\)', - js, - ), 'Session open handler must use _isExternalSession(s) for import gate' + r'if\s*\(\s*_isExternalSession\s*\(\s*session\s*\)\s*\)', + body, + ), 'Shared sidebar open helper must use _isExternalSession(session) for import gate' + assert "JSON.stringify(_externalImportPayload(session))" in body def test_gateway_refresh_uses_is_external_session(): @@ -67,18 +74,22 @@ def test_gateway_refresh_uses_is_external_session(): def test_lineage_open_uses_is_external_session(): - """Lineage segment open handler must use _isExternalSession.""" + """Lineage segment open handler must route through shared import/open helper.""" js = _read_js() + body = _open_sidebar_session_body(js) assert re.search( - r'if\s*\(\s*_isExternalSession\s*\(\s*seg\s*\)\s*\)', - js, - ), 'Lineage segment open must use _isExternalSession(seg)' + r'if\s*\(\s*_isExternalSession\s*\(\s*session\s*\)\s*\)', + body, + ), 'Shared sidebar helper must keep _isExternalSession(session) import gate' + assert "await _openSidebarSession(seg, {skipLineageResolve:true});" in js def test_child_session_open_uses_is_external_session(): - """Child session open handler must use _isExternalSession.""" + """Child session open handler must route through shared import/open helper.""" js = _read_js() + body = _open_sidebar_session_body(js) assert re.search( - r'if\s*\(\s*_isExternalSession\s*\(\s*(?:child|childSession)\s*\)\s*\)', - js, - ), 'Child session open must use _isExternalSession for the selected child session' + r'if\s*\(\s*_isExternalSession\s*\(\s*session\s*\)\s*\)', + body, + ), 'Shared sidebar helper must keep _isExternalSession(session) import gate' + assert "await _openSidebarSession(childSession, {skipLineageResolve:true});" in js diff --git a/tests/test_issue4662_profile_switch_skeleton_static.py b/tests/test_issue4662_profile_switch_skeleton_static.py index 1e6567ed..1b79e79b 100644 --- a/tests/test_issue4662_profile_switch_skeleton_static.py +++ b/tests/test_issue4662_profile_switch_skeleton_static.py @@ -10,6 +10,7 @@ silently regress: * style.css defines the skeleton classes, the sheen + fade keyframes, the reduced-motion fallback, and dark-mode tokens. """ +import re from pathlib import Path REPO_ROOT = Path(__file__).parent.parent.resolve() @@ -26,12 +27,21 @@ def _switch_body() -> str: return PANELS[start:end] +def _show_session_skeleton_call_idx(body: str) -> int: + match = re.search( + r"(?:^|\n)\s*(?:if\s*\([^\n;]*showSessionListSkeleton[^\n;]*\)\s*)?showSessionListSkeleton\s*\(", + body, + ) + assert match, "switchToProfile() must call showSessionListSkeleton(...)" + return match.start() + + class TestSwitchWiring: def test_shows_session_skeleton_up_front(self): body = _switch_body() - assert "showSessionListSkeleton()" in body + skeleton_idx = _show_session_skeleton_call_idx(body) # ...and before the awaited /api/profile/switch POST, so stale rows clear immediately - assert body.index("showSessionListSkeleton()") < body.index("/api/profile/switch") + assert skeleton_idx < body.index("await api('/api/profile/switch'") def test_shows_workspace_skeleton_when_panel_open(self): body = _switch_body() @@ -76,7 +86,7 @@ class TestSwitchWiring: # showing a skeleton (activateCurrentProfile() doesn't pre-check), else it # flashes skeleton→restore. body = _switch_body() - head = body[: body.index("showSessionListSkeleton()")] + head = body[: _show_session_skeleton_call_idx(body)] assert "name === S.activeProfile" in head, "missing no-op self-switch early-return" assert "return;" in head @@ -85,7 +95,7 @@ class TestSwitchWiring: # _renamingSid / _sessionActionMenu is set — which would strand the # skeleton. switchToProfile must dismiss both before showing it. body = _switch_body() - pre = body[: body.index("showSessionListSkeleton()")] + pre = body[: _show_session_skeleton_call_idx(body)] assert "_renamingSid = null" in pre, "must clear inline-rename state before skeleton" assert "closeSessionActionMenu()" in pre, "must close row action menu before skeleton" @@ -104,10 +114,10 @@ class TestSwitchWiring: # transient-but-eventually-successful switch. Failures surface only through # the generation-guarded catch handler, which is the single source of truth. body = _switch_body() - post_idx = body.index("/api/profile/switch") + post_idx = body.index("await api('/api/profile/switch'") # The same api(...) call expression that targets /api/profile/switch must # carry timeoutToast: false. Scope to a small window around the call. - window = body[post_idx - 200: post_idx + 200] + window = body[post_idx: post_idx + 300] assert "timeoutToast: false" in window or "timeoutToast:false" in window.replace(" ", ""), ( "the /api/profile/switch POST must suppress the generic timeout toast" ) diff --git a/tests/test_profile_switch_ux.py b/tests/test_profile_switch_ux.py index e6ce1f9f..e56afc5a 100644 --- a/tests/test_profile_switch_ux.py +++ b/tests/test_profile_switch_ux.py @@ -140,6 +140,74 @@ class TestParallelizedFetches: assert "const _dirP=loadDir('.')" in sessions_js assert "Keep new-chat first paint instant" in sessions_js + def test_cross_profile_empty_session_is_replaced_before_mutation_or_upload(self): + """Switching profile must not keep an old-profile empty session active. + + Uploads post ``S.session.session_id`` and the backend correctly rejects a + session that is invisible to the active profile. Therefore a profile + switch must promote a profile-mismatched, even empty, current session to + the ``sessionInProgress`` replacement path before any in-place + ``/api/session/update`` or local profile retagging can make the browser + believe the old session is safe to use. + """ + fn = self._get_switch_fn() + mismatch_idx = fn.find("sessionProfileMatchesTarget") + assert mismatch_idx != -1, ( + "switchToProfile() must explicitly compare the current session profile " + "with the target active profile" + ) + assert "const targetActiveProfile = S.activeProfile || 'default';" in fn + assert "currentSessionProfile === targetActiveProfile || (currentSessionProfile === 'default' && !!S.activeProfileIsDefault)" in fn, ( + "fallback matching must preserve the renamed-default/root-profile alias semantics" + ) + promote_idx = fn.find("sessionInProgress = true", mismatch_idx) + assert promote_idx != -1, ( + "a profile-mismatched current session must force the new-session branch, " + "even when it has no messages" + ) + first_in_place_patch = fn.find("if (S.session && !sessionInProgress)") + first_update = fn.find("await api('/api/session/update'") + branch_idx = fn.find("if (sessionInProgress)") + assert -1 not in (first_in_place_patch, first_update, branch_idx) + assert promote_idx < first_in_place_patch < first_update < branch_idx, ( + "the stale-session promotion must happen before any in-place session " + "retag/update and before choosing the profile-switch branch" + ) + + def test_profile_switch_opens_session_browser_after_successful_list_refresh(self): + """After changing profile, expose the new profile's conversation list. + + If the user started from an old-profile chat, leaving the old transcript as + the dominant UI invites sending into a stale session_id. The switch must + render the new profile's session list, then open the sidebar/mobile drawer + so the user can choose an existing conversation or click New Chat. + """ + fn = self._get_switch_fn() + assert "function _openProfileSwitchSessionBrowser(" in self.JS + open_calls = [m.start() for m in re.finditer(r"_openProfileSwitchSessionBrowser\(\)", fn)] + assert len(open_calls) >= 2, ( + "both profile-switch success branches must expose the new profile's session browser" + ) + for open_idx in open_calls: + render_idx = fn.rfind("await renderSessionList();", 0, open_idx) + assert render_idx != -1 and render_idx < open_idx, ( + "open the sidebar only after the new profile's session list has rendered" + ) + toast_idx = fn.find("showToast(", open_idx) + assert toast_idx != -1, "the open call should remain in the success branch before user feedback" + + def test_profile_switch_session_browser_helper_supports_desktop_and_mobile(self): + js = self.JS + start = js.find("function _openProfileSwitchSessionBrowser(") + assert start != -1 + end = js.find("async function switchToProfile(", start) + assert end != -1 + helper = js[start:end] + assert "expandSidebar()" in helper, "desktop profile switches must uncollapse the sidebar" + assert "mobile-panel-drawer" in helper + assert "mobile-open" in helper + assert "mobile-session-page" in helper + class TestSpinnerCss: """Verify the spinner CSS class is defined correctly.""" diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 45402a0f..935ee33f 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -1530,9 +1530,9 @@ def test_lineage_segment_expansion_static_contract(): assert "className='session-lineage-segment'" in js assert "const segTitle=_sessionDisplayTitle(seg)||t('session_lineage_segment_untitled');" in js assert "row.title=t('session_lineage_segment_open');" in js - assert "await loadSession(seg.session_id, {skipLineageResolve:true});" in js + assert "await _openSidebarSession(seg, {skipLineageResolve:true});" in js assert "const openChildSession=async(childSession)=>{" in js - assert "await loadSession(childSession.session_id, {skipLineageResolve:true});" in js + assert "await _openSidebarSession(childSession, {skipLineageResolve:true});" in js assert "if(!opts.skipLineageResolve && typeof _resolveSessionIdFromSidebarLineage==='function'){" in js assert ".session-lineage-count.expandable{" in css assert ".session-lineage-count.expandable:hover" in css diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 2c73e610..a9a8f2af 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -79,10 +79,10 @@ def test_open_session_menu_consumes_next_row_activation(): assert "if(_longPressMenuOpened){_gestureState='idle';return true;}" in SESSIONS_JS finish_idx = SESSIONS_JS.find("const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{") dismiss_idx = SESSIONS_JS.find("if(_sessionActionMenu&&!_sessionActionMenu.contains(target)){", finish_idx) - load_idx = SESSIONS_JS.find("await loadSession(s.session_id)", finish_idx) + open_idx = SESSIONS_JS.find("await _openSidebarSession(s)", finish_idx) pointerup_idx = SESSIONS_JS.find("el.onpointerup=(e)=>{") - assert finish_idx > 0 and load_idx > finish_idx - assert dismiss_idx > finish_idx and dismiss_idx < load_idx + assert finish_idx > 0 and open_idx > finish_idx + assert dismiss_idx > finish_idx and dismiss_idx < open_idx assert "if(_finishSessionGesture(e.clientX,e.clientY,e.target,e.pointerType)) e.stopPropagation();" in SESSIONS_JS[pointerup_idx:]