diff --git a/CHANGELOG.md b/CHANGELOG.md index b971045..bff474b 100644 --- a/CHANGELOG.md +++ b/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* diff --git a/SPRINTS.md b/SPRINTS.md index 3f22231..e9c6c83 100644 --- a/SPRINTS.md +++ b/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)* diff --git a/api/profiles.py b/api/profiles.py index 47559ff..95b5622 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -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: diff --git a/api/workspace.py b/api/workspace.py index f275593..d498662 100644 --- a/api/workspace.py +++ b/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 ) +# ── 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: - if WORKSPACES_FILE.exists(): + ws_file = _workspaces_file() + if ws_file.exists(): try: - return json.loads(WORKSPACES_FILE.read_text(encoding='utf-8')) + 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 diff --git a/static/index.html b/static/index.html index 7c350c1..a4f87ee 100644 --- a/static/index.html +++ b/static/index.html @@ -13,7 +13,7 @@