Merge pull request #46 from nesquena/fix/profile-switch-default-home

fix: Profile system polish — 10 post-Sprint-23 fixes (v0.26)
This commit is contained in:
Nathan Esquenazi
2026-04-03 13:44:16 -07:00
committed by GitHub
15 changed files with 480 additions and 90 deletions

View File

@@ -5,6 +5,54 @@
---
## [v0.26] Profile System Polish -- 10 Post-Sprint-23 Fixes
*April 3, 2026 | 426 tests*
### Bug Fixes
- **Profile switch base dir bug.** When `HERMES_HOME` was mutated to a
`profiles/` subdir at startup, `switch_profile()` doubled the path
(e.g. `~/.hermes/profiles/X/profiles/X`). New `_resolve_base_hermes_home()`
detects profile subdirs and walks up to the actual base.
- **Cross-provider model routing.** Picking a model from a different provider
than the config's default now routes through OpenRouter instead of trying
a direct API call to a provider whose key may not exist.
- **Legacy sessions missing profile tag.** `all_sessions()` now backfills
`profile='default'` for pre-Sprint-22 sessions so the profile filter works.
- **Workspace list cleanup.** Stale paths, test artifacts, and cross-profile
entries are now cleaned on load. Legacy global workspace file migrated
once for the default profile.
- **API error messages.** `api()` helper now parses JSON error bodies and
surfaces the human-readable message instead of raw JSON.
- **Workspace dropdown moved to sidebar.** The workspace picker now opens
upward from the sidebar bottom instead of clipping behind the topbar.
### Features
- **Rate limit error display.** Rate limit errors (429) now show a distinct
card with a rate limit icon and hint, instead of the generic error message.
- **SSE `apperror`/`warning` events.** Server can send typed error events
that the frontend handles with appropriate UX (rate limit card, fallback
notice, etc.).
- **Smart model resolver.** `_findModelInDropdown()` handles name mismatches
between config model IDs and dropdown values (e.g. `claude-sonnet-4-6` vs
`anthropic/claude-sonnet-4.6`).
- **Profile switch starts new session.** When the current session has messages,
switching profiles automatically starts a fresh session to prevent
cross-profile tagging.
- **Per-profile toolsets.** Agent now reads `platform_toolsets.cli` from the
active profile's config at call time, not the boot-time snapshot.
- **Per-profile fallback model.** `fallback_model` config is read from the
active profile and passed to AIAgent.
### Architecture
- `api/profiles.py`: `_resolve_base_hermes_home()` replaces naive env var read.
- `api/workspace.py`: `_clean_workspace_list()`, `_migrate_global_workspaces()`.
- `api/streaming.py`: Per-profile toolsets and fallback model at call time.
- `api/models.py`: `all_sessions()` backfills `profile='default'`.
- `static/ui.js`: `_findModelInDropdown()`, `_applyModelToDropdown()`.
- `static/messages.js`: `apperror` and `warning` SSE event handlers.
---
## [v0.25] Sprint 23 -- Profile/Workspace/Model Coherence
*April 3, 2026 | 423 tests*
@@ -829,4 +877,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
---
*Last updated: v0.25, April 3, 2026 | Tests: 423*
*Last updated: v0.26, April 3, 2026 | Tests: 426*

View File

@@ -1,6 +1,6 @@
# Hermes Web UI -- Forward Sprint Plan
> Current state: v0.25 | 423 tests | Daily driver ready
> Current state: v0.26 | 426 tests | Daily driver ready
> This document plans the path from here to two targets:
>
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
@@ -663,5 +663,5 @@ and switchToProfile() didn't refresh workspaces or sessions.
---
*Last updated: April 3, 2026*
*Current version: v0.25 | 423 tests*
*Current version: v0.26 | 426 tests*
*Next sprint: Sprint 24 (Desktop Application)*

View File

