diff --git a/static/panels.js b/static/panels.js index 19740efa..d426a663 100644 --- a/static/panels.js +++ b/static/panels.js @@ -5921,8 +5921,97 @@ async function switchToWorkspace(path,name){ // ── Profile panel + dropdown ── let _profilesCache = null; +let _profileDropdownFetchPromise = null; +let _profileDropdownCacheLoadedFromStorage = false; +const PROFILE_DROPDOWN_CACHE_KEY = 'hermes-webui-profile-dropdown-cache-v1'; +const PROFILE_DROPDOWN_CACHE_TTL_MS = 5 * 60 * 1000; let _profileSwitchGeneration = 0; let _profileDropdownTrigger = null; // tracks which element triggered the dropdown +let _profileDropdownOpenGeneration = 0; + +function _profileDropdownClearStoredCache(){ + try{localStorage.removeItem(PROFILE_DROPDOWN_CACHE_KEY);}catch(_){} +} + +function _profileDropdownDataCacheUsable(data){ + return !!( + data && + Array.isArray(data.profiles) && + data.profiles.length && + data.profiles.every(p=>p&&typeof p.name==='string') + ); +} + +function _profileDropdownCacheUsable(data){ + return !!(_profileDropdownDataCacheUsable(data) && data.single_profile_mode !== true); +} + +function _profileDropdownReadStoredCache(){ + if(_profileDropdownCacheLoadedFromStorage) return _profileDropdownCacheUsable(_profilesCache) ? _profilesCache : null; + _profileDropdownCacheLoadedFromStorage = true; + try{ + const raw=localStorage.getItem(PROFILE_DROPDOWN_CACHE_KEY); + if(!raw) return null; + const parsed=JSON.parse(raw); + if(!parsed || typeof parsed.ts!=='number' || !parsed.data) { _profileDropdownClearStoredCache(); return null; } + if(Date.now()-parsed.ts>PROFILE_DROPDOWN_CACHE_TTL_MS) { _profileDropdownClearStoredCache(); return null; } + if(!_profileDropdownCacheUsable(parsed.data)) { _profileDropdownClearStoredCache(); return null; } + _profilesCache = parsed.data; + return _profilesCache; + }catch(_){_profileDropdownClearStoredCache();return null;} +} + +function _profileDropdownWriteStoredCache(data){ + if(!_profileDropdownCacheUsable(data)) { _profileDropdownClearStoredCache(); return; } + try{localStorage.setItem(PROFILE_DROPDOWN_CACHE_KEY, JSON.stringify({ts:Date.now(), data}));}catch(_){} +} + +function _profileDropdownBestCachedData(){ + if(_profileDropdownCacheUsable(_profilesCache)) return _profilesCache; + if(_profileDropdownDataCacheUsable(_profilesCache)) return null; + _profilesCache = null; + return _profileDropdownReadStoredCache(); +} + +function _profileDropdownFetchFresh(){ + if(_profileDropdownFetchPromise) return _profileDropdownFetchPromise; + _profileDropdownFetchPromise = api('/api/profiles', {timeoutToast:false}).then(data=>{ + if(_profileDropdownDataCacheUsable(data)) _profilesCache = data; + _profileDropdownWriteStoredCache(data); + return data; + }).finally(()=>{ _profileDropdownFetchPromise = null; }); + return _profileDropdownFetchPromise; +} + +function _warmProfileDropdownCache(){ + _profileDropdownBestCachedData(); + _profileDropdownFetchFresh().catch(()=>{}); +} + +if(typeof window!=='undefined'){ + window.addEventListener('load',()=>{ + setTimeout(()=>{ + if(typeof document==='undefined'||!document.hidden) _warmProfileDropdownCache(); + },1200); + },{once:true}); +} + +function _renderProfileDropdownLoading(){ + const dd=$('profileDropdown'); + if(!dd)return; + dd.innerHTML=`
${esc(t('loading')||'Loading...')}
`; +} + +function _openProfileDropdownShell(){ + const dd=$('profileDropdown'); + if(!dd)return; + dd.classList.add('open'); + _positionProfileDropdown(); + const chip=$('profileChip'); + if(chip && _profileDropdownTrigger===chip) chip.classList.add('active'); + const tbtn=$('titlebarProfileBtn'); + if(tbtn && _profileDropdownTrigger===tbtn) tbtn.classList.add('active'); +} async function _profileSwitchPanelLoad(){ // Cross-profile cron visibility is an active-profile opt-in; never carry it @@ -5983,6 +6072,7 @@ async function loadProfilesPanel() { try { const data = await api('/api/profiles'); _profilesCache = data; + _profileDropdownWriteStoredCache(data); panel.innerHTML = ''; // Hide "New profile" button in single profile mode @@ -6188,10 +6278,11 @@ async function deleteCurrentProfile(){ } function renderProfileDropdown(data) { + data = data || {}; const dd = $('profileDropdown'); if (!dd) return; dd.innerHTML = ''; - const allProfiles = data.profiles || []; + const allProfiles = (Array.isArray(data.profiles) ? data.profiles : []).filter(p => p && typeof p.name === 'string'); const active = (S.activeProfile && allProfiles.some(p => p.name === S.activeProfile)) ? S.activeProfile : (data.active || 'default'); @@ -6235,24 +6326,39 @@ function toggleProfileDropdown(e) { if(typeof closeModelDropdown==='function') closeModelDropdown(); // Track which element triggered the dropdown for positioning _profileDropdownTrigger = (e && e.currentTarget) || $('profileChip'); - api('/api/profiles').then(data => { + const openGen = ++_profileDropdownOpenGeneration; + const cached = _profileDropdownBestCachedData(); + + if(cached && !cached.single_profile_mode){ + renderProfileDropdown(cached); + _openProfileDropdownShell(); + }else{ + _renderProfileDropdownLoading(); + _openProfileDropdownShell(); + } + + _profileDropdownFetchFresh().then(data => { + if(openGen !== _profileDropdownOpenGeneration) return; // In single profile mode, don't show profile dropdown at all if (data.single_profile_mode) { closeProfileDropdown(); return; } renderProfileDropdown(data); - dd.classList.add('open'); - _positionProfileDropdown(); - // Mark the triggering button as active - const chip=$('profileChip'); - if(chip && _profileDropdownTrigger===chip) chip.classList.add('active'); - const tbtn=$('titlebarProfileBtn'); - if(tbtn && _profileDropdownTrigger===tbtn) tbtn.classList.add('active'); - }).catch(e => { showToast(t('profiles_load_failed')); }); + _openProfileDropdownShell(); + }).catch(e => { + if(openGen !== _profileDropdownOpenGeneration) return; + if(cached && !cached.single_profile_mode){ + // Keep the cached menu open; the next click/background refresh will retry. + return; + } + closeProfileDropdown(); + showToast(t('profiles_load_failed')); + }); } function closeProfileDropdown() { + _profileDropdownOpenGeneration++; const dd = $('profileDropdown'); if (dd) dd.classList.remove('open'); const chip=$('profileChip'); diff --git a/tests/test_issue3623_profile_visibility.py b/tests/test_issue3623_profile_visibility.py index 7a86bbe9..e6838d0b 100644 --- a/tests/test_issue3623_profile_visibility.py +++ b/tests/test_issue3623_profile_visibility.py @@ -107,7 +107,8 @@ def _function_body(src: str, signature: str) -> str: def test_profile_dropdown_filters_hidden_profiles_but_preserves_active(): body = _function_body(_panels_js(), "function renderProfileDropdown(data)") - assert "const allProfiles = data.profiles || [];" in body + assert "data = data || {};" in body + assert "const allProfiles = (Array.isArray(data.profiles) ? data.profiles : []).filter(p => p && typeof p.name === 'string');" in body assert "allProfiles.some(p => p.name === S.activeProfile)" in body assert "const profiles = allProfiles.filter(p => p && (p.visible !== false || p.name === active));" in body diff --git a/tests/test_profile_dropdown_fast_open.py b/tests/test_profile_dropdown_fast_open.py new file mode 100644 index 00000000..751cf1ed --- /dev/null +++ b/tests/test_profile_dropdown_fast_open.py @@ -0,0 +1,267 @@ +"""Regression guards for fast profile dropdown opening. + +The user-visible failure was: clicking the composer/titlebar profile chip waited +for a cold /api/profiles request before showing the menu. On machines where the +profile metadata scan is slow, that made the click feel frozen for seconds. +""" +import json +import subprocess +import textwrap +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent.resolve() +PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8") + + +def _function_body(src: str, marker: str, next_marker: str | None = None) -> str: + start = src.index(marker) + if next_marker is not None: + end = src.index(next_marker, start) + return src[start:end] + depth = 0 + opened = False + for idx, ch in enumerate(src[start:], start): + if ch == "{": + depth += 1 + opened = True + elif ch == "}": + depth -= 1 + if opened and depth == 0: + return src[start : idx + 1] + raise AssertionError(f"Could not extract function body for {marker}") + + +def test_profile_dropdown_opens_shell_before_network_fetch(): + body = _function_body(PANELS_JS, "function toggleProfileDropdown(e) {") + cache_idx = body.index("const cached = _profileDropdownBestCachedData();") + open_idx = body.index("_openProfileDropdownShell();") + fetch_idx = body.index("_profileDropdownFetchFresh().then") + assert cache_idx < open_idx < fetch_idx, ( + "toggleProfileDropdown must render/open from cache or loading shell before " + "awaiting the slow /api/profiles refresh" + ) + + +def test_profile_dropdown_uses_shared_fetch_promise_and_validated_local_storage_cache(): + data_cache_body = _function_body(PANELS_JS, "function _profileDropdownDataCacheUsable(data){") + assert "Array.isArray(data.profiles)" in data_cache_body + assert "data.profiles.every(p=>p&&typeof p.name==='string')" in data_cache_body + + cache_body = _function_body(PANELS_JS, "function _profileDropdownCacheUsable(data){") + assert "_profileDropdownDataCacheUsable(data)" in cache_body + assert "data.single_profile_mode !== true" in cache_body + + fetch_body = _function_body(PANELS_JS, "function _profileDropdownFetchFresh(){") + assert "if(_profileDropdownFetchPromise) return _profileDropdownFetchPromise;" in fetch_body + assert "api('/api/profiles', {timeoutToast:false})" in fetch_body + assert "if(_profileDropdownDataCacheUsable(data)) _profilesCache = data;" in fetch_body + assert "_profileDropdownWriteStoredCache(data);" in fetch_body + assert "\n _profilesCache = data;\n" not in fetch_body + + best_body = _function_body(PANELS_JS, "function _profileDropdownBestCachedData(){") + assert "if(_profileDropdownDataCacheUsable(_profilesCache)) return null;" in best_body + assert best_body.index("if(_profileDropdownDataCacheUsable(_profilesCache)) return null;") < best_body.index("_profilesCache = null;") + + read_body = _function_body(PANELS_JS, "function _profileDropdownReadStoredCache(){") + assert "localStorage.getItem(PROFILE_DROPDOWN_CACHE_KEY)" in read_body + assert "PROFILE_DROPDOWN_CACHE_TTL_MS" in read_body + assert "_profileDropdownClearStoredCache();" in read_body + + write_body = _function_body(PANELS_JS, "function _profileDropdownWriteStoredCache(data){") + assert "if(!_profileDropdownCacheUsable(data)) { _profileDropdownClearStoredCache(); return; }" in write_body + + +def test_profile_dropdown_closing_invalidates_inflight_refresh(): + close_body = _function_body(PANELS_JS, "function closeProfileDropdown() {") + assert "_profileDropdownOpenGeneration++;" in close_body + + toggle_body = _function_body(PANELS_JS, "function toggleProfileDropdown(e) {") + assert "const openGen = ++_profileDropdownOpenGeneration;" in toggle_body + assert "if(openGen !== _profileDropdownOpenGeneration) return;" in toggle_body + + +def test_profiles_panel_refresh_updates_dropdown_cache(): + body = _function_body(PANELS_JS, "async function loadProfilesPanel() {") + api_idx = body.index("const data = await api('/api/profiles');") + cache_idx = body.index("_profileDropdownWriteStoredCache(data);") + render_idx = body.index("panel.innerHTML = '';") + assert api_idx < cache_idx < render_idx + + +def test_profile_dropdown_prefetches_after_page_load(): + assert "function _warmProfileDropdownCache(){" in PANELS_JS + assert "window.addEventListener('load'" in PANELS_JS + assert "_warmProfileDropdownCache();" in PANELS_JS + + +def test_poisoned_profile_cache_opens_then_switches_after_fresh_refresh(): + snippets = [ + PANELS_JS[ + PANELS_JS.index("let _profilesCache = null;") : PANELS_JS.index("async function _profileSwitchPanelLoad(){") + ], + _function_body(PANELS_JS, "function renderProfileDropdown(data) {"), + _function_body(PANELS_JS, "function toggleProfileDropdown(e) {"), + _function_body(PANELS_JS, "function closeProfileDropdown() {"), + ] + script = textwrap.dedent( + f""" + const assert = require('assert'); + const snippets = {json.dumps(snippets)}; + + class ClassList {{ + constructor() {{ this.values = new Set(); }} + add(name) {{ this.values.add(name); }} + remove(name) {{ this.values.delete(name); }} + contains(name) {{ return this.values.has(name); }} + }} + class Element {{ + constructor(tag, id) {{ + this.tagName = tag; + this.id = id || ''; + this.children = []; + this.className = ''; + this.classList = new ClassList(); + this.dataset = {{}}; + this.style = {{}}; + this.onclick = null; + this.textContent = ''; + this._innerHTML = ''; + }} + set innerHTML(value) {{ + this._innerHTML = String(value || ''); + if (this._innerHTML === '') this.children = []; + }} + get innerHTML() {{ return this._innerHTML; }} + appendChild(child) {{ this.children.push(child); return child; }} + }} + const elements = new Map(); + for (const id of ['profileDropdown', 'profileChip', 'titlebarProfileBtn', 'titlebarProfileLabel']) {{ + elements.set(id, new Element('div', id)); + }} + globalThis.document = {{ + createElement: (tag) => new Element(tag), + getElementById: (id) => elements.get(id) || null, + addEventListener: () => {{}}, + }}; + globalThis.window = {{ addEventListener: () => {{}} }}; + const store = new Map(); + globalThis.localStorage = {{ + getItem: (key) => store.has(key) ? store.get(key) : null, + setItem: (key, value) => store.set(key, String(value)), + removeItem: (key) => store.delete(key), + }}; + globalThis.$ = (id) => elements.get(id) || null; + globalThis.S = {{ activeProfile: 'default' }}; + globalThis.t = (key, n) => key === 'profile_skill_count' ? `${{n}} skills` : key; + globalThis.esc = (value) => String(value == null ? '' : value) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + globalThis.li = () => ''; + globalThis.closeWsDropdown = () => {{}}; + globalThis.closeModelDropdown = () => {{}}; + globalThis._positionProfileDropdown = () => {{}}; + globalThis.showToast = () => {{}}; + let switchedTo = null; + globalThis.switchToProfile = async (name) => {{ switchedTo = name; S.activeProfile = name; }}; + const multiProfileResponse = {{ + active: 'default', + single_profile_mode: false, + profiles: [ + {{ name: 'default', visible: true, is_default: true }}, + {{ name: 'other', visible: true }}, + ], + }}; + let apiResponse = multiProfileResponse; + globalThis.api = () => Promise.resolve(apiResponse); + + eval(snippets.join(String.fromCharCode(10)) + String.fromCharCode(10) + `;globalThis.__profileTest={{ + key: PROFILE_DROPDOWN_CACHE_KEY, + reset() {{ + _profilesCache = null; + _profileDropdownFetchPromise = null; + _profileDropdownCacheLoadedFromStorage = false; + _profileDropdownOpenGeneration = 0; + switchedTo = null; + S.activeProfile = 'default'; + const dd = document.getElementById('profileDropdown'); + dd.children = []; + dd.innerHTML = ''; + dd.classList.remove('open'); + }}, + usable: _profileDropdownCacheUsable, + dataUsable: _profileDropdownDataCacheUsable, + setApiResponse(data) {{ apiResponse = data; }}, + setCache(data) {{ _profilesCache = data; }}, + cache: () => _profilesCache, + profileDetailBacked(name) {{ + return !!(_profilesCache && _profilesCache.profiles && _profilesCache.profiles.find(x => x.name === name)); + }}, + toggle: toggleProfileDropdown, + close: closeProfileDropdown, + switchedTo: () => switchedTo, + }};`); + + async function runPoisonedCase(profiles) {{ + __profileTest.reset(); + __profileTest.setApiResponse(multiProfileResponse); + localStorage.setItem(__profileTest.key, JSON.stringify({{ ts: Date.now(), data: {{ active: 'default', profiles }} }})); + assert.strictEqual(__profileTest.usable({{ active: 'default', profiles }}), false); + assert.doesNotThrow(() => __profileTest.toggle({{ currentTarget: document.getElementById('profileChip') }})); + const dd = document.getElementById('profileDropdown'); + assert.strictEqual(dd.classList.contains('open'), true, 'dropdown shell should open from loading state'); + assert.strictEqual(localStorage.getItem(__profileTest.key), null, 'invalid stored cache should be removed before refresh'); + await new Promise((resolve) => setImmediate(resolve)); + const profileOptions = dd.children.filter((child) => String(child.className).includes('profile-opt')); + const other = profileOptions.find((child) => child.innerHTML.includes('other')); + assert(other, 'fresh refresh should render the valid profile option'); + await other.onclick(); + assert.strictEqual(__profileTest.switchedTo(), 'other'); + }} + + async function runSingleProfilePreservesSharedCache() {{ + __profileTest.reset(); + const singleProfileResponse = {{ + active: 'default', + single_profile_mode: true, + profiles: [{{ name: 'default', visible: true, is_default: true }}], + }}; + __profileTest.setApiResponse(singleProfileResponse); + __profileTest.setCache(singleProfileResponse); + assert.strictEqual(__profileTest.dataUsable(singleProfileResponse), true); + assert.strictEqual(__profileTest.usable(singleProfileResponse), false); + assert.strictEqual(__profileTest.profileDetailBacked('default'), true, 'single-profile cache should back Profiles panel before click'); + assert.doesNotThrow(() => __profileTest.toggle({{ currentTarget: document.getElementById('profileChip') }})); + assert.strictEqual(__profileTest.profileDetailBacked('default'), true, 'dropdown open must not null the Profiles panel cache synchronously'); + assert.strictEqual(__profileTest.cache(), singleProfileResponse); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual(__profileTest.profileDetailBacked('default'), true, 'fresh single-profile response must keep Profiles panel data'); + assert.strictEqual(__profileTest.cache().single_profile_mode, true); + }} + + async function runFreshSingleProfileReplacesStaleMultiCache() {{ + __profileTest.reset(); + const staleMultiProfileResponse = multiProfileResponse; + const singleProfileResponse = {{ + active: 'default', + single_profile_mode: true, + profiles: [{{ name: 'default', visible: true, is_default: true }}], + }}; + __profileTest.setCache(staleMultiProfileResponse); + __profileTest.setApiResponse(singleProfileResponse); + assert.strictEqual(__profileTest.usable(staleMultiProfileResponse), true); + assert.doesNotThrow(() => __profileTest.toggle({{ currentTarget: document.getElementById('profileChip') }})); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual(__profileTest.cache(), singleProfileResponse, 'fresh single-profile payload should replace stale multi-profile memory cache'); + assert.strictEqual(__profileTest.profileDetailBacked('default'), true); + assert.strictEqual(localStorage.getItem(__profileTest.key), null, 'single-profile payload should not be persisted as dropdown cache'); + }} + + (async () => {{ + await runPoisonedCase([null]); + await runPoisonedCase([{{}}]); + await runSingleProfilePreservesSharedCache(); + await runFreshSingleProfileReplacesStaleMultiCache(); + }})().catch((err) => {{ console.error(err && err.stack || err); process.exit(1); }}); + """ + ) + subprocess.run(["node", "-e", script], cwd=REPO_ROOT, check=True, text=True, capture_output=True)