From fd7212b01436322950538e7b363ce1a88218a4b3 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Wed, 20 May 2026 06:55:17 -0600 Subject: [PATCH 1/5] Optimize profile switching and session list loading --- CHANGELOG.md | 3 + api/config.py | 6 ++ api/profiles.py | 3 + static/boot.js | 47 ++++++++++-- static/panels.js | 91 +++++++++++++++-------- static/sessions.js | 71 +++++++++++++++++- static/style.css | 10 ++- static/ui.js | 1 + tests/test_issue1743_model_picker_race.py | 1 + tests/test_profile_switch_ux.py | 67 ++++++++++++++--- tests/test_session_metadata_fast_path.py | 43 ++++++++++- tests/test_sprint40_ui_polish.py | 30 ++++---- 12 files changed, 303 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 185f15f18e5..c3d462de4f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Changed + +- Make chat profile switching feel immediate by applying returned profile defaults and refreshing the visible session list before clearing the switch animation, while refreshing workspace metadata in the background and loading the model catalog lazily when the picker opens. The session list now eases in on profile changes and first app load instead of appearing abruptly, the first sidebar fetch no longer waits for workspace/onboarding metadata, and completed installs skip the extra onboarding status check on boot. ## [v0.51.95] — 2026-05-20 — Release BS (stage-388 — 5-PR batch — live tool callback event dedup + browser-only dashboard links + messaging transcript merge alignment + Geist Contrast skin + SSE runtime diagnostics) diff --git a/api/config.py b/api/config.py index 9edc12bb98d..aec0991ba3e 100644 --- a/api/config.py +++ b/api/config.py @@ -4366,6 +4366,12 @@ def load_settings() -> dict: stored.get("skin") if isinstance(stored, dict) else settings.get("skin"), ) settings["default_model"] = get_effective_default_model() + try: + model_cfg = get_config().get("model", {}) + if isinstance(model_cfg, dict) and model_cfg.get("provider"): + settings["default_model_provider"] = str(model_cfg.get("provider")) + except Exception: + logger.debug("Failed to resolve default model provider for settings") return settings diff --git a/api/profiles.py b/api/profiles.py index 763654e9c03..6cd7a5bd165 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -914,10 +914,12 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: cfg = {} model_cfg = cfg.get('model', {}) default_model = None + default_model_provider = None if isinstance(model_cfg, str): default_model = model_cfg elif isinstance(model_cfg, dict): default_model = model_cfg.get('default') + default_model_provider = model_cfg.get('provider') # Read the target profile's workspace directly from *home* rather than via # get_last_workspace() which routes through the thread-local/process-global active @@ -969,6 +971,7 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: 'profiles': list_profiles_api(), 'active': name, 'default_model': default_model, + 'default_model_provider': default_model_provider, 'default_workspace': default_workspace, } diff --git a/static/boot.js b/static/boot.js index 24fd34bebf7..ae24cd5a936 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1421,7 +1421,30 @@ function applyBotName(){ window._busyInputMode=(s.busy_input_mode||'queue'); window._sessionEndlessScrollEnabled=!!s.session_endless_scroll; window._botName=s.bot_name||'Hermes'; - if(s.default_model) window._defaultModel=s.default_model; + if(s.default_model_provider) window._activeProvider=s.default_model_provider; + if(s.default_model){ + window._defaultModel=s.default_model; + const sel=$('modelSelect'); + const savedState=(typeof _readPersistedModelState==='function') + ? _readPersistedModelState() + : (localStorage.getItem('hermes-webui-model')?{model:localStorage.getItem('hermes-webui-model'),model_provider:null}:null); + if(sel&&!savedState&&typeof _applyModelToDropdown==='function'){ + const existingDefaultOpt=Array.from(sel.options).find(o=>o.value===s.default_model); + if(existingDefaultOpt&&window._activeProvider&&!existingDefaultOpt.dataset.provider){ + existingDefaultOpt.dataset.provider=window._activeProvider; + } + if(!existingDefaultOpt){ + const opt=document.createElement('option'); + opt.value=s.default_model; + opt.textContent=typeof getModelLabel==='function'?getModelLabel(s.default_model):s.default_model; + opt.dataset.custom='1'; + opt.dataset.provider=window._activeProvider||''; + sel.querySelectorAll('option[data-custom]').forEach(o=>o.remove()); + sel.appendChild(opt); + } + _applyModelToDropdown(s.default_model,sel,window._activeProvider||null); + } + } window._sessionJumpButtonsEnabled=!!s.session_jump_buttons; // Reconcile appearance: prefer localStorage (what the user last saw) over // the server. If they diverge (e.g. a previous autosave POST failed), @@ -1510,7 +1533,7 @@ function applyBotName(){ // Fetch available models without blocking session restore. The static HTML // options are enough for first paint; the dynamic provider list can settle // after the saved session is visible. - const _modelDropdownReady=populateModelDropdown().then(()=>{ + const _hydrateBootModelDropdown=()=>populateModelDropdown().then(()=>{ const savedState=(typeof _readPersistedModelState==='function') ? _readPersistedModelState() : (localStorage.getItem('hermes-webui-model')?{model:localStorage.getItem('hermes-webui-model'),model_provider:null}:null); @@ -1529,13 +1552,25 @@ function applyBotName(){ } if(S.session) syncTopbar(); }).catch(()=>{}); - window._modelDropdownReady=_modelDropdownReady; - // Pre-load workspace list so sidebar name is correct from first render. + const _startBootModelDropdown=()=>{ + const ready=window._modelDropdownReady; + if(ready&&typeof ready.then==='function') return ready; + const next=_hydrateBootModelDropdown(); + window._modelDropdownReady=next; + return next; + }; + window._modelDropdownReady=null; + window._ensureModelDropdownReady=_startBootModelDropdown; + // Start independent boot fetches without holding the conversation list behind + // them. The sidebar can render from /api/sessions while workspace/onboarding + // metadata settles in parallel. + const _workspaceListReady=loadWorkspaceList(); + const _onboardingReady=_bootSettings.onboarding_completed?Promise.resolve(false):loadOnboardingWizard(); // Render the session list before restoring the saved conversation so a stale // saved-session/client-side boot error cannot leave the sidebar empty forever. - await loadWorkspaceList(); - await loadOnboardingWizard(); await renderSessionList(); + await _workspaceListReady; + await _onboardingReady; _initResizePanels(); // Workspace panel restore happens AFTER loadSession so we know if // the session has a workspace — prevents the snap-open-then-closed flash (#576). diff --git a/static/panels.js b/static/panels.js index b40252e7168..3de2b2e1de5 100644 --- a/static/panels.js +++ b/static/panels.js @@ -4488,6 +4488,25 @@ async function switchToWorkspace(path,name){ // ── Profile panel + dropdown ── let _profilesCache = null; +let _profileSwitchGeneration = 0; + +function _profileSwitchPanelLoad(){ + if (_currentPanel === 'skills') return loadSkills(); + if (_currentPanel === 'memory') return loadMemory(); + if (_currentPanel === 'tasks') return loadCrons(); + if (_currentPanel === 'kanban') return loadKanban(); + if (_currentPanel === 'profiles') return loadProfilesPanel(); + if (_currentPanel === 'workspaces') return loadWorkspacesPanel(); + return Promise.resolve(); +} + +function _refreshProfileSwitchBackground(gen){ + window._modelDropdownReady=null; + Promise.resolve(loadWorkspaceList()).then(()=>{ + if (gen !== _profileSwitchGeneration) return; + if (S.session && typeof syncTopbar === 'function') syncTopbar(); + }).catch(()=>{}); +} async function loadProfilesPanel() { const panel = $('profilesPanel'); @@ -4750,6 +4769,7 @@ async function switchToProfile(name) { const _chip = $('profileChip'); const _chipLabel = $('profileChipLabel'); const _prevProfileName = S.activeProfile || 'default'; + const _switchGen = ++_profileSwitchGeneration; if (_chip) { _chip.classList.add('switching'); _chip.disabled = true; } // Optimistic name update — shows the target name right away if (_chipLabel) _chipLabel.textContent = name; @@ -4765,35 +4785,52 @@ async function switchToProfile(name) { try { const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) }); + if (_switchGen !== _profileSwitchGeneration) return; S.activeProfile = data.active || name; // Update composer placeholder and title bar while the core profile-switch // state is still close to the profile API response. if (typeof applyBotName === 'function') applyBotName(); - // ── Model + Workspace (parallelized) ─────────────────────────────────── - // populateModelDropdown hits /api/models; loadWorkspaceList hits /api/workspaces. - // They are fully independent — run both simultaneously to cut switch time ~50%. + // ── Model + Workspace ────────────────────────────────────────────────── + // Apply the profile defaults returned by /api/profile/switch immediately. + // Refreshing the full model/workspace catalogs is useful, but it should not + // hold the visible switch animation open. if(typeof _clearPersistedModelState==='function') _clearPersistedModelState(); else localStorage.removeItem('hermes-webui-model'); _skillsData = null; _workspaceList = null; - await Promise.all([populateModelDropdown(), loadWorkspaceList()]); + if (data.default_model) window._defaultModel = data.default_model; + if (data.default_model_provider) window._activeProvider = data.default_model_provider; // ── Apply model ──────────────────────────────────────────────────────── if (data.default_model) { const sel = $('modelSelect'); - const resolved = _applyModelToDropdown(data.default_model, sel, window._activeProvider||null); + const providerId = data.default_model_provider || window._activeProvider || null; + const existingDefaultOpt = sel ? Array.from(sel.options).find(o => o.value === data.default_model) : null; + if (existingDefaultOpt && providerId && !existingDefaultOpt.dataset.provider) { + existingDefaultOpt.dataset.provider = providerId; + } + if (sel && !existingDefaultOpt) { + const opt = document.createElement('option'); + opt.value = data.default_model; + opt.textContent = typeof getModelLabel === 'function' ? getModelLabel(data.default_model) : data.default_model; + opt.dataset.custom = '1'; + if (providerId) opt.dataset.provider = providerId; + sel.querySelectorAll('option[data-custom]').forEach(o => o.remove()); + sel.appendChild(opt); + } + const resolved = _applyModelToDropdown(data.default_model, sel, providerId); const modelToUse = resolved || data.default_model; const modelState = (typeof _modelStateForSelect==='function') ? _modelStateForSelect(sel, modelToUse) - : {model:modelToUse,model_provider:null}; + : {model:modelToUse,model_provider:providerId}; S._pendingProfileModel = modelToUse; - S._pendingProfileModelProvider = modelState.model_provider||null; + S._pendingProfileModelProvider = modelState.model_provider||providerId||null; // Only patch the in-memory session model if we're NOT about to replace the session if (S.session && !sessionInProgress) { S.session.model = modelToUse; - S.session.model_provider = modelState.model_provider||null; + S.session.model_provider = modelState.model_provider||providerId||null; } } @@ -4822,23 +4859,14 @@ async function switchToProfile(name) { // ── Session ──────────────────────────────────────────────────────────── _showAllProfiles = false; + if (typeof animateNextSessionListRefresh === 'function') animateNextSessionListRefresh(); 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. - await newSession(false); - // Apply profile default workspace to the newly created session (fixes #424) - if (S._profileDefaultWorkspace && S.session) { - try { - await api('/api/session/update', { method: 'POST', body: JSON.stringify({ - session_id: S.session.session_id, - workspace: S._profileDefaultWorkspace, - model: S.session.model, - model_provider: S.session.model_provider||null, - })}); - S.session.workspace = S._profileDefaultWorkspace; - } catch (_) {} - } + const workspaceVisible = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed'; + await newSession(false, {awaitWorkspaceLoad: workspaceVisible}); + if (_switchGen !== _profileSwitchGeneration) return; // Keep topbar chips (workspace/profile) in sync after creating the // new profile-scoped session. syncTopbar(); @@ -4847,28 +4875,27 @@ async function switchToProfile(name) { } else { // No messages yet — just refresh the list and topbar in place await renderSessionList(); + if (_switchGen !== _profileSwitchGeneration) return; syncTopbar(); // Refresh workspace file tree so the right panel shows the new // profile's workspace, not the previous one (#1214). - if (S.session && S.session.workspace) loadDir('.'); + if (S.session && S.session.workspace) { + const dirLoad = loadDir('.'); + if (typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed') await dirLoad; + } showToast(t('profile_switched', name)); } - // ── Sidebar panels ───────────────────────────────────────────────────── - if (_currentPanel === 'skills') await loadSkills(); - if (_currentPanel === 'memory') await loadMemory(); - if (_currentPanel === 'tasks') await loadCrons(); - if (_currentPanel === 'kanban') await loadKanban(); - if (_currentPanel === 'profiles') await loadProfilesPanel(); - if (_currentPanel === 'workspaces') await loadWorkspacesPanel(); + await _profileSwitchPanelLoad(); + _refreshProfileSwitchBackground(_switchGen); } catch (e) { // Revert the optimistic name update on error - if (_chipLabel) _chipLabel.textContent = _prevProfileName; - showToast(t('switch_failed') + e.message); + if (_switchGen === _profileSwitchGeneration && _chipLabel) _chipLabel.textContent = _prevProfileName; + if (_switchGen === _profileSwitchGeneration) showToast(t('switch_failed') + e.message); } finally { // Always remove loading indicator regardless of success or failure - if (_chip) { _chip.classList.remove('switching'); _chip.disabled = false; } + if (_switchGen === _profileSwitchGeneration && _chip) { _chip.classList.remove('switching'); _chip.disabled = false; } } } diff --git a/static/sessions.js b/static/sessions.js index fc9ae52bb20..0c9cdd6818d 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -518,7 +518,11 @@ async function newSession(flash, options={}){ }); } updateQueueBadge(S.session.session_id); - syncTopbar();renderMessages();loadDir('.'); + syncTopbar();renderMessages(); + const dirLoad=loadDir('.'); + // Hidden workspace trees should not block new-chat/profile-switch flow. + // When the panel is visible, wait so the file list lands with the session. + if(options&&options.awaitWorkspaceLoad) await dirLoad; // don't call renderSessionList here - callers do it when needed })(); try{ @@ -1914,6 +1918,56 @@ window.addEventListener('resize',()=>{ // concurrently. Without this guard, a slower older response can overwrite _allSessions // with stale data, causing sessions to vanish from the sidebar. let _renderSessionListGen = 0; +let _sessionListRefreshAnimationPending = false; +let _sessionListFirstRenderAnimated = false; +let _sessionListEnterAllAnimationPending = false; + +function animateNextSessionListRefresh(options={}){ + _sessionListRefreshAnimationPending = true; + if(options&&options.enterAll) _sessionListEnterAllAnimationPending = true; +} + +function _captureSessionListFlipPositions(){ + const list=$('sessionList'); + if(!list) return null; + const positions=new Map(); + list.querySelectorAll('.session-item[data-sid]').forEach(row=>{ + positions.set(row.dataset.sid,row.getBoundingClientRect().top); + }); + return positions; +} + +function _sessionListPrefersReducedMotion(){ + try{return window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;} + catch(_){return false;} +} + +function _playSessionListFlipAnimation(before){ + if(!before||!before.size||_sessionListPrefersReducedMotion()) return; + const list=$('sessionList'); + if(!list) return; + list.querySelectorAll('.session-item[data-sid]').forEach(row=>{ + const oldTop=before.get(row.dataset.sid); + if(oldTop===undefined) return; + const delta=oldTop-row.getBoundingClientRect().top; + if(Math.abs(delta)<1) return; + row.style.setProperty('--session-reflow-offset',delta+'px'); + row.classList.add('session-reflowing'); + row.getBoundingClientRect(); + row.style.setProperty('--session-reflow-offset','0px'); + let cleared=false; + const clear=()=>{ + if(cleared) return; + cleared=true; + row.classList.remove('session-reflowing'); + row.style.removeProperty('--session-reflow-offset'); + row.removeEventListener('transitionend',onEnd); + }; + const onEnd=(event)=>{ if(event.propertyName==='transform') clear(); }; + row.addEventListener('transitionend',onEnd); + setTimeout(clear,460); + }); +} function _isOptimisticFirstTurnSessionRow(s){ if(!s||!s.session_id||s.archived) return false; @@ -2018,6 +2072,10 @@ function _applySessionListPayload(sessData, projData){ } ensureSessionTimeRefreshPoll(); ensureActiveSessionExternalRefreshPoll(); + if(!_sessionListFirstRenderAnimated&&Array.isArray(_allSessions)&&_allSessions.length){ + animateNextSessionListRefresh({enterAll:true}); + if(S&&S._bootReady) _sessionListFirstRenderAnimated=true; + } renderSessionListFromCache(); // no-ops if rename is in progress } @@ -2817,6 +2875,11 @@ function renderSessionListFromCache(){ _syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar); const archivedCount=projectFiltered.filter(s=>s.archived).length; const list=$('sessionList'); + const animateRefresh=_sessionListRefreshAnimationPending; + _sessionListRefreshAnimationPending=false; + const enterAllAnimatedRows=animateRefresh&&_sessionListEnterAllAnimationPending; + _sessionListEnterAllAnimationPending=false; + const flipBefore=animateRefresh?_captureSessionListFlipPositions():null; const listScrollTopBeforeRender=list.scrollTop||0; list.innerHTML=''; // Batch select bar (when in select mode) @@ -3055,6 +3118,9 @@ function renderSessionListFromCache(){ toggleBtn.onclick=(e)=>{e.stopPropagation();toggleSessionSelectMode();}; list.appendChild(toggleBtn); } + if(animateRefresh){ + _playSessionListFlipAnimation(flipBefore); + } // Note: declared after the groups loop but available via function hoisting. function _renderOneSession(s, isPinnedGroup=false){ const el=document.createElement('div'); @@ -3065,6 +3131,9 @@ function renderSessionListFromCache(){ const hasUnread=_hasUnreadForSession(s)&&!isActive; const readOnly=_isReadOnlySession(s); el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(isStreaming?' streaming':'')+(hasUnread?' unread':''); + if(animateRefresh&&(enterAllAnimatedRows||!(flipBefore&&flipBefore.has(s.session_id)))){ + el.classList.add('session-list-flip-enter'); + } if(s.is_cli_session){ el.classList.add('cli-session'); el.dataset.source=_getChannelLabel(s)||'CLI'; diff --git a/static/style.css b/static/style.css index 8a84a24d77e..3a3c7b27783 100644 --- a/static/style.css +++ b/static/style.css @@ -655,7 +655,7 @@ and attention indicator (26x26 at right:6px) still need 40px reserved when they're visible — covered by the hover / streaming / unread / menu-open / focus-within rule below. */ - .session-item{padding:8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s;display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;touch-action:manipulation;-webkit-tap-highlight-color:transparent;} + .session-item{padding:8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s,transform .36s cubic-bezier(.2,.8,.2,1);display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;touch-action:manipulation;-webkit-tap-highlight-color:transparent;transform:translateY(var(--session-reflow-offset,0));} .session-item.streaming,.session-item.unread,.session-item:focus-within,.session-item.menu-open{padding-right:40px;} .session-item:hover{background:var(--hover-bg);color:var(--text);} /* Suppress hover highlight during drag to avoid visual noise mid-scroll */ @@ -738,8 +738,16 @@ /* Hide overlay during inline rename */ .session-item:has(.session-title-input) .session-actions{display:none;} @keyframes newflash{0%{background:var(--accent-bg-strong);color:var(--accent);}100%{background:transparent;color:var(--muted);}} + @keyframes sessionListFlipIn{0%{opacity:0;transform:perspective(700px) rotateX(-8deg) translateY(10px) scale(.985);}100%{opacity:1;transform:perspective(700px) rotateX(0) translateY(0) scale(1);}} @keyframes spin{to{transform:rotate(360deg);}} .session-item.new-flash{animation:newflash 1.4s ease-out forwards;} + .session-item.session-reflowing{transition:background .15s,color .15s,transform .36s cubic-bezier(.2,.8,.2,1);will-change:transform;} + .session-item.session-list-flip-enter{animation:sessionListFlipIn .28s cubic-bezier(.2,.8,.2,1) backwards;transform-origin:center top;} + @media (prefers-reduced-motion: reduce){ + .session-item, + .session-item.session-reflowing{transition:background .15s,color .15s;} + .session-item.session-list-flip-enter{animation:none;} + } /* Collapsible date group headers */ .session-date-header{display:flex;align-items:center;gap:5px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 10px 4px;cursor:pointer;user-select:none;opacity:.8;transition:opacity .15s;} .session-date-header:hover{opacity:1;} diff --git a/static/ui.js b/static/ui.js index 76a44fae16b..130189c6618 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1450,6 +1450,7 @@ async function toggleModelDropdown(){ if(typeof closeWsDropdown==='function') closeWsDropdown(); if(typeof closeReasoningDropdown==='function') closeReasoningDropdown(); if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown(); + if(typeof window._ensureModelDropdownReady==='function') window._ensureModelDropdownReady(); const ready=window._modelDropdownReady; if(ready&&typeof ready.then==='function'){ try{await ready;}catch(_){} diff --git a/tests/test_issue1743_model_picker_race.py b/tests/test_issue1743_model_picker_race.py index 17530de1e00..2bd46da9aa7 100644 --- a/tests/test_issue1743_model_picker_race.py +++ b/tests/test_issue1743_model_picker_race.py @@ -17,6 +17,7 @@ def test_model_picker_open_waits_for_async_model_catalog_before_rendering(): body = _body_between(UI_JS, "async function toggleModelDropdown", "function closeModelDropdown") assert "window._modelDropdownReady" in body + assert "window._ensureModelDropdownReady" in body assert "await" in body assert body.index("await") < body.index("renderModelDropdown()") diff --git a/tests/test_profile_switch_ux.py b/tests/test_profile_switch_ux.py index bd7b4fdf3b1..3dab6bebd8c 100644 --- a/tests/test_profile_switch_ux.py +++ b/tests/test_profile_switch_ux.py @@ -4,8 +4,8 @@ Two changes: 1. switchToProfile() shows a spinner on the profile chip during the async switch, with an optimistic name update and error revert. -2. populateModelDropdown() and loadWorkspaceList() are now parallelized via Promise.all - instead of sequential awaits. +2. loadWorkspaceList() refreshes in the background and the model catalog is + invalidated for lazy refresh instead of holding the visible switch animation open. """ import re from pathlib import Path @@ -79,7 +79,7 @@ def test_error_reverts_chip_label_to_previous_name(self): class TestParallelizedFetches: - """Verify that model and workspace fetches are parallelized.""" + """Verify that background refresh work does not block visible profile switching.""" JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8") @@ -95,11 +95,15 @@ def _get_switch_fn(self): return self.JS[idx: i + 1] raise AssertionError("Could not extract switchToProfile") - def test_populate_and_workspace_in_promise_all(self): - """Both fetches must be inside Promise.all([...]).""" + def test_workspace_refresh_in_background_and_model_catalog_lazy(self): + """Workspace refresh should run behind completion; model catalog waits for picker open.""" fn = self._get_switch_fn() - assert "Promise.all([populateModelDropdown(), loadWorkspaceList()])" in fn, ( - "populateModelDropdown() and loadWorkspaceList() are not parallelized." + assert "_refreshProfileSwitchBackground(_switchGen)" in fn, ( + "switchToProfile() must schedule non-visible refreshes after the switch." + ) + assert "window._modelDropdownReady=null" in self.JS + assert "await Promise.all([populateModelDropdown(), loadWorkspaceList()])" not in fn, ( + "Profile switching still awaits full model/workspace catalog refreshes." ) def test_no_sequential_await_pattern(self): @@ -114,14 +118,24 @@ def test_no_sequential_await_pattern(self): ) def test_apply_steps_after_promise_all(self): - """Model apply step must come after Promise.all resolves.""" + """Model defaults must apply before background catalog refresh starts.""" fn = self._get_switch_fn() - promise_all_idx = fn.find("await Promise.all(") + background_idx = fn.find("_refreshProfileSwitchBackground(_switchGen)") apply_model_idx = fn.find("S._pendingProfileModel = modelToUse") assert apply_model_idx != -1 - assert apply_model_idx > promise_all_idx, ( - "Model apply step must come AFTER Promise.all resolves." + assert background_idx != -1 + assert apply_model_idx < background_idx, ( + "Model defaults must apply before background refresh starts." ) + assert "existingDefaultOpt.dataset.provider = providerId" in fn + + def test_workspace_load_is_awaited_only_when_visible(self): + """Profile switches should not duplicate workspace-tree loads.""" + fn = self._get_switch_fn() + assert "awaitWorkspaceLoad: workspaceVisible" in fn + sessions_js = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + assert "if(options&&options.awaitWorkspaceLoad) await dirLoad" in sessions_js + assert "Hidden workspace trees should not block" in sessions_js class TestSpinnerCss: @@ -143,3 +157,34 @@ def test_switching_class_has_pointer_events_none(self): assert idx != -1 block = self.CSS[idx: idx + 200] assert "pointer-events:none" in block + + +class TestProfileSessionListFlip: + """Verify session-list refreshes use row-level FLIP motion.""" + + JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") + + def test_profile_refresh_captures_row_positions(self): + assert "function _captureSessionListFlipPositions()" in self.JS + assert "getBoundingClientRect().top" in self.JS + assert "querySelectorAll('.session-item[data-sid]')" in self.JS + + def test_profile_refresh_reflows_existing_rows(self): + assert "function _playSessionListFlipAnimation(before)" in self.JS + assert "--session-reflow-offset" in self.JS + assert "session-reflowing" in self.JS + + def test_profile_refresh_flips_new_rows(self): + assert "session-list-flip-enter" in self.JS + assert "@keyframes sessionListFlipIn" in self.CSS + assert "rotateX" in self.CSS + + def test_first_non_empty_session_render_is_animated(self): + assert "_sessionListFirstRenderAnimated" in self.JS + assert "animateNextSessionListRefresh({enterAll:true});" in self.JS + assert "if(S&&S._bootReady) _sessionListFirstRenderAnimated=true;" in self.JS + assert "enterAllAnimatedRows" in self.JS + + def test_profile_refresh_is_not_whole_list_fade(self): + assert "session-list.profile-refresh" not in self.CSS diff --git a/tests/test_session_metadata_fast_path.py b/tests/test_session_metadata_fast_path.py index b3c3bacc74d..645e89eb056 100644 --- a/tests/test_session_metadata_fast_path.py +++ b/tests/test_session_metadata_fast_path.py @@ -60,7 +60,44 @@ def test_deferred_model_resolution_refreshes_context_metadata(): def test_boot_does_not_block_session_restore_on_model_catalog(): src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") - assert "if(s.default_model) window._defaultModel=s.default_model;" in src - assert "const _modelDropdownReady=populateModelDropdown().then" in src - assert "window._modelDropdownReady=_modelDropdownReady" in src + assert "if(s.default_model){" in src + assert "window._defaultModel=s.default_model;" in src + assert "const _hydrateBootModelDropdown=()=>populateModelDropdown().then" in src + assert "window._modelDropdownReady=null;" in src + assert "window._ensureModelDropdownReady=_startBootModelDropdown;" in src assert "await populateModelDropdown()" not in src + + +def test_boot_primes_visible_default_model_without_catalog_fetch(): + src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") + default_block_start = src.index("if(s.default_model){") + default_block = src[default_block_start:src.index("window._sessionJumpButtonsEnabled", default_block_start)] + + assert "if(s.default_model_provider) window._activeProvider=s.default_model_provider;" in src + assert "const existingDefaultOpt=Array.from(sel.options).find(o=>o.value===s.default_model);" in default_block + assert "existingDefaultOpt.dataset.provider=window._activeProvider;" in default_block + assert "if(!existingDefaultOpt)" in default_block + assert "opt.dataset.custom='1'" in default_block + assert "opt.dataset.provider=window._activeProvider||''" in default_block + assert "_applyModelToDropdown(s.default_model,sel,window._activeProvider||null)" in default_block + assert "populateModelDropdown()" not in default_block + + +def test_settings_exposes_default_model_provider_for_lazy_boot_catalog(): + src = (ROOT / "api" / "config.py").read_text(encoding="utf-8") + + assert 'settings["default_model_provider"]' in src + assert 'model_cfg = get_config().get("model", {})' in src + + +def test_boot_renders_session_list_before_workspace_and_onboarding_settle(): + src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") + workspace_start = src.index("const _workspaceListReady=loadWorkspaceList();") + onboarding_start = src.index("const _onboardingReady=_bootSettings.onboarding_completed?Promise.resolve(false):loadOnboardingWizard();") + render_pos = src.index("await renderSessionList();", onboarding_start) + workspace_await = src.index("await _workspaceListReady;", render_pos) + onboarding_await = src.index("await _onboardingReady;", render_pos) + + assert workspace_start < render_pos < workspace_await + assert onboarding_start < render_pos < onboarding_await + assert "_bootSettings.onboarding_completed" in src diff --git a/tests/test_sprint40_ui_polish.py b/tests/test_sprint40_ui_polish.py index 8ad3a632eb4..6f26aad7f43 100644 --- a/tests/test_sprint40_ui_polish.py +++ b/tests/test_sprint40_ui_polish.py @@ -219,12 +219,12 @@ def test_topbar_synced_after_profile_switch(self): # Slice from that point to cover the relevant block block = PANELS_JS[idx:idx + 1000] - # newSession(false) must be called first - self.assertIn('await newSession(false)', block, - "sessionInProgress branch must call await newSession(false)") + # newSession(false, ...) must be called first + self.assertIn('await newSession(false', block, + "sessionInProgress branch must call await newSession(false, ...)") - # The fix: syncTopbar() must be called after newSession(false) - pos_new_session = block.find('await newSession(false)') + # The fix: syncTopbar() must be called after newSession(false, ...) + pos_new_session = block.find('await newSession(false') pos_sync_topbar = block.find('syncTopbar()') self.assertGreater(pos_sync_topbar, -1, "syncTopbar() must be called in the sessionInProgress branch") @@ -232,27 +232,25 @@ def test_topbar_synced_after_profile_switch(self): "syncTopbar() must be called AFTER newSession(false)") def test_profile_default_workspace_applied_to_new_session(self): - """After newSession(false) the code must assign S._profileDefaultWorkspace - to S.session.workspace so the session is correctly tagged.""" + """newSession(false) should apply the pending profile workspace itself.""" idx = PANELS_JS.find('if (sessionInProgress)') self.assertGreater(idx, -1) block = PANELS_JS[idx:idx + 1000] - # The fix block must set S.session.workspace from S._profileDefaultWorkspace - self.assertIn('S.session.workspace = S._profileDefaultWorkspace', block, - "S.session.workspace must be set from S._profileDefaultWorkspace " - "in the sessionInProgress branch after newSession(false)") + self.assertIn('await newSession(false', block) + self.assertNotIn('/api/session/update', block, + "sessionInProgress should not post a duplicate workspace update " + "after newSession(false)") def test_api_session_update_called_for_new_session_workspace(self): - """The fix must call /api/session/update to persist the workspace on the server.""" + """The profile switch path should avoid duplicate workspace persistence.""" idx = PANELS_JS.find('if (sessionInProgress)') self.assertGreater(idx, -1) block = PANELS_JS[idx:idx + 1000] - # Must patch the session on the backend too - self.assertIn('/api/session/update', block, - "The sessionInProgress branch must call /api/session/update " - "to persist the new workspace after newSession(false)") + self.assertNotIn('/api/session/update', block, + "newSession(false) receives S._profileSwitchWorkspace, so " + "a second /api/session/update is unnecessary") def test_sync_topbar_before_render_session_list(self): """syncTopbar() should be called before renderSessionList() From 439a574cf4004829ed008fea7673498b3a9f470e Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Wed, 20 May 2026 09:59:36 -0600 Subject: [PATCH 2/5] address PR comments & bugs from real testing --- static/panels.js | 3 +++ static/sessions.js | 5 +++-- tests/test_profile_switch_ux.py | 25 ++++++++++++++++++++----- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/static/panels.js b/static/panels.js index 3de2b2e1de5..ac5183028ed 100644 --- a/static/panels.js +++ b/static/panels.js @@ -4502,6 +4502,9 @@ function _profileSwitchPanelLoad(){ function _refreshProfileSwitchBackground(gen){ window._modelDropdownReady=null; + if (typeof window._ensureModelDropdownReady === 'function') { + Promise.resolve(window._ensureModelDropdownReady()).catch(()=>{}); + } Promise.resolve(loadWorkspaceList()).then(()=>{ if (gen !== _profileSwitchGeneration) return; if (S.session && typeof syncTopbar === 'function') syncTopbar(); diff --git a/static/sessions.js b/static/sessions.js index 0c9cdd6818d..503b5bbeb57 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -520,8 +520,9 @@ async function newSession(flash, options={}){ updateQueueBadge(S.session.session_id); syncTopbar();renderMessages(); const dirLoad=loadDir('.'); - // Hidden workspace trees should not block new-chat/profile-switch flow. - // When the panel is visible, wait so the file list lands with the session. + // loadDir('.') is fire-and-forget while the workspace panel is closed: + // waiting would block new-chat/profile-switch flow for users who never open + // the file tree. When visible, wait so the file list lands with the session. if(options&&options.awaitWorkspaceLoad) await dirLoad; // don't call renderSessionList here - callers do it when needed })(); diff --git a/tests/test_profile_switch_ux.py b/tests/test_profile_switch_ux.py index 3dab6bebd8c..b7d8b99519b 100644 --- a/tests/test_profile_switch_ux.py +++ b/tests/test_profile_switch_ux.py @@ -135,7 +135,7 @@ def test_workspace_load_is_awaited_only_when_visible(self): assert "awaitWorkspaceLoad: workspaceVisible" in fn sessions_js = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") assert "if(options&&options.awaitWorkspaceLoad) await dirLoad" in sessions_js - assert "Hidden workspace trees should not block" in sessions_js + assert "loadDir('.') is fire-and-forget while the workspace panel is closed" in sessions_js class TestSpinnerCss: @@ -167,19 +167,34 @@ class TestProfileSessionListFlip: def test_profile_refresh_captures_row_positions(self): assert "function _captureSessionListFlipPositions()" in self.JS - assert "getBoundingClientRect().top" in self.JS - assert "querySelectorAll('.session-item[data-sid]')" in self.JS + start = self.JS.index("function _captureSessionListFlipPositions()") + end = self.JS.index("function _sessionListPrefersReducedMotion()", start) + fn = self.JS[start:end] + assert "querySelectorAll('.session-item[data-sid]')" in fn + assert "positions.set(row.dataset.sid,row.getBoundingClientRect().top);" in fn def test_profile_refresh_reflows_existing_rows(self): assert "function _playSessionListFlipAnimation(before)" in self.JS - assert "--session-reflow-offset" in self.JS - assert "session-reflowing" in self.JS + start = self.JS.index("function _playSessionListFlipAnimation(before)") + end = self.JS.index("function _isOptimisticFirstTurnSessionRow(s)", start) + fn = self.JS[start:end] + assert "const delta=oldTop-row.getBoundingClientRect().top;" in fn + assert "row.style.setProperty('--session-reflow-offset',delta+'px');" in fn + assert "row.classList.add('session-reflowing');" in fn def test_profile_refresh_flips_new_rows(self): assert "session-list-flip-enter" in self.JS assert "@keyframes sessionListFlipIn" in self.CSS assert "rotateX" in self.CSS + def test_profile_refresh_captures_before_render_and_plays_after_rows_exist(self): + capture = self.JS.index("const flipBefore=animateRefresh?_captureSessionListFlipPositions():null;") + clear = self.JS.index("list.innerHTML='';", capture) + row_render = self.JS.index("body.appendChild(_renderOneSession", clear) + play = self.JS.index("_playSessionListFlipAnimation(flipBefore);", row_render) + + assert capture < clear < row_render < play + def test_first_non_empty_session_render_is_animated(self): assert "_sessionListFirstRenderAnimated" in self.JS assert "animateNextSessionListRefresh({enterAll:true});" in self.JS From dc57d358eb593d62e25efc8ba5e72b2744ce2068 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Wed, 20 May 2026 10:02:46 -0600 Subject: [PATCH 3/5] fix test --- static/panels.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/static/panels.js b/static/panels.js index ac5183028ed..75353a22af7 100644 --- a/static/panels.js +++ b/static/panels.js @@ -4490,14 +4490,13 @@ async function switchToWorkspace(path,name){ let _profilesCache = null; let _profileSwitchGeneration = 0; -function _profileSwitchPanelLoad(){ - if (_currentPanel === 'skills') return loadSkills(); - if (_currentPanel === 'memory') return loadMemory(); - if (_currentPanel === 'tasks') return loadCrons(); - if (_currentPanel === 'kanban') return loadKanban(); - if (_currentPanel === 'profiles') return loadProfilesPanel(); - if (_currentPanel === 'workspaces') return loadWorkspacesPanel(); - return Promise.resolve(); +async function _profileSwitchPanelLoad(){ + if (_currentPanel === 'skills') await loadSkills(); + if (_currentPanel === 'memory') await loadMemory(); + if (_currentPanel === 'tasks') await loadCrons(); + if (_currentPanel === 'kanban') await loadKanban(); + if (_currentPanel === 'profiles') await loadProfilesPanel(); + if (_currentPanel === 'workspaces') await loadWorkspacesPanel(); } function _refreshProfileSwitchBackground(gen){ From 19ad20afff4317394f4ab2e09e4a746f8b6be8e2 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Wed, 20 May 2026 10:57:04 -0600 Subject: [PATCH 4/5] Fix new chats using profile default model --- api/models.py | 31 +++++++++++++++++++++++++++++-- static/sessions.js | 16 ---------------- tests/test_issue798.py | 24 ++++++++++++++++++++++++ tests/test_provider_mismatch.py | 9 ++++++--- 4 files changed, 59 insertions(+), 21 deletions(-) diff --git a/api/models.py b/api/models.py index 233af2003f4..8ab527eb2ba 100644 --- a/api/models.py +++ b/api/models.py @@ -1340,6 +1340,27 @@ def get_session(sid, metadata_only=False): return s raise KeyError(sid) +def _profile_default_model_state(profile=None): + """Return the default model/provider configured for *profile*.""" + default_model = "" + default_provider = None + try: + from api.profiles import get_hermes_home_for_profile + config_path = Path(get_hermes_home_for_profile(profile)) / "config.yaml" + config_data = _cfg._load_yaml_config_file(config_path) + except Exception: + config_data = {} + + model_cfg = config_data.get("model", {}) if isinstance(config_data, dict) else {} + if isinstance(model_cfg, str): + default_model = model_cfg.strip() + elif isinstance(model_cfg, dict): + default_model = str(model_cfg.get("default") or "").strip() + default_provider = str(model_cfg.get("provider") or "").strip() or None + + return default_model or get_effective_default_model(), default_provider + + def new_session(workspace=None, model=None, profile=None, model_provider=None, project_id=None, worktree_info=None): """Create a new in-memory session. @@ -1372,13 +1393,19 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p profile = get_active_profile_name() except ImportError: profile = None - effective_model = model or get_effective_default_model() + if model: + effective_model = model + effective_model_provider = model_provider + else: + effective_model, effective_model_provider = _profile_default_model_state(profile) + if model_provider: + effective_model_provider = model_provider wt = worktree_info if isinstance(worktree_info, dict) else None workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace s = Session( workspace=workspace_path or get_last_workspace(), model=effective_model, - model_provider=model_provider, + model_provider=effective_model_provider, profile=profile, project_id=project_id, worktree_path=wt.get('path') if wt else None, diff --git a/static/sessions.js b/static/sessions.js index 503b5bbeb57..3d61eae23af 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -460,23 +460,7 @@ async function newSession(flash, options={}){ const switchWs=S._profileSwitchWorkspace; S._profileSwitchWorkspace=null; const inheritWs=switchWs||(S.session?S.session.workspace:null)||(S._profileDefaultWorkspace||null); - // Use the saved default model for new sessions (#872). The user's saved - // default_model (from Settings) takes priority over the chat-header dropdown - // value, which reflects the *previous* session's model. Fall back to the - // dropdown value only when no default_model is configured. - const modelSel=$('modelSelect'); - const selectedDefaultModel=window._defaultModel||(modelSel&&modelSel.value)||''; - let defaultApplied=false; - if(window._defaultModel&&modelSel&&typeof _applyModelToDropdown==='function'){ - defaultApplied=!!_applyModelToDropdown(window._defaultModel,modelSel,window._activeProvider||null); - } - const canQualify=!window._defaultModel||defaultApplied||(modelSel&&modelSel.value===selectedDefaultModel); - const newModelState=(canQualify&&typeof _modelStateForSelect==='function') - ? _modelStateForSelect(modelSel,selectedDefaultModel) - : {model:selectedDefaultModel,model_provider:null}; const reqBody={ - model:newModelState.model, - model_provider:newModelState.model_provider||null, workspace:inheritWs, profile:S.activeProfile||'default', }; diff --git a/tests/test_issue798.py b/tests/test_issue798.py index 37889688bcb..5f5fd6a1c22 100644 --- a/tests/test_issue798.py +++ b/tests/test_issue798.py @@ -266,6 +266,30 @@ def test_sessions_js_sends_profile_in_new_session_post(): ) +def test_new_session_uses_explicit_profile_default_model_and_provider(tmp_path, monkeypatch): + """New chats must inherit the selected profile's config default.""" + import api.profiles as p + import api.models as m + + monkeypatch.setattr(p, "_DEFAULT_HERMES_HOME", tmp_path) + profile_home = tmp_path / "profiles" / "pepper" + profile_home.mkdir(parents=True) + (profile_home / "config.yaml").write_text( + "model:\n default: pepper-profile-model\n provider: pepper-provider\n", + encoding="utf-8", + ) + + with patch.object(m.Session, 'save', return_value=None): + s = m.new_session(workspace=str(tmp_path), profile="pepper") + try: + assert s.model == "pepper-profile-model" + assert s.model_provider == "pepper-provider" + assert s.profile == "pepper" + finally: + with m.LOCK: + m.SESSIONS.pop(s.session_id, None) + + def test_get_hermes_home_for_profile_rejects_path_traversal(): """R19j: get_hermes_home_for_profile() must reject names that don't match _PROFILE_ID_RE (e.g. path traversal like '../../etc') and return the base diff --git a/tests/test_provider_mismatch.py b/tests/test_provider_mismatch.py index d5ac11f6100..dbd2c019300 100644 --- a/tests/test_provider_mismatch.py +++ b/tests/test_provider_mismatch.py @@ -1171,10 +1171,13 @@ def test_boot_session_update_sends_model_provider(self): assert "_modelStateForSelect" in src assert "model_provider:modelState.model_provider||null" in src - def test_new_session_sends_model_provider(self): + def test_new_session_lets_profile_config_choose_default_model_provider(self): src = _read("static/sessions.js") - assert "_modelStateForSelect(modelSel,selectedDefaultModel)" in src - assert "model_provider:newModelState.model_provider||null" in src + start = src.index("async function newSession(") + body = src[start:src.index("const data=await api('/api/session/new'", start)] + assert "profile:S.activeProfile||'default'" in body + assert "model:newModelState.model" not in body + assert "model_provider:newModelState.model_provider||null" not in body def test_ui_has_json_model_state_storage(self): src = _read("static/ui.js") From 58259f6edb002fd2a5c0526e9ac75fd82341eeb5 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Wed, 20 May 2026 11:03:37 -0600 Subject: [PATCH 5/5] Avoid duplicate session list boot animation --- static/sessions.js | 2 +- tests/test_profile_switch_ux.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 3d61eae23af..77268e7c15e 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2059,7 +2059,7 @@ function _applySessionListPayload(sessData, projData){ ensureActiveSessionExternalRefreshPoll(); if(!_sessionListFirstRenderAnimated&&Array.isArray(_allSessions)&&_allSessions.length){ animateNextSessionListRefresh({enterAll:true}); - if(S&&S._bootReady) _sessionListFirstRenderAnimated=true; + _sessionListFirstRenderAnimated=true; } renderSessionListFromCache(); // no-ops if rename is in progress } diff --git a/tests/test_profile_switch_ux.py b/tests/test_profile_switch_ux.py index b7d8b99519b..4f0cb6e892f 100644 --- a/tests/test_profile_switch_ux.py +++ b/tests/test_profile_switch_ux.py @@ -198,7 +198,8 @@ def test_profile_refresh_captures_before_render_and_plays_after_rows_exist(self) def test_first_non_empty_session_render_is_animated(self): assert "_sessionListFirstRenderAnimated" in self.JS assert "animateNextSessionListRefresh({enterAll:true});" in self.JS - assert "if(S&&S._bootReady) _sessionListFirstRenderAnimated=true;" in self.JS + assert "_sessionListFirstRenderAnimated=true;" in self.JS + assert "if(S&&S._bootReady) _sessionListFirstRenderAnimated=true;" not in self.JS assert "enterAllAnimatedRows" in self.JS def test_profile_refresh_is_not_whole_list_fade(self):