@@ -373,15 +373,16 @@ def resolve_model_provider(model_id: str):
if '/' in model_id:
prefix, bare = model_id.split('/', 1)
# If prefix matches config provider, strip it and use that provider directly
# If prefix matches config provider exactly, strip it and use that provider directly.
# e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API
if config_provider and prefix == config_provider:
return bare, config_provider, config_base_url
# If the config provider is openrouter (or unset/None), pass the full
# provider/model string through -- OpenRouter uses this as its model ID.
# Only strip the prefix and switch to a direct-API provider when the
# config is explicitly set to that direct provider.
if config_provider and config_provider != 'openrouter' and prefix in _PROVIDER_MODELS:
return bare, prefix, None
# If prefix does NOT match config provider, the user picked a cross-provider model
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
# In this case always route through openrouter with the full provider/model string.
# Never strip the prefix and try a direct-API call to a provider whose key may not exist.
if prefix in _PROVIDER_MODELS and prefix != config_provider:
return model_id, 'openrouter', None
return model_id, config_provider, config_base_url

View File

@@ -90,6 +90,11 @@ def all_sessions():
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), s['updated_at']), reverse=True)
# Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.)
result = [s for s in result if not (s.get('title','Untitled')=='Untitled' and s.get('message_count',0)==0)]
# Backfill: sessions created before Sprint 22 have no profile tag.
# Attribute them to 'default' so the client profile filter works correctly.
for s in result:
if not s.get('profile'):
s['profile'] = 'default'
return result
except Exception:
pass # fall through to full scan
@@ -105,7 +110,11 @@ def all_sessions():
for s in SESSIONS.values():
if all(s.session_id != x.session_id for x in out): out.append(s)
out.sort(key=lambda s: (getattr(s, 'pinned', False), s.updated_at), reverse=True)
return [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)]
result = [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)]
for s in result:
if not s.get('profile'):
s['profile'] = 'default'
return result
def title_from(messages, fallback='Untitled'):

View File

@@ -16,9 +16,44 @@ from pathlib import Path
# ── Module state ────────────────────────────────────────────────────────────
_active_profile = 'default'
_profile_lock = threading.Lock()
# Read from env var so test isolation (HERMES_HOME=TEST_STATE_DIR) is respected.
# Evaluated at import time; server restart picks up any env change.
_DEFAULT_HERMES_HOME = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes')))
def _resolve_base_hermes_home() -> Path:
"""Return the BASE ~/.hermes directory — the root that contains profiles/.
This is intentionally distinct from HERMES_HOME, which tracks the *active
profile's* home and changes on every profile switch. The base dir must
always point to the top-level .hermes regardless of which profile is active.
Resolution order:
1. HERMES_BASE_HOME env var (set explicitly, highest priority)
2. HERMES_HOME env var — but only if it does NOT look like a profile subdir
(i.e. its parent is not named 'profiles'). This handles test isolation
where HERMES_HOME is set to an isolated test state dir.
3. ~/.hermes (always-correct default)
The bug this prevents: if HERMES_HOME has already been mutated to
/home/user/.hermes/profiles/webui (by init_profile_state at startup),
reading it here would make _DEFAULT_HERMES_HOME point to that subdir,
causing switch_profile('webui') to look for
/home/user/.hermes/profiles/webui/profiles/webui — which doesn't exist.
"""
# Explicit override for tests or unusual setups
base_override = os.getenv('HERMES_BASE_HOME', '').strip()
if base_override:
return Path(base_override).expanduser()
hermes_home = os.getenv('HERMES_HOME', '').strip()
if hermes_home:
p = Path(hermes_home).expanduser()
# If HERMES_HOME points to a profiles/ subdir, walk up two levels to the base
if p.parent.name == 'profiles':
return p.parent.parent
# Otherwise trust it (e.g. test isolation sets HERMES_HOME to TEST_STATE_DIR)
return p
return Path.home() / '.hermes'
_DEFAULT_HERMES_HOME = _resolve_base_hermes_home()
def _read_active_profile_file() -> str:

View File

