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:
50
CHANGELOG.md
50
CHANGELOG.md
@@ -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
|
## [v0.25] Sprint 23 -- Profile/Workspace/Model Coherence
|
||||||
*April 3, 2026 | 423 tests*
|
*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*
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Hermes Web UI -- Forward Sprint Plan
|
# 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:
|
> 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
|
> 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*
|
*Last updated: April 3, 2026*
|
||||||
*Current version: v0.25 | 423 tests*
|
*Current version: v0.26 | 426 tests*
|
||||||
*Next sprint: Sprint 24 (Desktop Application)*
|
*Next sprint: Sprint 24 (Desktop Application)*
|
||||||
|
|||||||
@@ -373,15 +373,16 @@ def resolve_model_provider(model_id: str):
|
|||||||
|
|
||||||
if '/' in model_id:
|
if '/' in model_id:
|
||||||
prefix, bare = model_id.split('/', 1)
|
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:
|
if config_provider and prefix == config_provider:
|
||||||
return bare, config_provider, config_base_url
|
return bare, config_provider, config_base_url
|
||||||
# If the config provider is openrouter (or unset/None), pass the full
|
# If prefix does NOT match config provider, the user picked a cross-provider model
|
||||||
# provider/model string through -- OpenRouter uses this as its model ID.
|
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
|
||||||
# Only strip the prefix and switch to a direct-API provider when the
|
# In this case always route through openrouter with the full provider/model string.
|
||||||
# config is explicitly set to that direct provider.
|
# Never strip the prefix and try a direct-API call to a provider whose key may not exist.
|
||||||
if config_provider and config_provider != 'openrouter' and prefix in _PROVIDER_MODELS:
|
if prefix in _PROVIDER_MODELS and prefix != config_provider:
|
||||||
return bare, prefix, None
|
return model_id, 'openrouter', None
|
||||||
|
|
||||||
return model_id, config_provider, config_base_url
|
return model_id, config_provider, config_base_url
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ def all_sessions():
|
|||||||
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), s['updated_at']), reverse=True)
|
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.)
|
# 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)]
|
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
|
return result
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # fall through to full scan
|
pass # fall through to full scan
|
||||||
@@ -105,7 +110,11 @@ def all_sessions():
|
|||||||
for s in SESSIONS.values():
|
for s in SESSIONS.values():
|
||||||
if all(s.session_id != x.session_id for x in out): out.append(s)
|
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)
|
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'):
|
def title_from(messages, fallback='Untitled'):
|
||||||
|
|||||||
@@ -16,9 +16,44 @@ from pathlib import Path
|
|||||||
# ── Module state ────────────────────────────────────────────────────────────
|
# ── Module state ────────────────────────────────────────────────────────────
|
||||||
_active_profile = 'default'
|
_active_profile = 'default'
|
||||||
_profile_lock = threading.Lock()
|
_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.
|
def _resolve_base_hermes_home() -> Path:
|
||||||
_DEFAULT_HERMES_HOME = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes')))
|
"""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:
|
def _read_active_profile_file() -> str:
|
||||||
|
|||||||
@@ -112,13 +112,38 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
|||||||
if AIAgent is None:
|
if AIAgent is None:
|
||||||
raise ImportError("AIAgent not available -- check that hermes-agent is on sys.path")
|
raise ImportError("AIAgent not available -- check that hermes-agent is on sys.path")
|
||||||
resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(model)
|
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(
|
agent = AIAgent(
|
||||||
model=resolved_model,
|
model=resolved_model,
|
||||||
provider=resolved_provider,
|
provider=resolved_provider,
|
||||||
base_url=resolved_base_url,
|
base_url=resolved_base_url,
|
||||||
platform='cli',
|
platform='cli',
|
||||||
quiet_mode=True,
|
quiet_mode=True,
|
||||||
enabled_toolsets=CLI_TOOLSETS,
|
enabled_toolsets=_toolsets,
|
||||||
|
fallback_model=_fallback_resolved,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
stream_delta_callback=on_token,
|
stream_delta_callback=on_token,
|
||||||
tool_progress_callback=on_tool,
|
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:
|
except Exception as e:
|
||||||
print('[webui] stream error:\n' + traceback.format_exc(), flush=True)
|
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:
|
finally:
|
||||||
_clear_thread_env() # TD1: always clear thread-local context
|
_clear_thread_env() # TD1: always clear thread-local context
|
||||||
with STREAMS_LOCK:
|
with STREAMS_LOCK:
|
||||||
|
|||||||
106
api/workspace.py
106
api/workspace.py
@@ -53,15 +53,29 @@ def _last_workspace_file() -> Path:
|
|||||||
def _profile_default_workspace() -> str:
|
def _profile_default_workspace() -> str:
|
||||||
"""Read the profile's default workspace from its config.yaml.
|
"""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.
|
Falls back to the boot-time DEFAULT_WORKSPACE constant.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from api.profiles import get_active_hermes_home
|
|
||||||
from api.config import get_config
|
from api.config import get_config
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
ws = cfg.get('default_workspace')
|
# Explicit webui workspace keys first
|
||||||
|
for key in ('workspace', 'default_workspace'):
|
||||||
|
ws = cfg.get(key)
|
||||||
if ws:
|
if ws:
|
||||||
p = Path(ws).expanduser().resolve()
|
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():
|
if p.is_dir():
|
||||||
return str(p)
|
return str(p)
|
||||||
except (ImportError, Exception):
|
except (ImportError, Exception):
|
||||||
@@ -71,26 +85,94 @@ def _profile_default_workspace() -> str:
|
|||||||
|
|
||||||
# ── Public API ──────────────────────────────────────────────────────────────
|
# ── 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:
|
def load_workspaces() -> list:
|
||||||
ws_file = _workspaces_file()
|
ws_file = _workspaces_file()
|
||||||
if ws_file.exists():
|
if ws_file.exists():
|
||||||
try:
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Fallback: for the DEFAULT profile only, migrate from the legacy global file.
|
return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}]
|
||||||
# Named profiles should start with a clean list, not inherit another profile's workspaces.
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 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:
|
try:
|
||||||
from api.profiles import get_active_profile_name
|
from api.profiles import get_active_profile_name
|
||||||
is_default = get_active_profile_name() in ('default', None)
|
is_default = get_active_profile_name() in ('default', None)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
is_default = True
|
is_default = True
|
||||||
if is_default and _GLOBAL_WS_FILE.exists():
|
if is_default:
|
||||||
try:
|
migrated = _migrate_global_workspaces()
|
||||||
return json.loads(_GLOBAL_WS_FILE.read_text(encoding='utf-8'))
|
if migrated:
|
||||||
except Exception:
|
return migrated
|
||||||
pass
|
# Fresh start: single entry from the profile's configured workspace, labeled "Home"
|
||||||
return [{'path': _profile_default_workspace(), 'name': 'default'}]
|
return [{'path': _profile_default_workspace(), 'name': 'Home'}]
|
||||||
|
|
||||||
|
|
||||||
def save_workspaces(workspaces: list):
|
def save_workspaces(workspaces: list):
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sidebar">
|
<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">
|
<div class="sidebar-nav">
|
||||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||||
@@ -145,6 +145,7 @@
|
|||||||
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option>
|
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
|
<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">
|
<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">📁</span>
|
<span style="font-size:14px;opacity:.7">📁</span>
|
||||||
<div style="min-width:0;flex:1">
|
<div style="min-width:0;flex:1">
|
||||||
@@ -153,6 +154,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<span style="font-size:10px;color:var(--muted);flex-shrink:0">▾</span>
|
<span style="font-size:10px;color:var(--muted);flex-shrink:0">▾</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ws-dropdown" id="wsDropdown"></div>
|
||||||
|
</div>
|
||||||
<div class="sidebar-actions">
|
<div class="sidebar-actions">
|
||||||
<button class="sm-btn" id="btnDownload" title="Download as Markdown">↓ Transcript</button>
|
<button class="sm-btn" id="btnDownload" title="Download as Markdown">↓ Transcript</button>
|
||||||
<button class="sm-btn" id="btnExportJSON" title="Export full session as JSON">❬/❭ JSON</button>
|
<button class="sm-btn" id="btnExportJSON" title="Export full session as JSON">❬/❭ JSON</button>
|
||||||
@@ -174,10 +177,7 @@
|
|||||||
<div class="profile-dropdown" id="profileDropdown"></div>
|
<div class="profile-dropdown" id="profileDropdown"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chip model" id="modelChip">GPT-5.4 Mini</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">📁 test-workspace ▾</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">🗑 Clear</button>
|
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none">🗑 Clear</button>
|
||||||
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings">⚙</button>
|
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings">⚙</button>
|
||||||
<button class="chip mobile-files-btn" id="btnMobileFiles" onclick="toggleMobileFiles()" title="Files">📁</button>
|
<button class="chip mobile-files-btn" id="btnMobileFiles" onclick="toggleMobileFiles()" title="Files">📁</button>
|
||||||
|
|||||||
@@ -162,6 +162,46 @@ async function send(){
|
|||||||
renderSessionList();setBusy(false);setStatus('');
|
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.addEventListener('error',e=>{
|
||||||
source.close();
|
source.close();
|
||||||
// Attempt one reconnect if the stream is still active server-side
|
// Attempt one reconnect if the stream is still active server-side
|
||||||
|
|||||||
@@ -490,7 +490,7 @@ function closeWsDropdown(){
|
|||||||
if(dd)dd.classList.remove('open');
|
if(dd)dd.classList.remove('open');
|
||||||
}
|
}
|
||||||
document.addEventListener('click',e=>{
|
document.addEventListener('click',e=>{
|
||||||
if(!e.target.closest('#wsChipWrap'))closeWsDropdown();
|
if(!e.target.closest('#sidebarWsDisplay') && !e.target.closest('#wsDropdown'))closeWsDropdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadWorkspacesPanel(){
|
async function loadWorkspacesPanel(){
|
||||||
@@ -660,40 +660,74 @@ document.addEventListener('click', e => {
|
|||||||
|
|
||||||
async function switchToProfile(name) {
|
async function switchToProfile(name) {
|
||||||
if (S.busy) { showToast('Cannot switch profiles while agent is running'); return; }
|
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 {
|
try {
|
||||||
const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) });
|
const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) });
|
||||||
S.activeProfile = data.active || name;
|
S.activeProfile = data.active || name;
|
||||||
// Clear stale model pref so profile default applies
|
|
||||||
|
// ── Model ──────────────────────────────────────────────────────────────
|
||||||
localStorage.removeItem('hermes-webui-model');
|
localStorage.removeItem('hermes-webui-model');
|
||||||
// Refresh model dropdown (profile may have different provider/models)
|
|
||||||
_skillsData = null;
|
_skillsData = null;
|
||||||
await populateModelDropdown();
|
await populateModelDropdown();
|
||||||
// Apply profile's default model if provided
|
if (data.default_model) {
|
||||||
if (data.default_model && $('modelSelect')) {
|
const sel = $('modelSelect');
|
||||||
$('modelSelect').value = data.default_model;
|
const resolved = _applyModelToDropdown(data.default_model, sel);
|
||||||
if ($('modelSelect').value !== data.default_model) {
|
const modelToUse = resolved || data.default_model;
|
||||||
// Model not in list — add it
|
S._pendingProfileModel = modelToUse;
|
||||||
const opt = document.createElement('option');
|
// Only patch the in-memory session model if we're NOT about to replace the session
|
||||||
opt.value = data.default_model;
|
if (S.session && !sessionInProgress) {
|
||||||
opt.textContent = data.default_model.split('/').pop();
|
S.session.model = modelToUse;
|
||||||
$('modelSelect').insertBefore(opt, $('modelSelect').firstChild);
|
|
||||||
$('modelSelect').value = data.default_model;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Refresh workspace list (now profile-local)
|
|
||||||
|
// ── Workspace ──────────────────────────────────────────────────────────
|
||||||
_workspaceList = null;
|
_workspaceList = null;
|
||||||
await loadWorkspaceList();
|
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;
|
_showAllProfiles = false;
|
||||||
|
|
||||||
|
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();
|
await renderSessionList();
|
||||||
syncTopbar();
|
syncTopbar();
|
||||||
// Refresh visible sidebar panels
|
showToast('Switched to profile: ' + name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sidebar panels ─────────────────────────────────────────────────────
|
||||||
if (_currentPanel === 'skills') await loadSkills();
|
if (_currentPanel === 'skills') await loadSkills();
|
||||||
if (_currentPanel === 'memory') await loadMemory();
|
if (_currentPanel === 'memory') await loadMemory();
|
||||||
if (_currentPanel === 'tasks') await loadCrons();
|
if (_currentPanel === 'tasks') await loadCrons();
|
||||||
if (_currentPanel === 'profiles') await loadProfilesPanel();
|
if (_currentPanel === 'profiles') await loadProfilesPanel();
|
||||||
if (_currentPanel === 'workspaces') await loadWorkspacesPanel();
|
if (_currentPanel === 'workspaces') await loadWorkspacesPanel();
|
||||||
showToast('Switched to profile: ' + name);
|
|
||||||
} catch (e) { showToast('Switch failed: ' + e.message); }
|
} catch (e) { showToast('Switch failed: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ async function newSession(flash){
|
|||||||
MSG_QUEUE.length=0;updateQueueBadge();
|
MSG_QUEUE.length=0;updateQueueBadge();
|
||||||
S.toolCalls=[];
|
S.toolCalls=[];
|
||||||
clearLiveToolCards();
|
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})});
|
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||[];
|
S.session=data.session;S.messages=data.session.messages||[];
|
||||||
if(flash)S.session._flash=true;
|
if(flash)S.session._flash=true;
|
||||||
@@ -113,7 +116,9 @@ function renderSessionListFromCache(){
|
|||||||
const titleIds=new Set(titleMatches.map(s=>s.session_id));
|
const titleIds=new Set(titleMatches.map(s=>s.session_id));
|
||||||
const allMatched=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches;
|
const allMatched=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches;
|
||||||
// Filter by active profile (unless "All profiles" is toggled on)
|
// 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
|
// Filter by active project
|
||||||
const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered;
|
const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered;
|
||||||
// Filter archived unless toggle is on
|
// Filter archived unless toggle is on
|
||||||
|
|||||||
@@ -345,7 +345,7 @@
|
|||||||
|
|
||||||
/* ── Workspace dropdown (topbar) ── */
|
/* ── Workspace dropdown (topbar) ── */
|
||||||
.ws-chip{user-select:none;}
|
.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-dropdown.open{display:block;}
|
||||||
.ws-opt{padding:9px 14px;cursor:pointer;transition:background .12s;}
|
.ws-opt{padding:9px 14px;cursor:pointer;transition:background .12s;}
|
||||||
.ws-opt:hover{background:rgba(255,255,255,.07);}
|
.ws-opt:hover{background:rgba(255,255,255,.07);}
|
||||||
|
|||||||
63
static/ui.js
63
static/ui.js
@@ -7,6 +7,38 @@ const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'&
|
|||||||
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
|
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
|
||||||
let _dynamicModelLabels={};
|
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(){
|
async function populateModelDropdown(){
|
||||||
const sel=$('modelSelect');
|
const sel=$('modelSelect');
|
||||||
if(!sel) return;
|
if(!sel) return;
|
||||||
@@ -30,15 +62,7 @@ async function populateModelDropdown(){
|
|||||||
}
|
}
|
||||||
// Set default model from server if no localStorage preference
|
// Set default model from server if no localStorage preference
|
||||||
if(data.default_model && !localStorage.getItem('hermes-webui-model')){
|
if(data.default_model && !localStorage.getItem('hermes-webui-model')){
|
||||||
sel.value=data.default_model;
|
_applyModelToDropdown(data.default_model, sel);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}catch(e){
|
}catch(e){
|
||||||
// API unavailable -- keep the hardcoded HTML options as fallback
|
// API unavailable -- keep the hardcoded HTML options as fallback
|
||||||
@@ -320,29 +344,30 @@ function syncTopbar(){
|
|||||||
document.title=sessionTitle+' \u2014 Hermes';
|
document.title=sessionTitle+' \u2014 Hermes';
|
||||||
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
|
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
|
||||||
$('topbarMeta').textContent=`${vis.length} messages`;
|
$('topbarMeta').textContent=`${vis.length} messages`;
|
||||||
|
// 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 m=S.session.model||'';
|
||||||
$('modelSelect').value=m; // set dropdown first so chip reads consistent value
|
const applied=_applyModelToDropdown(m,$('modelSelect'));
|
||||||
// If session model isn't in the dropdown, add it dynamically
|
// If the model isn't in the list at all, add it so the session value is preserved
|
||||||
if(m && $('modelSelect').value!==m){
|
if(!applied && m){
|
||||||
const opt=document.createElement('option');
|
const opt=document.createElement('option');
|
||||||
opt.value=m;
|
opt.value=m;
|
||||||
opt.textContent=getModelLabel(m);
|
opt.textContent=getModelLabel(m);
|
||||||
$('modelSelect').appendChild(opt);
|
$('modelSelect').appendChild(opt);
|
||||||
$('modelSelect').value=m;
|
$('modelSelect').value=m;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Show Clear button only when session has messages
|
// Show Clear button only when session has messages
|
||||||
const clearBtn=$('btnClearConv');
|
const clearBtn=$('btnClearConv');
|
||||||
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none';
|
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none';
|
||||||
const displayModel=$('modelSelect').value||m;
|
const displayModel=$('modelSelect').value||m;
|
||||||
$('modelChip').textContent=getModelLabel(displayModel);
|
$('modelChip').textContent=getModelLabel(displayModel);
|
||||||
const ws=S.session.workspace||'';
|
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
|
// Update sidebar workspace display
|
||||||
const sidebarName=$('sidebarWsName');
|
const sidebarName=$('sidebarWsName');
|
||||||
const sidebarPath=$('sidebarWsPath');
|
const sidebarPath=$('sidebarWsPath');
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
async function api(path,opts={}){
|
async function api(path,opts={}){
|
||||||
const url=new URL(path,location.origin);
|
const url=new URL(path,location.origin);
|
||||||
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
|
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')||'';
|
const ct=res.headers.get('content-type')||'';
|
||||||
return ct.includes('application/json')?res.json():res.text();
|
return ct.includes('application/json')?res.json():res.text();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,3 +122,72 @@ def test_panels_js_clears_model_on_switch():
|
|||||||
assert "localStorage.removeItem('hermes-webui-model')" in content
|
assert "localStorage.removeItem('hermes-webui-model')" in content
|
||||||
assert "loadWorkspaceList" in content
|
assert "loadWorkspaceList" in content
|
||||||
assert "renderSessionList" 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"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user