feat: Sprint 23 -- profile/workspace/model coherence
Fix five coherence bugs in profile switching: 1. Model picker ignored profile default (localStorage stale key) 2. Workspace list was global (not profile-scoped) 3. DEFAULT_WORKSPACE was a boot-time singleton 4. Session list showed all profiles (no filtering) 5. switchToProfile() didn't refresh workspaces or sessions Backend: workspace storage is now profile-local for named profiles, switch_profile() returns default_model and default_workspace. Frontend: switchToProfile() clears stale model pref, refreshes workspace list and session list, sessions.js filters by active profile with 'Show N from other profiles' toggle. 8 new tests. 400 pass / 23 fail (identical to baseline). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
36
CHANGELOG.md
36
CHANGELOG.md
@@ -5,6 +5,42 @@
|
||||
|
||||
---
|
||||
|
||||
## [v0.25] Sprint 23 -- Profile/Workspace/Model Coherence
|
||||
*April 3, 2026 | 423 tests*
|
||||
|
||||
### Features
|
||||
- **Profile-local workspace storage.** Each named profile now stores its own
|
||||
`workspaces.json` and `last_workspace.txt` under `{profile_home}/webui_state/`.
|
||||
Default profile continues using the global STATE_DIR for backward compat.
|
||||
- **Profile switch returns defaults.** `POST /api/profile/switch` response now
|
||||
includes `default_model` and `default_workspace` from the new profile's
|
||||
config.yaml, enabling one-round-trip state sync.
|
||||
- **Session profile filter.** Session sidebar filters to the active profile by
|
||||
default. "Show N from other profiles" toggle reveals sessions from all
|
||||
profiles, modeled on the existing archived toggle. Resets on profile switch.
|
||||
|
||||
### Bug Fixes
|
||||
- **Model picker ignores profile on switch.** `switchToProfile()` now clears
|
||||
the `hermes-webui-model` localStorage key so the profile's default model
|
||||
applies instead of a stale preference from another profile.
|
||||
- **Workspace list was global.** Switching profiles no longer shows the wrong
|
||||
profile's workspaces.
|
||||
- **`DEFAULT_WORKSPACE` was a boot-time singleton.** Now resolved dynamically
|
||||
through `_profile_default_workspace()`.
|
||||
- **Session list showed all profiles.** Now filtered to active profile.
|
||||
- **`switchToProfile()` didn't refresh workspaces or sessions.** Now refreshes
|
||||
workspace list, session list, and resets profile filter on switch.
|
||||
|
||||
### Architecture
|
||||
- `api/workspace.py` rewritten with profile-aware path resolution.
|
||||
- `api/profiles.py`: `switch_profile()` returns `default_model` and
|
||||
`default_workspace`.
|
||||
- `static/sessions.js`: Profile filter with toggle UI.
|
||||
- `static/panels.js`: Full cascade refresh on profile switch.
|
||||
- 8 new tests in `test_sprint23.py`.
|
||||
|
||||
---
|
||||
|
||||
## [v0.24] Sprint 22 -- Multi-Profile Support (Issue #28)
|
||||
*April 3, 2026 | 415 tests*
|
||||
|
||||
|
||||
62
SPRINTS.md
62
SPRINTS.md
@@ -511,7 +511,63 @@ single default profile, blocking multi-persona workflows.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 23 -- Desktop Application (PLANNED)
|
||||
## Sprint 23 -- Profile/Workspace/Model Coherence (COMPLETED)
|
||||
|
||||
**Theme:** Make profiles, workspaces, models, and sessions coherent across
|
||||
profile switches.
|
||||
|
||||
**Why now:** Sprint 22 added profile switching but five coherence bugs remained:
|
||||
the model picker ignored the profile's default, workspaces were a global file,
|
||||
DEFAULT_WORKSPACE was a startup singleton, the session list showed all profiles,
|
||||
and switchToProfile() didn't refresh workspaces or sessions.
|
||||
|
||||
### Track A: Bugs
|
||||
- **Model picker ignores profile on switch.** `populateModelDropdown()` skipped
|
||||
the profile's default model if `localStorage` had a saved preference. Fixed:
|
||||
`switchToProfile()` now clears `hermes-webui-model` from localStorage and
|
||||
applies the profile's default model from the switch response.
|
||||
- **Workspace list is a global file.** `workspaces.json` was process-global.
|
||||
Fixed: workspace storage is now profile-local at `{profile_home}/webui_state/`.
|
||||
Default profile uses global STATE_DIR for backward compatibility.
|
||||
- **`DEFAULT_WORKSPACE` is a startup singleton.** Frozen at boot. Fixed:
|
||||
`get_last_workspace()` and `_profile_default_workspace()` now resolve
|
||||
dynamically through the active profile's config.
|
||||
- **Session list shows all profiles.** Fixed: `renderSessionListFromCache()`
|
||||
filters to `S.activeProfile` by default, with "Show N from other profiles"
|
||||
toggle (modeled on the archived toggle).
|
||||
- **`switchToProfile()` doesn't refresh workspace list or sessions.** Fixed:
|
||||
now calls `loadWorkspaceList()`, `renderSessionList()`, resets profile filter.
|
||||
|
||||
### Track B: Features
|
||||
- **Profile-local workspace storage.** Each named profile stores its own
|
||||
`workspaces.json` and `last_workspace.txt` under `{profile_home}/webui_state/`.
|
||||
Falls back to global STATE_DIR for the default profile (preserves test
|
||||
isolation and backward compat).
|
||||
- **Profile switch returns defaults.** `POST /api/profile/switch` response now
|
||||
includes `default_model` and `default_workspace` so the frontend can apply
|
||||
both in one round-trip.
|
||||
- **Session profile filter.** Session sidebar filters to active profile by
|
||||
default. "Show N from other profiles" toggle reveals sessions from all
|
||||
profiles. Resets on profile switch.
|
||||
|
||||
### Track C: Architecture
|
||||
- `api/workspace.py`: Rewritten with `_profile_state_dir()`, `_workspaces_file()`,
|
||||
`_last_workspace_file()`, `_profile_default_workspace()`. All lazy imports to
|
||||
avoid circular deps.
|
||||
- `api/profiles.py`: `switch_profile()` returns `default_model` and
|
||||
`default_workspace` from the new profile's config.yaml.
|
||||
- `static/panels.js`: `switchToProfile()` clears localStorage model key,
|
||||
refreshes workspace list and session list, resets profile filter.
|
||||
- `static/sessions.js`: `_showAllProfiles` state variable, profile filter in
|
||||
`renderSessionListFromCache()`, toggle UI.
|
||||
|
||||
**Tests:** 8 new (test_sprint23.py). Total: 423.
|
||||
**Hermes CLI parity impact:** High (coherent profile behavior)
|
||||
**Claude parity impact:** Low
|
||||
|
||||
---
|
||||
|
||||
## Sprint 24 -- Desktop Application (PLANNED)
|
||||
|
||||
**Theme:** Native desktop experience.
|
||||
|
||||
@@ -607,5 +663,5 @@ single default profile, blocking multi-persona workflows.
|
||||
---
|
||||
|
||||
*Last updated: April 3, 2026*
|
||||
*Current version: v0.24 | 415 tests*
|
||||
*Next sprint: Sprint 23 (Desktop Application)*
|
||||
*Current version: v0.25 | 423 tests*
|
||||
*Next sprint: Sprint 24 (Desktop Application)*
|
||||
|
||||
@@ -148,7 +148,23 @@ def switch_profile(name: str) -> dict:
|
||||
# Reload config.yaml from the new profile
|
||||
reload_config()
|
||||
|
||||
return {'profiles': list_profiles_api(), 'active': name}
|
||||
# Return profile-specific defaults so frontend can apply them
|
||||
from api.workspace import get_last_workspace, _profile_default_workspace
|
||||
from api.config import get_config
|
||||
cfg = get_config()
|
||||
model_cfg = cfg.get('model', {})
|
||||
default_model = None
|
||||
if isinstance(model_cfg, str):
|
||||
default_model = model_cfg
|
||||
elif isinstance(model_cfg, dict):
|
||||
default_model = model_cfg.get('default')
|
||||
|
||||
return {
|
||||
'profiles': list_profiles_api(),
|
||||
'active': name,
|
||||
'default_model': default_model,
|
||||
'default_workspace': get_last_workspace(),
|
||||
}
|
||||
|
||||
|
||||
def list_profiles_api() -> list:
|
||||
|
||||
100
api/workspace.py
100
api/workspace.py
@@ -1,43 +1,123 @@
|
||||
"""
|
||||
Hermes Web UI -- Workspace and file system helpers.
|
||||
|
||||
Workspace lists and last-used workspace are stored per-profile so each
|
||||
profile has its own workspace configuration. State files live at
|
||||
``{profile_home}/webui_state/workspaces.json`` and
|
||||
``{profile_home}/webui_state/last_workspace.txt``. The global STATE_DIR
|
||||
paths are used as fallback when no profile module is available.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import (
|
||||
WORKSPACES_FILE, LAST_WORKSPACE_FILE, DEFAULT_WORKSPACE,
|
||||
WORKSPACES_FILE as _GLOBAL_WS_FILE,
|
||||
LAST_WORKSPACE_FILE as _GLOBAL_LW_FILE,
|
||||
DEFAULT_WORKSPACE as _BOOT_DEFAULT_WORKSPACE,
|
||||
MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS
|
||||
)
|
||||
|
||||
|
||||
def load_workspaces() -> list:
|
||||
if WORKSPACES_FILE.exists():
|
||||
# ── Profile-aware path resolution ───────────────────────────────────────────
|
||||
|
||||
def _profile_state_dir() -> Path:
|
||||
"""Return the webui_state directory for the active profile.
|
||||
|
||||
For the default profile, returns the global STATE_DIR (respects
|
||||
HERMES_WEBUI_STATE_DIR env var for test isolation).
|
||||
For named profiles, returns {profile_home}/webui_state/.
|
||||
"""
|
||||
try:
|
||||
return json.loads(WORKSPACES_FILE.read_text(encoding='utf-8'))
|
||||
from api.profiles import get_active_profile_name, get_active_hermes_home
|
||||
name = get_active_profile_name()
|
||||
if name and name != 'default':
|
||||
d = get_active_hermes_home() / 'webui_state'
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
except ImportError:
|
||||
pass
|
||||
return _GLOBAL_WS_FILE.parent
|
||||
|
||||
|
||||
def _workspaces_file() -> Path:
|
||||
"""Return the workspaces.json path for the active profile."""
|
||||
return _profile_state_dir() / 'workspaces.json'
|
||||
|
||||
|
||||
def _last_workspace_file() -> Path:
|
||||
"""Return the last_workspace.txt path for the active profile."""
|
||||
return _profile_state_dir() / 'last_workspace.txt'
|
||||
|
||||
|
||||
def _profile_default_workspace() -> str:
|
||||
"""Read the profile's default workspace from its config.yaml.
|
||||
|
||||
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)
|
||||
except (ImportError, Exception):
|
||||
pass
|
||||
return str(_BOOT_DEFAULT_WORKSPACE)
|
||||
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def load_workspaces() -> list:
|
||||
ws_file = _workspaces_file()
|
||||
if ws_file.exists():
|
||||
try:
|
||||
return json.loads(ws_file.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
pass
|
||||
return [{'path': str(DEFAULT_WORKSPACE), 'name': 'default'}]
|
||||
# Fallback: try global file (migrates from pre-profile storage)
|
||||
if _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'}]
|
||||
|
||||
|
||||
def save_workspaces(workspaces: list):
|
||||
WORKSPACES_FILE.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
ws_file = _workspaces_file()
|
||||
ws_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
ws_file.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def get_last_workspace() -> str:
|
||||
if LAST_WORKSPACE_FILE.exists():
|
||||
lw_file = _last_workspace_file()
|
||||
if lw_file.exists():
|
||||
try:
|
||||
p = LAST_WORKSPACE_FILE.read_text(encoding='utf-8').strip()
|
||||
p = lw_file.read_text(encoding='utf-8').strip()
|
||||
if p and Path(p).is_dir():
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
return str(DEFAULT_WORKSPACE)
|
||||
# Fallback: try global file
|
||||
if _GLOBAL_LW_FILE.exists():
|
||||
try:
|
||||
p = _GLOBAL_LW_FILE.read_text(encoding='utf-8').strip()
|
||||
if p and Path(p).is_dir():
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
return _profile_default_workspace()
|
||||
|
||||
|
||||
def set_last_workspace(path: str):
|
||||
try:
|
||||
LAST_WORKSPACE_FILE.write_text(str(path), encoding='utf-8')
|
||||
lw_file = _last_workspace_file()
|
||||
lw_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
lw_file.write_text(str(path), encoding='utf-8')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -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.24</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.25</div></div></div>
|
||||
<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" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||
|
||||
@@ -663,14 +663,36 @@ async function switchToProfile(name) {
|
||||
try {
|
||||
const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) });
|
||||
S.activeProfile = data.active || name;
|
||||
syncTopbar();
|
||||
// Refresh dependent panels
|
||||
// Clear stale model pref so profile default applies
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Refresh workspace list (now profile-local)
|
||||
_workspaceList = null;
|
||||
await loadWorkspaceList();
|
||||
// Reset profile filter and refresh session list
|
||||
_showAllProfiles = false;
|
||||
await renderSessionList();
|
||||
syncTopbar();
|
||||
// Refresh visible 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); }
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ let _renamingSid = null; // session_id currently being renamed (blocks list re-
|
||||
let _showArchived = false; // toggle to show archived sessions
|
||||
let _allProjects = []; // cached project list
|
||||
let _activeProject = null; // project_id filter (null = show all)
|
||||
let _showAllProfiles = false; // false = filter to active profile only
|
||||
|
||||
async function renderSessionList(){
|
||||
try{
|
||||
@@ -111,8 +112,10 @@ function renderSessionListFromCache(){
|
||||
// Merge content matches (deduped): content matches appended after title matches
|
||||
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);
|
||||
// Filter by active project
|
||||
const projectFiltered=_activeProject?allMatched.filter(s=>s.project_id===_activeProject):allMatched;
|
||||
const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered;
|
||||
// Filter archived unless toggle is on
|
||||
const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
|
||||
const archivedCount=projectFiltered.filter(s=>s.archived).length;
|
||||
@@ -154,6 +157,21 @@ function renderSessionListFromCache(){
|
||||
bar.appendChild(addBtn);
|
||||
list.appendChild(bar);
|
||||
}
|
||||
// Profile filter toggle (show sessions from other profiles)
|
||||
const otherProfileCount=allMatched.filter(s=>s.profile&&s.profile!==S.activeProfile).length;
|
||||
if(otherProfileCount>0&&!_showAllProfiles){
|
||||
const pfToggle=document.createElement('div');
|
||||
pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
|
||||
pfToggle.textContent='Show '+otherProfileCount+' from other profiles';
|
||||
pfToggle.onclick=()=>{_showAllProfiles=true;renderSessionListFromCache();};
|
||||
list.appendChild(pfToggle);
|
||||
} else if(_showAllProfiles&&otherProfileCount>0){
|
||||
const pfToggle=document.createElement('div');
|
||||
pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
|
||||
pfToggle.textContent='Show active profile only';
|
||||
pfToggle.onclick=()=>{_showAllProfiles=false;renderSessionListFromCache();};
|
||||
list.appendChild(pfToggle);
|
||||
}
|
||||
// Show/hide archived toggle if there are archived sessions
|
||||
if(archivedCount>0){
|
||||
const toggle=document.createElement('div');
|
||||
|
||||
114
tests/test_sprint23.py
Normal file
114
tests/test_sprint23.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Sprint 23 tests: profile/workspace/model coherence."""
|
||||
import json, pathlib, re, urllib.request, urllib.error
|
||||
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
def get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
|
||||
def post(path, body=None):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(BASE + path, data=data, headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read()), e.code
|
||||
|
||||
|
||||
# ── Workspace profile-locality ──────────────────────────────────────────────
|
||||
|
||||
def test_workspace_list_returns_data():
|
||||
"""Workspace list endpoint works after profile-local refactor."""
|
||||
data, status = get("/api/workspaces")
|
||||
assert status == 200
|
||||
assert "workspaces" in data
|
||||
assert isinstance(data["workspaces"], list)
|
||||
assert "last" in data
|
||||
|
||||
|
||||
def test_workspace_add_remove_roundtrip():
|
||||
"""Workspace add/remove still works with profile-local storage."""
|
||||
import os
|
||||
# Use a path that won't resolve differently (macOS /tmp -> /private/tmp)
|
||||
resolved_tmp = str(pathlib.Path("/tmp").resolve())
|
||||
# Clean slate
|
||||
post("/api/workspaces/remove", {"path": resolved_tmp})
|
||||
# Add
|
||||
data, status = post("/api/workspaces/add", {"path": "/tmp", "name": "Temp"})
|
||||
assert status == 200
|
||||
assert any(w["path"] == resolved_tmp for w in data.get("workspaces", []))
|
||||
# Remove
|
||||
data, status = post("/api/workspaces/remove", {"path": resolved_tmp})
|
||||
assert status == 200
|
||||
assert not any(w["path"] == resolved_tmp for w in data.get("workspaces", []))
|
||||
|
||||
|
||||
# ── Profile switch response fields ─────────────────────────────────────────
|
||||
|
||||
def test_profile_switch_returns_default_model_and_workspace():
|
||||
"""switch_profile() response includes default_model and default_workspace."""
|
||||
data, status = post("/api/profile/switch", {"name": "default"})
|
||||
assert status == 200
|
||||
assert "active" in data
|
||||
assert data["active"] == "default"
|
||||
# default_workspace should always be present (may be null for model)
|
||||
assert "default_workspace" in data
|
||||
assert isinstance(data["default_workspace"], str)
|
||||
assert "default_model" in data # can be None
|
||||
|
||||
|
||||
def test_profile_active_endpoint():
|
||||
"""GET /api/profile/active returns name and path."""
|
||||
data, status = get("/api/profile/active")
|
||||
assert status == 200
|
||||
assert data["name"] == "default"
|
||||
assert "path" in data
|
||||
|
||||
|
||||
# ── Session profile tagging ────────────────────────────────────────────────
|
||||
|
||||
def test_new_session_has_profile_field():
|
||||
"""Sessions created after Sprint 22 should have a profile field."""
|
||||
data, status = post("/api/session/new", {})
|
||||
assert status == 200
|
||||
session = data["session"]
|
||||
assert "profile" in session
|
||||
# Clean up
|
||||
post("/api/session/delete", {"session_id": session["session_id"]})
|
||||
|
||||
|
||||
def test_sessions_list_includes_profile():
|
||||
"""Session list endpoint returns profile field for filtering."""
|
||||
# Create a session
|
||||
create_data, _ = post("/api/session/new", {})
|
||||
sid = create_data["session"]["session_id"]
|
||||
try:
|
||||
data, status = get("/api/sessions")
|
||||
assert status == 200
|
||||
matching = [s for s in data["sessions"] if s["session_id"] == sid]
|
||||
if matching:
|
||||
assert "profile" in matching[0]
|
||||
finally:
|
||||
post("/api/session/delete", {"session_id": sid})
|
||||
|
||||
|
||||
# ── Static JS analysis ─────────────────────────────────────────────────────
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
|
||||
def test_sessions_js_has_profile_filter():
|
||||
"""sessions.js should filter sessions by active profile."""
|
||||
content = (REPO_ROOT / "static" / "sessions.js").read_text()
|
||||
assert "_showAllProfiles" in content
|
||||
assert "profileFiltered" in content
|
||||
assert "S.activeProfile" in content
|
||||
|
||||
|
||||
def test_panels_js_clears_model_on_switch():
|
||||
"""switchToProfile() must clear localStorage model key."""
|
||||
content = (REPO_ROOT / "static" / "panels.js").read_text()
|
||||
assert "localStorage.removeItem('hermes-webui-model')" in content
|
||||
assert "loadWorkspaceList" in content
|
||||
assert "renderSessionList" in content
|
||||
Reference in New Issue
Block a user