@@ -112,13 +112,38 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
if AIAgent is None:
raise ImportError("AIAgent not available -- check that hermes-agent is on sys.path")
resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(model)
# Read per-profile config at call time (not module-level snapshot)
from api.config import get_config as _get_config
_cfg = _get_config()
# Per-profile toolsets (fall back to module-level CLI_TOOLSETS)
_pt = _cfg.get('platform_toolsets', {})
_toolsets = _pt.get('cli', CLI_TOOLSETS) if isinstance(_pt, dict) else CLI_TOOLSETS
# Fallback model from profile config (e.g. for rate-limit recovery)
_fallback = _cfg.get('fallback_model') or None
if _fallback:
# Resolve the fallback through our provider logic too
fb_model = _fallback.get('model', '')
fb_provider = _fallback.get('provider', '')
fb_base_url = _fallback.get('base_url')
_fallback_resolved = {
'model': fb_model,
'provider': fb_provider,
'base_url': fb_base_url,
}
else:
_fallback_resolved = None
agent = AIAgent(
model=resolved_model,
provider=resolved_provider,
base_url=resolved_base_url,
platform='cli',
quiet_mode=True,
enabled_toolsets=CLI_TOOLSETS,
enabled_toolsets=_toolsets,
fallback_model=_fallback_resolved,
session_id=session_id,
stream_delta_callback=on_token,
tool_progress_callback=on_tool,
@@ -203,7 +228,18 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
except Exception as e:
print('[webui] stream error:\n' + traceback.format_exc(), flush=True)
put('error', {'message': str(e)})
err_str = str(e)
# Detect rate limit errors specifically so the client can show a helpful card
# rather than the generic "Connection lost" message
is_rate_limit = 'rate limit' in err_str.lower() or '429' in err_str or 'RateLimitError' in type(e).__name__
if is_rate_limit:
put('apperror', {
'message': err_str,
'type': 'rate_limit',
'hint': 'Rate limit reached. The fallback model (if configured) was also exhausted. Try again in a moment.',
})
else:
put('apperror', {'message': err_str, 'type': 'error'})
finally:
_clear_thread_env() # TD1: always clear thread-local context
with STREAMS_LOCK:

View File

@@ -53,17 +53,31 @@ def _last_workspace_file() -> Path:
def _profile_default_workspace() -> str:
"""Read the profile's default workspace from its config.yaml.
Checks keys in priority order:
1. 'workspace' — explicit webui workspace key
2. 'default_workspace' — alternate explicit key
3. 'terminal.cwd' — hermes-agent terminal working dir (most common)
Falls back to the boot-time DEFAULT_WORKSPACE constant.
"""
try:
from api.profiles import get_active_hermes_home
from api.config import get_config
cfg = get_config()
ws = cfg.get('default_workspace')
if ws:
p = Path(ws).expanduser().resolve()
if p.is_dir():
return str(p)
# Explicit webui workspace keys first
for key in ('workspace', 'default_workspace'):
ws = cfg.get(key)
if ws:
p = Path(str(ws)).expanduser().resolve()
if p.is_dir():
return str(p)
# Fall through to terminal.cwd — the agent's configured working directory
terminal_cfg = cfg.get('terminal', {})
if isinstance(terminal_cfg, dict):
cwd = terminal_cfg.get('cwd', '')
if cwd and str(cwd) not in ('.', ''):
p = Path(str(cwd)).expanduser().resolve()
if p.is_dir():
return str(p)
except (ImportError, Exception):
pass
return str(_BOOT_DEFAULT_WORKSPACE)
@@ -71,26 +85,94 @@ def _profile_default_workspace() -> str:
# ── Public API ──────────────────────────────────────────────────────────────
def _clean_workspace_list(workspaces: list) -> list:
"""Sanitize a workspace list:
- Remove entries whose paths no longer exist on disk.
- Remove entries that look like test artifacts (webui-mvp-test, test-workspace).
- Remove entries whose paths live inside another profile's directory
(e.g. ~/.hermes/profiles/X/... should not appear on a different profile).
- Rename any entry whose name is literally 'default' to 'Home' (avoids
confusion with the 'default' profile name).
Returns the cleaned list (may be empty).
"""
hermes_profiles = (Path.home() / '.hermes' / 'profiles').resolve()
result = []
for w in workspaces:
path = w.get('path', '')
name = w.get('name', '')
p = Path(path).resolve() if path else Path('/')
# Skip test artifacts
if 'test-workspace' in path or 'webui-mvp-test' in path:
continue
# Skip paths that no longer exist
if not p.is_dir():
continue
# Skip paths inside a named profile's directory (cross-profile leak)
try:
p.relative_to(hermes_profiles)
continue # it IS under profiles/ — remove it
except ValueError:
pass
# Rename confusing 'default' label to 'Home'
if name.lower() == 'default':
name = 'Home'
result.append({'path': str(p), 'name': name})
return result
def _migrate_global_workspaces() -> list:
"""Read the legacy global workspaces.json, clean it, and return the result.
This is the migration path for users upgrading from a pre-profile version:
their global file may contain cross-profile entries, test artifacts, and
stale paths accumulated over time. We clean it in-place and rewrite it.
"""
if not _GLOBAL_WS_FILE.exists():
return []
try:
raw = json.loads(_GLOBAL_WS_FILE.read_text(encoding='utf-8'))
cleaned = _clean_workspace_list(raw)
if len(cleaned) != len(raw):
# Rewrite the cleaned version so future reads are already clean
_GLOBAL_WS_FILE.write_text(
json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
)
return cleaned
except Exception:
return []
def load_workspaces() -> list:
ws_file = _workspaces_file()
if ws_file.exists():
try:
return json.loads(ws_file.read_text(encoding='utf-8'))
raw = json.loads(ws_file.read_text(encoding='utf-8'))
cleaned = _clean_workspace_list(raw)
if len(cleaned) != len(raw):
# Persist the cleaned version so stale entries don't keep reappearing
try:
ws_file.write_text(
json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
)
except Exception:
pass
return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}]
except Exception:
pass
# Fallback: for the DEFAULT profile only, migrate from the legacy global file.
# Named profiles should start with a clean list, not inherit another profile's workspaces.
# No profile-local file yet.
# For the DEFAULT profile: migrate from the legacy global file (one-time cleanup).
# For NAMED profiles: always start clean with just their own workspace.
try:
from api.profiles import get_active_profile_name
is_default = get_active_profile_name() in ('default', None)
except ImportError:
is_default = True
if is_default and _GLOBAL_WS_FILE.exists():
try:
return json.loads(_GLOBAL_WS_FILE.read_text(encoding='utf-8'))
except Exception:
pass
return [{'path': _profile_default_workspace(), 'name': 'default'}]
if is_default:
migrated = _migrate_global_workspaces()
if migrated:
return migrated
# Fresh start: single entry from the profile's configured workspace, labeled "Home"
return [{'path': _profile_default_workspace(), 'name': 'Home'}]
def save_workspaces(workspaces: list):

