# Sprint 23 — Profile/Workspace/Model Coherence **Goal:** Make the three systems (Profiles, Workspaces, Model picker) behave as a coherent hierarchy. Profile is the identity layer. Workspace and model are per-profile defaults that flow into per-session overrides. Switching profiles updates defaults immediately; it never retroactively changes existing sessions. **Repo:** `nesquena/hermes-webui` **Branch to create:** `feat/profile-workspace-model-coherence` **Base:** current `master` (f21b088, v0.24) **Test baseline:** 415 passing tests --- ## The Invariant (Do Not Violate) ``` Profile switch → sets new DEFAULTS for future sessions refreshes dependent UI (models list, workspace list, session list) NEVER mutates existing sessions Session create → inherits active profile's default model + default workspace tagged with active profile name Session override (mid-convo model change, workspace chip change) → affects ONLY that session does not touch profile defaults ``` --- ## What Is Broken Today (Root Cause Analysis) ### Problem 1 — Model picker ignores profile default on switch `switchToProfile()` in `static/panels.js` calls `populateModelDropdown()`, which rebuilds the dropdown and restores the model from `localStorage.getItem('hermes-webui-model')` — a single global browser key. So switching from Profile A (GPT-4) to Profile B (Claude) leaves the picker still showing GPT-4 because localStorage trumps the server default. **Root cause:** `populateModelDropdown()` has this guard: ```js if (data.default_model && !localStorage.getItem('hermes-webui-model')) { sel.value = data.default_model; } ``` The localStorage key is never cleared on profile switch, so the profile's default model never applies after the first session. ### Problem 2 — Workspace list is global, not per-profile `WORKSPACES_FILE = STATE_DIR / 'workspaces.json'` — a single file in the global state dir. `api/workspace.py:load_workspaces()` reads this file unconditionally. Switching profiles does NOT reload the workspace list. Profile A's workspaces remain visible under Profile B. `LAST_WORKSPACE_FILE = STATE_DIR / 'last_workspace.txt'` — also global. New sessions on Profile B inherit Profile A's last-used workspace. ### Problem 3 — `DEFAULT_WORKSPACE` is a process-level singleton `api/config.py` line 193: `DEFAULT_WORKSPACE = _discover_default_workspace()` — evaluated at server startup, frozen forever. `new_session()` in `api/models.py` line 71 calls `get_last_workspace()` which reads `LAST_WORKSPACE_FILE` — also global. So new sessions never get the active profile's configured default workspace. ### Problem 4 — Session list is not filtered by active profile `_allSessions` in `sessions.js` contains sessions from all profiles. Session objects have a `profile` field (added in Sprint 22) but the sidebar never filters on it. Users see other profiles' sessions mixed in. ### Problem 5 — `switchToProfile()` doesn't refresh the workspace list or session list After a switch, the workspace dropdown shows stale data. The session list still shows all profiles' sessions. Neither is refreshed. --- ## Changes Required ### 1. `api/workspace.py` — Make workspace storage profile-aware The workspace list and last-workspace pointer need to live inside the active profile's state, not in a global `STATE_DIR` file. **New helper — `_profile_workspaces_file()` and `_profile_last_workspace_file()`:** ```python def _profile_state_dir() -> Path: """Return the state dir for the active profile. Falls back to global STATE_DIR when profiles module is unavailable.""" try: from api.profiles import get_active_hermes_home home = get_active_hermes_home() # Per-profile state lives inside the profile's HERMES_HOME d = home / 'webui_state' d.mkdir(parents=True, exist_ok=True) return d except ImportError: from api.config import STATE_DIR return STATE_DIR def _workspaces_file() -> Path: return _profile_state_dir() / 'workspaces.json' def _last_workspace_file() -> Path: return _profile_state_dir() / 'last_workspace.txt' ``` **Update `load_workspaces()`** to call `_workspaces_file()` instead of `WORKSPACES_FILE`. The fallback default when the file doesn't exist should be the profile's configured default workspace, not the global `DEFAULT_WORKSPACE`: ```python def load_workspaces() -> list: f = _workspaces_file() if f.exists(): try: return json.loads(f.read_text(encoding='utf-8')) except Exception: pass # Fallback: build a single-entry list from the profile's default workspace default = _get_profile_default_workspace() return [{'path': str(default), 'name': 'default'}] ``` **New helper — `_get_profile_default_workspace()`:** ```python def _get_profile_default_workspace() -> Path: """Return the default workspace for the active profile. Priority: profile config.yaml 'workspace' key > env var > STATE_DIR/workspace.""" from api.config import get_config, DEFAULT_WORKSPACE cfg_ws = get_config().get('workspace') or get_config().get('default_workspace') if cfg_ws: p = Path(cfg_ws).expanduser() if p.is_dir(): return p return DEFAULT_WORKSPACE ``` **Update `save_workspaces()`, `get_last_workspace()`, `set_last_workspace()`** to use `_workspaces_file()` and `_last_workspace_file()` respectively. These are already small single-liners — just swap the path source. **Important:** The global `WORKSPACES_FILE` in `api/config.py` can stay as-is for backward compatibility. `api/workspace.py` simply stops importing and using it directly. **Migration:** On first call to `load_workspaces()` for a profile, if the profile-local file doesn't exist but the global `WORKSPACES_FILE` does, copy the global file's contents as the starting point for the default profile only. Non-default profiles start fresh. ### 2. `api/routes.py` — New endpoint: `GET /api/profile/default-workspace` ```python if parsed.path == '/api/profile/default-workspace': from api.workspace import _get_profile_default_workspace return j(handler, {'workspace': str(_get_profile_default_workspace())}) ``` This lets the frontend ask "what workspace should I start a new session with?" using the currently-active profile's config, not a stale server-startup value. ### 3. `api/profiles.py` — Return `default_model` and `default_workspace` on switch `switch_profile()` currently returns `{'profiles': [...], 'active': name}`. Extend the return value: ```python return { 'profiles': list_profiles_api(), 'active': name, 'default_model': _cfg.DEFAULT_MODEL, # freshly read after reload_config() 'default_workspace': str(_get_profile_default_workspace()), } ``` This gives the frontend everything it needs to update the picker and workspace chip atomically in a single round-trip — no second fetch required. `_get_profile_default_workspace()` should be imported from `api.workspace` (or duplicated as a small helper here to avoid circular imports — check carefully). ### 4. `static/panels.js` — `switchToProfile()` uses returned defaults ```js async function switchToProfile(name) { if (S.busy) { showToast('Cannot switch profiles while agent is running'); return; } try { const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) }); S.activeProfile = data.active || name; // ── Model: apply profile default, bypassing localStorage ────────────── // Profile switch is an explicit user intent to adopt this profile's model. // We clear the localStorage preference so the profile default wins. if (data.default_model) { localStorage.removeItem('hermes-webui-model'); } await populateModelDropdown(); // now respects data.default_model via server // If the session has a model set, keep it. If no active session, update the picker. const sel = $('modelSelect'); if (sel && data.default_model && !S.session) { sel.value = data.default_model; } // ── Workspace: update active workspace to profile default ───────────── if (data.default_workspace) { S._profileDefaultWorkspace = data.default_workspace; } syncTopbar(); // ── Refresh all dependent panels ────────────────────────────────────── _skillsData = null; await Promise.all([ loadWorkspaceList(), // refresh workspace list from new profile's storage ]); renderSessionListFromCache(); // re-render to show/hide by profile filter await loadSessions(); // fetch sessions tagged to the new profile if (_currentPanel === 'skills') await loadSkills(); if (_currentPanel === 'memory') await loadMemory(); if (_currentPanel === 'tasks') await loadCrons(); if (_currentPanel === 'profiles') await loadProfilesPanel(); showToast('Switched to profile: ' + name); } catch (e) { showToast('Switch failed: ' + e.message); } } ``` ### 5. `static/sessions.js` — Filter session list by active profile The session list should default to showing only the current profile's sessions. Add a toggle to show all profiles. **State variable** (add near `_activeProject`): ```js let _profileFilter = true; // true = show only active profile's sessions ``` **Filter application** (inside `renderSessionListFromCache()`, after project filter): ```js // Profile filter — show only sessions tagged to the active profile // (sessions with profile=null are legacy pre-Sprint22, always shown) const profileFiltered = _profileFilter ? projectFiltered.filter(s => !s.profile || s.profile === S.activeProfile) : projectFiltered; ``` **Toggle button** — add a small "All profiles" / "This profile" toggle in the session list header, similar to the existing archived toggle. When clicked, flips `_profileFilter` and calls `renderSessionListFromCache()`. No server round-trip needed. **On profile switch** — reset `_profileFilter = true` and call `renderSessionListFromCache()` so you immediately see only the new profile's sessions. ### 6. `static/panels.js` — `renderWorkspaceDropdown()` refresh on profile switch `loadWorkspaceList()` already calls `GET /api/workspaces`. Since `api/workspace.py` will now read from the profile-local file, calling `loadWorkspaceList()` after a profile switch is sufficient — no other changes needed in the dropdown renderer. However, the workspace chip in the topbar shows the current session's workspace, which should not change on profile switch. Only the *dropdown list* (available options) should update. This is already the correct behavior — the chip reads `S.session.workspace`, not the list. ### 7. `api/models.py` — `new_session()` uses profile default workspace ```python def new_session(workspace=None, model=None): try: from api.profiles import get_active_profile_name _profile = get_active_profile_name() except ImportError: _profile = None # Use profile's default workspace, not the global last_workspace if workspace is None: try: from api.workspace import _get_profile_default_workspace, get_last_workspace # last_workspace is now profile-local too, so this is correct workspace = get_last_workspace() except Exception: from api.config import DEFAULT_WORKSPACE workspace = str(DEFAULT_WORKSPACE) s = Session( workspace=workspace, model=model or _cfg.DEFAULT_MODEL, profile=_profile, ) ... ``` Note: since `get_last_workspace()` will be profile-local after change #1, this effectively already does the right thing. The explicit comment is just for clarity. --- ## What NOT to Change - **The workspace chip on the topbar** — it shows the current session's workspace, not a profile default. This is correct. Don't change it. - **Per-session model overrides** — the session's `model` field should not be touched on profile switch. Already correct. - **The `WORKSPACES_FILE` import in `api/config.py`** — leave it. It's used by the settings serialization and possibly conftest.py. `api/workspace.py` simply stops importing it directly. - **Auth, streaming, profiles.py lock logic** — untouched. - **The `profile` field on Session** — already exists from Sprint 22. Just start using it in the filter. --- ## New Tests Required File: `tests/test_sprint23.py` ``` test_workspace_file_is_profile_local — GET /api/workspaces before and after profile switch return different lists (after saving different workspaces to each profile's state dir) test_new_session_inherits_profile_default_workspace — Create session with no workspace arg; verify session.workspace matches the active profile's configured workspace test_switch_profile_response_includes_default_model — POST /api/profile/switch returns default_model field test_switch_profile_response_includes_default_workspace — POST /api/profile/switch returns default_workspace field test_session_list_profile_field — Sessions created under different profiles have correct profile field test_profile_filter_excludes_other_profiles — Static analysis: sessions.js renderSessionListFromCache contains '_profileFilter' and 's.profile === S.activeProfile' test_workspace_list_reload_on_switch — Static analysis: switchToProfile() in panels.js calls loadWorkspaceList() test_model_localstorage_cleared_on_switch — Static analysis: switchToProfile() in panels.js calls localStorage.removeItem('hermes-webui-model') ``` --- ## File Change Summary | File | Change | |------|--------| | `api/workspace.py` | Make `load_workspaces`, `save_workspaces`, `get_last_workspace`, `set_last_workspace` read/write from profile-local paths. Add `_get_profile_default_workspace()`. Add migration for default profile. | | `api/profiles.py` | `switch_profile()` returns `default_model` and `default_workspace` in response. | | `api/routes.py` | Add `GET /api/profile/default-workspace` endpoint. | | `api/models.py` | `new_session()` comment clarification only (behavior already correct after workspace.py fix). | | `static/panels.js` | `switchToProfile()`: clear localStorage model key, call `loadWorkspaceList()`, call `loadSessions()`, reset profile filter. | | `static/sessions.js` | Add `_profileFilter` state, filter `renderSessionListFromCache()` by active profile, add "All profiles" toggle button, reset filter on profile switch. | | `tests/test_sprint23.py` | New test file with 8 tests. | --- ## Explicit Non-Goals (Out of Scope for Sprint 23) - Migrating existing sessions from "no profile tag" to "default profile" — they stay untagged and are shown under all profiles (the `!s.profile` guard handles this). - Per-profile session storage directories — sessions stay in the global `SESSION_DIR`. The profile tag on the session object is sufficient for filtering. - UI for setting a profile's default workspace (that's a settings panel feature, Sprint 24). - Disabling workspaces by default — rejected. The workspace is the agent's `cwd`; hiding it doesn't simplify things, it makes the default silently wrong. --- ## Circular Import Warning `api/workspace.py` will import from `api.profiles`. `api/profiles.py` imports from `api.config`. `api/config.py` imports from `api.profiles` (already, for `get_config()`). To avoid a new circular: `api/workspace.py` should use a **deferred import** inside the helper functions, not a top-level import. The pattern already exists in `api/profiles.py` and `api/models.py`. Example: ```python def _profile_state_dir() -> Path: try: from api.profiles import get_active_hermes_home # deferred — avoid circular ... ``` Do NOT add `from api.profiles import ...` at the top level of `workspace.py`. --- ## How to Verify End-to-End (Manual Checklist) After implementation, verify these flows in the browser: 1. **Profile switch updates model picker** - Set Profile A's config.yaml: `model: anthropic/claude-opus-4-5` - Set Profile B's config.yaml: `model: openai/gpt-5.4-mini` - Load the UI. Switch to Profile A. Picker should show Claude. - Switch to Profile B. Picker should show GPT-4o-mini. - Verify: changing the picker manually doesn't affect the other profile. 2. **Profile switch updates workspace list** - Add workspace `/tmp/work-a` to Profile A via the workspace dropdown. - Switch to Profile B. Open workspace dropdown. `/tmp/work-a` should NOT appear. - Add `/tmp/work-b` to Profile B. Switch back to Profile A. Only A's workspaces appear. 3. **New session inherits profile's default workspace** - While on Profile B (default workspace: `/tmp/work-b`), create a new session. - Session workspace chip should show `/tmp/work-b`, not a stale Profile A path. 4. **Session list filters by profile** - Create 2 sessions on Profile A, 2 sessions on Profile B. - While on Profile B, sidebar shows only Profile B's 2 sessions. - Toggle "All profiles" — all 4 appear. 5. **Existing sessions survive profile switch unmodified** - Open Session X on Profile A (model: Claude, workspace: `/tmp/work-a`). - Switch to Profile B. - Switch back to Profile A and reopen Session X. - Model and workspace should be unchanged. --- ## Commit Message Template ``` feat: Sprint 23 — profile/workspace/model coherence - Workspaces are now profile-local: each profile's workspace list and last-workspace pointer live in {profile_home}/webui_state/ instead of the global STATE_DIR. Switching profiles reloads the correct workspace list. - Profile switch response now includes default_model and default_workspace, so the frontend can update the model picker and session defaults in one round-trip. - Model picker: switching profiles clears the localStorage preference and applies the new profile's default model. Per-session overrides are unaffected. - Session list: filtered to active profile by default with an "All profiles" toggle. Sessions from before Sprint 22 (no profile tag) always shown. - new_session() inherits the active profile's default workspace via the now-profile-local get_last_workspace(). Tests: N passed, 0 failed (+8 new tests in test_sprint23.py). Co-Authored-By: ``` --- ## Key Existing Code Locations (for the implementing agent) ``` api/workspace.py — load_workspaces, save_workspaces, get/set_last_workspace api/config.py:38 — WORKSPACES_FILE = STATE_DIR / 'workspaces.json' api/config.py:41 — LAST_WORKSPACE_FILE = STATE_DIR / 'last_workspace.txt' api/config.py:194 — DEFAULT_MODEL = os.getenv(...) api/config.py:193 — DEFAULT_WORKSPACE = _discover_default_workspace() api/config.py:677-690 — save_settings() updates DEFAULT_MODEL / DEFAULT_WORKSPACE globals api/models.py:64-71 — new_session() — uses get_last_workspace() and _cfg.DEFAULT_MODEL api/models.py:37 — Session.__init__ — has profile=None field api/profiles.py:100-135 — switch_profile() — returns {'profiles':[], 'active': name} api/profiles.py:155-180 — list_profiles_api() — has p.model per profile api/routes.py:169-170 — GET /api/workspaces api/routes.py:360-367 — workspace add/remove/rename endpoints api/routes.py:385-421 — profile switch/create/delete endpoints static/panels.js:659-680 — switchToProfile() — currently missing ws/session refresh static/panels.js:474-492 — toggleWsDropdown() / closeWsDropdown() static/sessions.js:67 — _allSessions cache static/sessions.js:89-120 — filterSessions() / renderSessionListFromCache() static/sessions.js:71 — _activeProject filter (model for _profileFilter) static/ui.js:10-44 — populateModelDropdown() — has the localStorage guard ```