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:
1 parent
b9813af66c
commit
5e5b44e733
10 files changed
+288
-61
No files matched your search
+56
-7
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:]
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user