View File

@@ -13,7 +13,7 @@
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.25</div></div></div>
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.26</div></div></div>
<div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button>
@@ -145,13 +145,16 @@
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option>
</optgroup>
</select>
<div id="sidebarWsDisplay" style="display:flex;align-items:center;gap:7px;padding:0 0 8px;cursor:pointer;border-radius:8px;transition:background .15s" onclick="toggleWsDropdown()" title="Switch workspace">
<span style="font-size:14px;opacity:.7">&#128193;</span>
<div style="min-width:0;flex:1">
<div style="font-size:11px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" id="sidebarWsName">Workspace</div>
<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:1px" id="sidebarWsPath"></div>
<div style="position:relative">
<div id="sidebarWsDisplay" style="display:flex;align-items:center;gap:7px;padding:0 0 8px;cursor:pointer;border-radius:8px;transition:background .15s" onclick="toggleWsDropdown()" title="Switch workspace">
<span style="font-size:14px;opacity:.7">&#128193;</span>
<div style="min-width:0;flex:1">
<div style="font-size:11px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" id="sidebarWsName">Workspace</div>
<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:1px" id="sidebarWsPath"></div>
</div>
<span style="font-size:10px;color:var(--muted);flex-shrink:0">&#9662;</span>
</div>
<span style="font-size:10px;color:var(--muted);flex-shrink:0">&#9662;</span>
<div class="ws-dropdown" id="wsDropdown"></div>
</div>
<div class="sidebar-actions">
<button class="sm-btn" id="btnDownload" title="Download as Markdown">&#8595; Transcript</button>
@@ -174,10 +177,7 @@
<div class="profile-dropdown" id="profileDropdown"></div>
</div>
<div class="chip model" id="modelChip">GPT-5.4 Mini</div>
<div id="wsChipWrap" style="position:relative">
<div class="chip ws-chip" id="wsChip" onclick="toggleWsDropdown()" title="Switch workspace" style="cursor:pointer">&#128193; test-workspace &#9662;</div>
<div class="ws-dropdown" id="wsDropdown"></div>
</div>
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none">&#128465; Clear</button>
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings">&#9881;</button>
<button class="chip mobile-files-btn" id="btnMobileFiles" onclick="toggleMobileFiles()" title="Files">&#128193;</button>

