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:
parent
b9813af66c
commit
5e5b44e733
10 changed files with 288 additions and 61 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 ──
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue