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 <ruizanthony@users.noreply.github.com>
This commit is contained in:
nesquena-hermes 2026-07-03 20:49:51 +00:00
parent b9813af66c
commit 5e5b44e733
10 changed files with 288 additions and 61 deletions

View file

@ -6383,6 +6383,21 @@ window.addEventListener('resize',()=>{
if(dd&&dd.classList.contains('open')) _positionProfileDropdown(); 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) { async function switchToProfile(name) {
// ── #4671 profile-switch loading-skeleton — FOUR-GUARD CONTRACT ─────────────── // ── #4671 profile-switch loading-skeleton — FOUR-GUARD CONTRACT ───────────────
// The skeleton must never be clobbered by the OLD profile's content and must never // 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 _titlebarLabel = $('titlebarProfileLabel');
const _prevProfileName = S.activeProfile || 'default'; const _prevProfileName = S.activeProfile || 'default';
const _switchGen = ++_profileSwitchGeneration; const _switchGen = ++_profileSwitchGeneration;
const _openingExistingSidebarSession = !!(typeof _profileSwitchOpeningExistingSession !== 'undefined' && _profileSwitchOpeningExistingSession);
if (_chip) { _chip.classList.add('switching'); _chip.disabled = true; } if (_chip) { _chip.classList.add('switching'); _chip.disabled = true; }
if (_titlebarBtn) { _titlebarBtn.classList.add('switching'); _titlebarBtn.disabled = true; } if (_titlebarBtn) { _titlebarBtn.classList.add('switching'); _titlebarBtn.disabled = true; }
// Optimistic name update — shows the target name right away // 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. // context change where dismissing those transient affordances is correct.
if (typeof _renamingSid !== 'undefined' && _renamingSid) _renamingSid = null; if (typeof _renamingSid !== 'undefined' && _renamingSid) _renamingSid = null;
if (typeof closeSessionActionMenu === 'function') closeSessionActionMenu(); if (typeof closeSessionActionMenu === 'function') closeSessionActionMenu();
// Determine whether the current session has any messages. // Determine whether the current session must be replaced instead of being
// A session with messages is "in progress" and belongs to the current profile — // retagged in place. A session with messages/active runtime belongs to the
// we must not retag it. We'll start a fresh session for the new profile instead. // current profile. After the profile-switch POST returns, we also treat an
const sessionInProgress = S.session && ( // 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.messages && S.messages.length > 0) ||
S.session.active_stream_id || S.session.active_stream_id ||
S.session.pending_user_message 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'; const _workspaceVisibleAtStart = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed';
// #4671 CORE: the skeleton/embargo/generation setup is INSIDE the try so the // #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; if (_switchGen !== _profileSwitchGeneration) return;
S.activeProfile = data.active || name; S.activeProfile = data.active || name;
S.activeProfileIsDefault = !!data.is_default; 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 // #4650 review: a profile switch can change agent.reasoning_effort (and other
// reasoning inputs like base_url) WITHOUT changing the default model/provider, // reasoning inputs like base_url) WITHOUT changing the default model/provider,
// which is all the reasoning-chip cache key tracks. Force exactly one reasoning // which is all the reasoning-chip cache key tracks. Force exactly one reasoning
@ -6577,10 +6614,20 @@ async function switchToProfile(name) {
} }
// ── Session ──────────────────────────────────────────────────────────── // ── 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 (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. // The current session has messages and belongs to the previous profile.
// Start a new session for the new profile so nothing gets cross-tagged. // Start a new session for the new profile so nothing gets cross-tagged.
const workspaceVisible = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed'; 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. // and pop a stale toast. Mirrors the no-messages branch guard below.
// (@rodboev/greptile review, #4662) // (@rodboev/greptile review, #4662)
if (_switchGen !== _profileSwitchGeneration) return; if (_switchGen !== _profileSwitchGeneration) return;
if (typeof _openProfileSwitchSessionBrowser === 'function') _openProfileSwitchSessionBrowser();
// Safety net: if the new session has no workspace, newSession() won't have // 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 // 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. // (#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); if (typeof _setProfileSwitchListEmbargo === 'function') _setProfileSwitchListEmbargo(false);
await renderSessionList(); await renderSessionList();
if (_switchGen !== _profileSwitchGeneration) return; if (_switchGen !== _profileSwitchGeneration) return;
if (typeof _openProfileSwitchSessionBrowser === 'function') _openProfileSwitchSessionBrowser();
syncTopbar(); syncTopbar();
// Refresh workspace file tree so the right panel shows the new // Refresh workspace file tree so the right panel shows the new
// profile's workspace, not the previous one (#1214). // profile's workspace, not the previous one (#1214).

View file

@ -1854,6 +1854,37 @@ function _externalImportPayload(session) {
return payload; 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) { function _isReadOnlySession(session) {
return !!(session && (session.read_only || session.is_read_only)); return !!(session && (session.read_only || session.is_read_only));
} }
@ -3161,7 +3192,9 @@ let _allProjects = []; // cached project list
// double-underscore prefixes provide. // double-underscore prefixes provide.
const NO_PROJECT_FILTER = '__none__'; const NO_PROJECT_FILTER = '__none__';
let _activeProject = null; // project_id filter (null = show all, NO_PROJECT_FILTER = unassigned only) 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 _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 _otherProfileCount = 0; // count of sessions from other profiles (server-reported)
let _archivedWebuiCount = 0; // archived WebUI sessions not fetched until requested let _archivedWebuiCount = 0; // archived WebUI sessions not fetched until requested
let _archivedCliCount = 0; // archived non-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 _serverWebuiSessionCount = null; // explicit server count for WebUI sessions
let _serverCliSessionCount = null; // explicit server count for CLI sessions let _serverCliSessionCount = null; // explicit server count for CLI sessions
let _sessionSourceFilter = 'webui'; // 'webui' keeps WebUI chats separate from read-only 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(); _restoreSessionSourceFilter();
let _sessionActionMenu = null; let _sessionActionMenu = null;
let _sessionActionAnchor = null; let _sessionActionAnchor = null;
@ -6607,13 +6654,13 @@ function renderSessionListFromCache(){
const pfToggle=document.createElement('div'); 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.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.textContent='Show '+otherProfileCount+' from other profiles';
pfToggle.onclick=()=>{_showAllProfiles=true;renderSessionList();}; pfToggle.onclick=()=>{_setShowAllProfiles(true);renderSessionList({deferWhileInteracting:false});};
list.appendChild(pfToggle); list.appendChild(pfToggle);
} else if(_showAllProfiles){ } else if(_showAllProfiles){
const pfToggle=document.createElement('div'); 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.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.textContent='Show active profile only';
pfToggle.onclick=()=>{_showAllProfiles=false;renderSessionList();}; pfToggle.onclick=()=>{_setShowAllProfiles(false);renderSessionList({deferWhileInteracting:false});};
list.appendChild(pfToggle); list.appendChild(pfToggle);
} }
// Show/hide archived toggle if there are archived sessions. Archived rows // 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.title=t('session_lineage_segment_open');
row.onclick=async(e)=>{ row.onclick=async(e)=>{
e.stopPropagation(); e.stopPropagation();
if(_isExternalSession(seg)){ await _openSidebarSession(seg, {skipLineageResolve:true});
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();
}; };
lineageList.appendChild(row); lineageList.appendChild(row);
} }
@ -7044,12 +7086,7 @@ function renderSessionListFromCache(){
['pointerdown','pointerup','click','touchstart','touchmove','touchend','touchcancel'].forEach(ev=>childList.addEventListener(ev,e=>e.stopPropagation())); ['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 sortedChildren=[...s._child_sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
const openChildSession=async(childSession)=>{ const openChildSession=async(childSession)=>{
if(_isExternalSession(childSession)){ await _openSidebarSession(childSession, {skipLineageResolve:true});
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();
}; };
const childLabelFor=(child)=>{ const childLabelFor=(child)=>{
const childTitle=_sessionDisplayTitle(child)||'Untitled child session'; const childTitle=_sessionDisplayTitle(child)||'Untitled child session';
@ -7671,16 +7708,9 @@ function renderSessionListFromCache(){
_tapTimer=null; _tapTimer=null;
_lastTapTime=0; _lastTapTime=0;
if(_renamingSid) return; 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{ try{
if(($('sessionSearch').value||'').trim()) _hideSearchPreviewsAfterSelect=true; if(($('sessionSearch').value||'').trim()) _hideSearchPreviewsAfterSelect=true;
await loadSession(s.session_id);renderSessionListFromCache(); await _openSidebarSession(s);
if(typeof closeMobileSidebar==='function')closeMobileSidebar(); if(typeof closeMobileSidebar==='function')closeMobileSidebar();
}finally{ }finally{
el.classList.remove('loading'); el.classList.remove('loading');
@ -7756,8 +7786,19 @@ async function _handleActiveSessionStorageEvent(e){
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); 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'){ if(typeof window!=='undefined'){
window.addEventListener('storage', (e) => { void _handleActiveSessionStorageEvent(e); }); window.addEventListener('storage', (e) => {
void _handleActiveSessionStorageEvent(e);
void _handleShowAllProfilesStorageEvent(e);
});
window.addEventListener('popstate', () => { window.addEventListener('popstate', () => {
const sid=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null; const sid=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
if(!sid || (S.session && S.session.session_id===sid)) return; if(!sid || (S.session && S.session.session_id===sid)) return;

View file

@ -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 streaming_poll_block
assert "renderSessionList({deferWhileInteracting:true})" in gateway_poll_block assert "renderSessionList({deferWhileInteracting:true})" in gateway_poll_block
assert "renderSessionList({deferWhileInteracting:true}); // re-fetch and re-render" in gateway_sse_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=()=>{_setShowAllProfiles(true);renderSessionList({deferWhileInteracting:false});};" in SESSIONS_JS
assert "pfToggle.onclick=()=>{_showAllProfiles=false;renderSessionList();};" 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(): def test_session_list_pointer_hover_and_scroll_activity_are_tracked():
interaction_block = _block("function _isSessionListUserInteracting()", "function _schedulePendingSessionListApply") interaction_block = _block("function _isSessionListUserInteracting()", "function _schedulePendingSessionListApply")

View file

@ -182,7 +182,50 @@ def test_static_sessions_js_marks_all_profiles_imports_with_profile():
assert "function _externalImportPayload(session)" in src assert "function _externalImportPayload(session)" in src
assert "payload.all_profiles = true;" in src assert "payload.all_profiles = true;" in src
assert "payload.profile = session.profile;" 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 ── # ── SHOULD-FIX #2: profile filter must run BEFORE messaging-source dedupe ──

View file

@ -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 allowed while another session is streaming. Only process-wide profile switches
must remain blocked because they mutate global Hermes runtime state. must remain blocked because they mutate global Hermes runtime state.
""" """
import re
from pathlib import Path from pathlib import Path
import pytest 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(): def test_frontend_treats_active_or_pending_session_as_in_progress():
fn = _extract_switch_to_profile() 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.active_stream_id" in session_block
assert "S.session.pending_user_message" in session_block assert "S.session.pending_user_message" in session_block

View file

@ -6,9 +6,9 @@ sessions, making messaging sessions click-to-open-but-can't-send.
This test pins: This test pins:
- `_isExternalSession` exists in sessions.js - `_isExternalSession` exists in sessions.js
- The open-path gate at line ~5024 uses `_isExternalSession` (not just is_cli_session) - The shared sidebar open helper uses `_isExternalSession` before import_cli
- The gateway-refresh gate at line ~3116 uses `_isExternalSession` - The gateway-refresh gate uses `_isExternalSession`
- The lineage-segment and child-session open gates use `_isExternalSession` - Main, lineage-segment, and child-session opens route through that helper
""" """
import re 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(): 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() js = _read_js()
# The import gate in the session-click handler should use _isExternalSession body = _open_sidebar_session_body(js)
assert re.search( assert re.search(
r'if\s*\(\s*_isExternalSession\s*\(\s*s\s*\)\s*\)', r'if\s*\(\s*_isExternalSession\s*\(\s*session\s*\)\s*\)',
js, body,
), 'Session open handler must use _isExternalSession(s) for import gate' ), '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(): 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(): 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() js = _read_js()
body = _open_sidebar_session_body(js)
assert re.search( assert re.search(
r'if\s*\(\s*_isExternalSession\s*\(\s*seg\s*\)\s*\)', r'if\s*\(\s*_isExternalSession\s*\(\s*session\s*\)\s*\)',
js, body,
), 'Lineage segment open must use _isExternalSession(seg)' ), '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(): 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() js = _read_js()
body = _open_sidebar_session_body(js)
assert re.search( assert re.search(
r'if\s*\(\s*_isExternalSession\s*\(\s*(?:child|childSession)\s*\)\s*\)', r'if\s*\(\s*_isExternalSession\s*\(\s*session\s*\)\s*\)',
js, body,
), 'Child session open must use _isExternalSession for the selected child session' ), 'Shared sidebar helper must keep _isExternalSession(session) import gate'
assert "await _openSidebarSession(childSession, {skipLineageResolve:true});" in js

View file

@ -10,6 +10,7 @@ silently regress:
* style.css defines the skeleton classes, the sheen + fade keyframes, the * style.css defines the skeleton classes, the sheen + fade keyframes, the
reduced-motion fallback, and dark-mode tokens. reduced-motion fallback, and dark-mode tokens.
""" """
import re
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent.resolve() REPO_ROOT = Path(__file__).parent.parent.resolve()
@ -26,12 +27,21 @@ def _switch_body() -> str:
return PANELS[start:end] 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: class TestSwitchWiring:
def test_shows_session_skeleton_up_front(self): def test_shows_session_skeleton_up_front(self):
body = _switch_body() 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 # ...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): def test_shows_workspace_skeleton_when_panel_open(self):
body = _switch_body() body = _switch_body()
@ -76,7 +86,7 @@ class TestSwitchWiring:
# showing a skeleton (activateCurrentProfile() doesn't pre-check), else it # showing a skeleton (activateCurrentProfile() doesn't pre-check), else it
# flashes skeleton→restore. # flashes skeleton→restore.
body = _switch_body() 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 "name === S.activeProfile" in head, "missing no-op self-switch early-return"
assert "return;" in head assert "return;" in head
@ -85,7 +95,7 @@ class TestSwitchWiring:
# _renamingSid / _sessionActionMenu is set — which would strand the # _renamingSid / _sessionActionMenu is set — which would strand the
# skeleton. switchToProfile must dismiss both before showing it. # skeleton. switchToProfile must dismiss both before showing it.
body = _switch_body() 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 "_renamingSid = null" in pre, "must clear inline-rename state before skeleton"
assert "closeSessionActionMenu()" in pre, "must close row action menu 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 # transient-but-eventually-successful switch. Failures surface only through
# the generation-guarded catch handler, which is the single source of truth. # the generation-guarded catch handler, which is the single source of truth.
body = _switch_body() 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 # The same api(...) call expression that targets /api/profile/switch must
# carry timeoutToast: false. Scope to a small window around the call. # 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(" ", ""), ( assert "timeoutToast: false" in window or "timeoutToast:false" in window.replace(" ", ""), (
"the /api/profile/switch POST must suppress the generic timeout toast" "the /api/profile/switch POST must suppress the generic timeout toast"
) )

View file

@ -140,6 +140,74 @@ class TestParallelizedFetches:
assert "const _dirP=loadDir('.')" in sessions_js assert "const _dirP=loadDir('.')" in sessions_js
assert "Keep new-chat first paint instant" 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: class TestSpinnerCss:
"""Verify the spinner CSS class is defined correctly.""" """Verify the spinner CSS class is defined correctly."""

View file

@ -1530,9 +1530,9 @@ def test_lineage_segment_expansion_static_contract():
assert "className='session-lineage-segment'" in js assert "className='session-lineage-segment'" in js
assert "const segTitle=_sessionDisplayTitle(seg)||t('session_lineage_segment_untitled');" 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 "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 "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 "if(!opts.skipLineageResolve && typeof _resolveSessionIdFromSidebarLineage==='function'){" in js
assert ".session-lineage-count.expandable{" in css assert ".session-lineage-count.expandable{" in css
assert ".session-lineage-count.expandable:hover" in css assert ".session-lineage-count.expandable:hover" in css

View file

@ -79,10 +79,10 @@ def test_open_session_menu_consumes_next_row_activation():
assert "if(_longPressMenuOpened){_gestureState='idle';return true;}" in SESSIONS_JS assert "if(_longPressMenuOpened){_gestureState='idle';return true;}" in SESSIONS_JS
finish_idx = SESSIONS_JS.find("const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{") finish_idx = SESSIONS_JS.find("const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{")
dismiss_idx = SESSIONS_JS.find("if(_sessionActionMenu&&!_sessionActionMenu.contains(target)){", finish_idx) 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)=>{") pointerup_idx = SESSIONS_JS.find("el.onpointerup=(e)=>{")
assert finish_idx > 0 and load_idx > finish_idx assert finish_idx > 0 and open_idx > finish_idx
assert dismiss_idx > finish_idx and dismiss_idx < load_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:] assert "if(_finishSessionGesture(e.clientX,e.clientY,e.target,e.pointerType)) e.stopPropagation();" in SESSIONS_JS[pointerup_idx:]