View File

@@ -162,6 +162,46 @@ async function send(){
renderSessionList();setBusy(false);setStatus('');
});
source.addEventListener('apperror',e=>{
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
// This is distinct from the SSE network 'error' event below.
source.close();
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard();
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
clearLiveToolCards();if(!assistantText)removeThinking();
try{
const d=JSON.parse(e.data);
const isRateLimit=d.type==='rate_limit';
const icon=isRateLimit?'⏱️':'⚠️';
const label=isRateLimit?'Rate limit reached':'Error';
const hint=d.hint?`\n\n*${d.hint}*`:'';
S.messages.push({role:'assistant',content:`**${icon} ${label}:** ${d.message}${hint}`});
}catch(_){
S.messages.push({role:'assistant',content:'**⚠️ Error:** An error occurred. Check server logs.'});
}
renderMessages();
}else if(typeof trackBackgroundError==='function'){
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
try{const d=JSON.parse(e.data);trackBackgroundError(activeSid,_errTitle,d.message||'Error');}
catch(_){trackBackgroundError(activeSid,_errTitle,'Error');}
}
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setStatus('');}
});
source.addEventListener('warning',e=>{
// Non-fatal warning from server (e.g. fallback activated, retrying)
if(!S.session||S.session.session_id!==activeSid) return;
try{
const d=JSON.parse(e.data);
// Show as a small inline notice, not a full error
setStatus(`⚠️ ${d.message||'Warning'}`);
// If it's a fallback notice, show it briefly then clear
if(d.type==='fallback') setTimeout(()=>setStatus(''),4000);
}catch(_){}
});
source.addEventListener('error',e=>{
source.close();
// Attempt one reconnect if the stream is still active server-side

View File

@@ -490,7 +490,7 @@ function closeWsDropdown(){
if(dd)dd.classList.remove('open');
}
document.addEventListener('click',e=>{
if(!e.target.closest('#wsChipWrap'))closeWsDropdown();
if(!e.target.closest('#sidebarWsDisplay') && !e.target.closest('#wsDropdown'))closeWsDropdown();
});
async function loadWorkspacesPanel(){
@@ -660,40 +660,74 @@ document.addEventListener('click', e => {
async function switchToProfile(name) {
if (S.busy) { showToast('Cannot switch profiles while agent is running'); return; }
// Determine whether the current session has any messages.
// A session with messages is "in progress" and belongs to the current profile —
// we must not retag it. We'll start a fresh session for the new profile instead.
const sessionInProgress = S.session && S.messages && S.messages.length > 0;
try {
const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) });
S.activeProfile = data.active || name;
// Clear stale model pref so profile default applies
// ── Model ──────────────────────────────────────────────────────────────
localStorage.removeItem('hermes-webui-model');
// Refresh model dropdown (profile may have different provider/models)
_skillsData = null;
await populateModelDropdown();
// Apply profile's default model if provided
if (data.default_model && $('modelSelect')) {
$('modelSelect').value = data.default_model;
if ($('modelSelect').value !== data.default_model) {
// Model not in list — add it
const opt = document.createElement('option');
opt.value = data.default_model;
opt.textContent = data.default_model.split('/').pop();
$('modelSelect').insertBefore(opt, $('modelSelect').firstChild);
$('modelSelect').value = data.default_model;
if (data.default_model) {
const sel = $('modelSelect');
const resolved = _applyModelToDropdown(data.default_model, sel);
const modelToUse = resolved || data.default_model;
S._pendingProfileModel = modelToUse;
// Only patch the in-memory session model if we're NOT about to replace the session
if (S.session && !sessionInProgress) {
S.session.model = modelToUse;
}
}
// Refresh workspace list (now profile-local)
// ── Workspace ──────────────────────────────────────────────────────────
_workspaceList = null;
await loadWorkspaceList();
// Reset profile filter and refresh session list
if (data.default_workspace) {
// Always store the profile default for new sessions
S._profileDefaultWorkspace = data.default_workspace;
if (S.session && !sessionInProgress) {
// Empty session (no messages yet) — safe to update it in place
try {
await api('/api/session/update', { method: 'POST', body: JSON.stringify({
session_id: S.session.session_id,
workspace: data.default_workspace,
model: S.session.model,
})});
S.session.workspace = data.default_workspace;
} catch (_) {}
}
}
// ── Session ────────────────────────────────────────────────────────────
_showAllProfiles = false;
await renderSessionList();
syncTopbar();
// Refresh visible sidebar panels
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);
await renderSessionList();
showToast('Switched to profile: ' + name + ' — new conversation started');
} else {
// No messages yet — just refresh the list and topbar in place
await renderSessionList();
syncTopbar();
showToast('Switched to profile: ' + name);
}
// ── Sidebar panels ─────────────────────────────────────────────────────
if (_currentPanel === 'skills') await loadSkills();
if (_currentPanel === 'memory') await loadMemory();
if (_currentPanel === 'tasks') await loadCrons();
if (_currentPanel === 'profiles') await loadProfilesPanel();
if (_currentPanel === 'workspaces') await loadWorkspacesPanel();
showToast('Switched to profile: ' + name);
} catch (e) { showToast('Switch failed: ' + e.message); }
}

View File

@@ -13,7 +13,10 @@ async function newSession(flash){
MSG_QUEUE.length=0;updateQueueBadge();
S.toolCalls=[];
clearLiveToolCards();
const inheritWs=S.session?S.session.workspace:null;
// Use profile default workspace for new sessions after a profile switch (one-shot),
// otherwise inherit from the current session (or let server pick the default)
const inheritWs=S._profileDefaultWorkspace||(S.session?S.session.workspace:null);
S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
S.session=data.session;S.messages=data.session.messages||[];
if(flash)S.session._flash=true;
@@ -113,7 +116,9 @@ function renderSessionListFromCache(){
const titleIds=new Set(titleMatches.map(s=>s.session_id));
const allMatched=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches;
// Filter by active profile (unless "All profiles" is toggled on)
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>!s.profile||s.profile===S.activeProfile);
// Server backfills profile='default' for legacy sessions, so every session has a profile.
// Show only sessions tagged to the active profile; 'All profiles' toggle overrides.
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>s.profile===S.activeProfile);
// Filter by active project
const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered;
// Filter archived unless toggle is on

View File

@@ -345,7 +345,7 @@
/* ── Workspace dropdown (topbar) ── */
.ws-chip{user-select:none;}
.ws-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:240px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
.ws-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;right:0;min-width:200px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
.ws-dropdown.open{display:block;}
.ws-opt{padding:9px 14px;cursor:pointer;transition:background .12s;}
.ws-opt:hover{background:rgba(255,255,255,.07);}

View File

@@ -7,6 +7,38 @@ const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
let _dynamicModelLabels={};
// ── Smart model resolver ────────────────────────────────────────────────────
// Finds the best matching option value in a <select> for a given model ID.
// Handles mismatches like 'claude-sonnet-4-6' vs 'anthropic/claude-sonnet-4.6'.
// Returns the matched option's value (already in the list), or null if no match.
function _findModelInDropdown(modelId, sel){
if(!modelId||!sel) return null;
const opts=Array.from(sel.options).map(o=>o.value);
// 1. Exact match
if(opts.includes(modelId)) return modelId;
// 2. Normalize: lowercase, strip namespace prefix, replace hyphens→dots
const norm=s=>s.toLowerCase().replace(/^[^/]+\//,'').replace(/-/g,'.');
const target=norm(modelId);
const exact=opts.find(o=>norm(o)===target);
if(exact) return exact;
// 3. Prefix/substring: target starts with or contains a significant chunk
const base=target.replace(/\.\d+$/,''); // strip trailing version number
const partial=opts.find(o=>norm(o).startsWith(base)||norm(o).includes(base));
return partial||null;
}
// Set the model picker to the best match for modelId.
// Returns the resolved value that was actually set, or null if nothing matched.
function _applyModelToDropdown(modelId, sel){
if(!modelId||!sel) return null;
const resolved=_findModelInDropdown(modelId,sel);
if(resolved){
sel.value=resolved;
return resolved;
}
return null;
}
async function populateModelDropdown(){
const sel=$('modelSelect');
if(!sel) return;
@@ -30,15 +62,7 @@ async function populateModelDropdown(){
}
// Set default model from server if no localStorage preference
if(data.default_model && !localStorage.getItem('hermes-webui-model')){
sel.value=data.default_model;
// If the default isn't in the list, add it
if(sel.value!==data.default_model){
const opt=document.createElement('option');
opt.value=data.default_model;
opt.textContent=data.default_model.split('/').pop();
sel.insertBefore(opt,sel.firstChild);
sel.value=data.default_model;
}
_applyModelToDropdown(data.default_model, sel);
}
}catch(e){
// API unavailable -- keep the hardcoded HTML options as fallback
@@ -320,15 +344,23 @@ function syncTopbar(){
document.title=sessionTitle+' \u2014 Hermes';
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
$('topbarMeta').textContent=`${vis.length} messages`;
const m=S.session.model||'';
$('modelSelect').value=m; // set dropdown first so chip reads consistent value
// If session model isn't in the dropdown, add it dynamically
if(m && $('modelSelect').value!==m){
const opt=document.createElement('option');
opt.value=m;
opt.textContent=getModelLabel(m);
$('modelSelect').appendChild(opt);
$('modelSelect').value=m;
// If a profile switch just happened, apply its model rather than the session's stale value.
// S._pendingProfileModel is set by switchToProfile() and cleared here after one application.
const modelOverride=S._pendingProfileModel;
if(modelOverride){
S._pendingProfileModel=null;
_applyModelToDropdown(modelOverride,$('modelSelect'));
} else {
const m=S.session.model||'';
const applied=_applyModelToDropdown(m,$('modelSelect'));
// If the model isn't in the list at all, add it so the session value is preserved
if(!applied && m){
const opt=document.createElement('option');
opt.value=m;
opt.textContent=getModelLabel(m);
$('modelSelect').appendChild(opt);
$('modelSelect').value=m;
}
}
// Show Clear button only when session has messages
const clearBtn=$('btnClearConv');
@@ -336,13 +368,6 @@ function syncTopbar(){
const displayModel=$('modelSelect').value||m;
$('modelChip').textContent=getModelLabel(displayModel);
const ws=S.session.workspace||'';
$('wsChip').textContent=ws.split('/').slice(-2).join('/')||ws;
// Update workspace chip in topbar with friendly name from workspace list
const wsChipEl=$('wsChip');
if(wsChipEl){
const wsFriendly=getWorkspaceFriendlyName(ws);
wsChipEl.textContent='\u{1F4C1} '+wsFriendly+' \u25BE';
}
// Update sidebar workspace display
const sidebarName=$('sidebarWsName');
const sidebarPath=$('sidebarWsPath');

View File

@@ -1,7 +1,13 @@
async function api(path,opts={}){
const url=new URL(path,location.origin);
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
if(!res.ok)throw new Error(await res.text());
if(!res.ok){
const text=await res.text();
// Parse JSON error body and surface the human-readable message,
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
try{const j=JSON.parse(text);throw new Error(j.error||j.message||text);}
catch(e){if(e instanceof SyntaxError)throw new Error(text);throw e;}
}
const ct=res.headers.get('content-type')||'';
return ct.includes('application/json')?res.json():res.text();
}

View File

@@ -122,3 +122,72 @@ def test_panels_js_clears_model_on_switch():
assert "localStorage.removeItem('hermes-webui-model')" in content
assert "loadWorkspaceList" in content
assert "renderSessionList" in content
# ── Regression: profile switch base dir bug (PR #44) ──────────────────────
def test_profile_switch_base_home_not_subdir():
"""_DEFAULT_HERMES_HOME must always be the base ~/.hermes root, not a
profile subdir. Regression: if HERMES_HOME was mutated to a profiles/
subdir at server startup, switch_profile() looked for
~/.hermes/profiles/X/profiles/X which never exists — returning 404.
We verify the fix is present via static analysis of profiles.py.
The live-switch variant is in test_profile_switch_returns_default_model_and_workspace.
"""
content = (REPO_ROOT / "api" / "profiles.py").read_text()
# The fix must define a resolver function that handles the profiles/ subdir case
assert "_resolve_base_hermes_home" in content, (
"profiles.py must define _resolve_base_hermes_home() to safely resolve "
"the base HERMES_HOME regardless of HERMES_HOME env var mutation"
)
assert "p.parent.name == 'profiles'" in content, (
"_resolve_base_hermes_home must detect when HERMES_HOME points to a "
"profiles/ subdir (e.g. ~/.hermes/profiles/webui) and walk up to base"
)
assert "p.parent.parent" in content, (
"_resolve_base_hermes_home must return p.parent.parent when HERMES_HOME "
"is a profiles/ subdir, giving back the actual ~/.hermes base"
)
# _DEFAULT_HERMES_HOME must be set from the resolver, not directly from env
assert "_DEFAULT_HERMES_HOME = _resolve_base_hermes_home()" in content, (
"_DEFAULT_HERMES_HOME must be assigned from _resolve_base_hermes_home(), "
"not directly from os.getenv('HERMES_HOME')"
)
def test_api_helper_returns_clean_error_message():
"""workspace.js api() helper must parse JSON error bodies and surface
the human-readable 'error' field, not raw JSON like
{'error': 'Profile X does not exist.'}.
Regression: api() did `throw new Error(await res.text())` which made
showToast display 'Switch failed: {"error":"Profile X does not exist."}' --
JSON noise the user shouldn't see.
"""
content = (REPO_ROOT / "static" / "workspace.js").read_text()
# Must parse the JSON error body
assert "JSON.parse(text)" in content, (
"api() must parse JSON error bodies -- raw res.text() leaks JSON to the UI"
)
# Must extract the .error field
assert "j.error" in content, (
"api() must extract j.error from parsed JSON error response"
)
def test_profile_switch_resolve_base_home_logic():
"""Static analysis: _resolve_base_hermes_home() must handle the case
where HERMES_HOME points to a profiles/ subdir by walking up to the base.
"""
content = (REPO_ROOT / "api" / "profiles.py").read_text()
assert "_resolve_base_hermes_home" in content, (
"profiles.py must define _resolve_base_hermes_home()"
)
assert "p.parent.name == 'profiles'" in content, (
"_resolve_base_hermes_home must detect and unwrap profiles/ subdir paths"
)
assert "p.parent.parent" in content, (
"_resolve_base_hermes_home must walk up two levels from a profiles/ subdir"
)