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:
Nathan Esquenazi
2026-04-03 11:46:15 -07:00
parent 0480bbf34c
commit 3520fa5643
8 changed files with 359 additions and 17 deletions

View File

@@ -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) ## [v0.24] Sprint 22 -- Multi-Profile Support (Issue #28)
*April 3, 2026 | 415 tests* *April 3, 2026 | 415 tests*

View File

@@ -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. **Theme:** Native desktop experience.
@@ -607,5 +663,5 @@ single default profile, blocking multi-persona workflows.
--- ---
*Last updated: April 3, 2026* *Last updated: April 3, 2026*
*Current version: v0.24 | 415 tests* *Current version: v0.25 | 423 tests*
*Next sprint: Sprint 23 (Desktop Application)* *Next sprint: Sprint 24 (Desktop Application)*

View File

@@ -148,7 +148,23 @@ def switch_profile(name: str) -> dict:
# Reload config.yaml from the new profile # Reload config.yaml from the new profile
reload_config() 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: def list_profiles_api() -> list:

View File

@@ -1,43 +1,123 @@
""" """
Hermes Web UI -- Workspace and file system helpers. 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 json
import os import os
from pathlib import Path from pathlib import Path
from api.config import ( 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 MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS
) )
# ── 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:
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: def load_workspaces() -> list:
if WORKSPACES_FILE.exists(): ws_file = _workspaces_file()
if ws_file.exists():
try: try:
return json.loads(WORKSPACES_FILE.read_text(encoding='utf-8')) return json.loads(ws_file.read_text(encoding='utf-8'))
except Exception: except Exception:
pass 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): 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: def get_last_workspace() -> str:
if LAST_WORKSPACE_FILE.exists(): lw_file = _last_workspace_file()
if lw_file.exists():
try: 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(): if p and Path(p).is_dir():
return p return p
except Exception: except Exception:
pass 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): def set_last_workspace(path: str):
try: 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: except Exception:
pass pass

View File

@@ -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.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"> <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 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> <button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button>

View File

@@ -663,14 +663,36 @@ async function switchToProfile(name) {
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;
syncTopbar(); // Clear stale model pref so profile default applies
// Refresh dependent panels 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 && $('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 === '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();
showToast('Switched to profile: ' + name); showToast('Switched to profile: ' + name);
} catch (e) { showToast('Switch failed: ' + e.message); } } catch (e) { showToast('Switch failed: ' + e.message); }
} }

View File

@@ -69,6 +69,7 @@ let _renamingSid = null; // session_id currently being renamed (blocks list re-
let _showArchived = false; // toggle to show archived sessions let _showArchived = false; // toggle to show archived sessions
let _allProjects = []; // cached project list let _allProjects = []; // cached project list
let _activeProject = null; // project_id filter (null = show all) let _activeProject = null; // project_id filter (null = show all)
let _showAllProfiles = false; // false = filter to active profile only
async function renderSessionList(){ async function renderSessionList(){
try{ try{
@@ -111,8 +112,10 @@ function renderSessionListFromCache(){
// Merge content matches (deduped): content matches appended after title matches // Merge content matches (deduped): content matches appended after title matches
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)
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>!s.profile||s.profile===S.activeProfile);
// Filter by active project // 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 // Filter archived unless toggle is on
const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived); const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
const archivedCount=projectFiltered.filter(s=>s.archived).length; const archivedCount=projectFiltered.filter(s=>s.archived).length;
@@ -154,6 +157,21 @@ function renderSessionListFromCache(){
bar.appendChild(addBtn); bar.appendChild(addBtn);
list.appendChild(bar); 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 // Show/hide archived toggle if there are archived sessions
if(archivedCount>0){ if(archivedCount>0){
const toggle=document.createElement('div'); const toggle=document.createElement('div');

114
tests/test_sprint23.py Normal file
View 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