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)
*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.
@@ -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)*

View File

@@ -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:

View File

@@ -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

View File

@@ -13,7 +13,7 @@
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.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">&#128172;</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 {
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); }
}

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 _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
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