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-hermesandruizanthony committed 2026-07-03 20:49:51 +00:00
1 parent b9813af66c
commit 5e5b44e733
10 files changed
+288 -61

No files matched your search

+56 -7
View File
@@ -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).
+64 -23
View File
@@ -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;
@@ -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")
@@ -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 ──
@@ -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
@@ -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
@@ -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"
)
+68
View File
@@ -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."""
+2 -2
View File
@@ -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
+3 -3
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
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:]