Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 6 additions & 0 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
31 changes: 29 additions & 2 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions api/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}

Expand Down
47 changes: 41 additions & 6 deletions static/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand All @@ -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).
Expand Down
93 changes: 61 additions & 32 deletions static/panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -4488,6 +4488,27 @@ async function switchToWorkspace(path,name){

// ── Profile panel + dropdown ──
let _profilesCache = null;
let _profileSwitchGeneration = 0;

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){
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();
}).catch(()=>{});
}

async function loadProfilesPanel() {
const panel = $('profilesPanel');
Expand Down Expand Up @@ -4750,6 +4771,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;
Expand All @@ -4765,35 +4787,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;
}
}

Expand Down Expand Up @@ -4822,23 +4861,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();
Expand All @@ -4847,28 +4877,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; }
}
}

Expand Down
Loading
Loading