docs: v0.26 release notes, remove planning artifact, update versions

- Add v0.26 CHANGELOG entry (10 post-Sprint-23 fixes)
- Remove SPRINT_23_PLAN.md (planning artifact, not runtime docs)
- Bump version label to v0.26 in index.html
- Update SPRINTS header and footer to v0.26 / 426 tests
- Update CHANGELOG footer to v0.26 / 426 tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Esquenazi
2026-04-03 13:44:06 -07:00
parent 733957cea1
commit 5c9edfc7bf
4 changed files with 52 additions and 482 deletions

View File

@@ -5,6 +5,54 @@
---
## [v0.26] Profile System Polish -- 10 Post-Sprint-23 Fixes
*April 3, 2026 | 426 tests*
### Bug Fixes
- **Profile switch base dir bug.** When `HERMES_HOME` was mutated to a
`profiles/` subdir at startup, `switch_profile()` doubled the path
(e.g. `~/.hermes/profiles/X/profiles/X`). New `_resolve_base_hermes_home()`
detects profile subdirs and walks up to the actual base.
- **Cross-provider model routing.** Picking a model from a different provider
than the config's default now routes through OpenRouter instead of trying
a direct API call to a provider whose key may not exist.
- **Legacy sessions missing profile tag.** `all_sessions()` now backfills
`profile='default'` for pre-Sprint-22 sessions so the profile filter works.
- **Workspace list cleanup.** Stale paths, test artifacts, and cross-profile
entries are now cleaned on load. Legacy global workspace file migrated
once for the default profile.
- **API error messages.** `api()` helper now parses JSON error bodies and
surfaces the human-readable message instead of raw JSON.
- **Workspace dropdown moved to sidebar.** The workspace picker now opens
upward from the sidebar bottom instead of clipping behind the topbar.
### Features
- **Rate limit error display.** Rate limit errors (429) now show a distinct
card with a rate limit icon and hint, instead of the generic error message.
- **SSE `apperror`/`warning` events.** Server can send typed error events
that the frontend handles with appropriate UX (rate limit card, fallback
notice, etc.).
- **Smart model resolver.** `_findModelInDropdown()` handles name mismatches
between config model IDs and dropdown values (e.g. `claude-sonnet-4-6` vs
`anthropic/claude-sonnet-4.6`).
- **Profile switch starts new session.** When the current session has messages,
switching profiles automatically starts a fresh session to prevent
cross-profile tagging.
- **Per-profile toolsets.** Agent now reads `platform_toolsets.cli` from the
active profile's config at call time, not the boot-time snapshot.
- **Per-profile fallback model.** `fallback_model` config is read from the
active profile and passed to AIAgent.
### Architecture
- `api/profiles.py`: `_resolve_base_hermes_home()` replaces naive env var read.
- `api/workspace.py`: `_clean_workspace_list()`, `_migrate_global_workspaces()`.
- `api/streaming.py`: Per-profile toolsets and fallback model at call time.
- `api/models.py`: `all_sessions()` backfills `profile='default'`.
- `static/ui.js`: `_findModelInDropdown()`, `_applyModelToDropdown()`.
- `static/messages.js`: `apperror` and `warning` SSE event handlers.
---
## [v0.25] Sprint 23 -- Profile/Workspace/Model Coherence
*April 3, 2026 | 423 tests*
@@ -829,4 +877,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
---
*Last updated: v0.25, April 3, 2026 | Tests: 423*
*Last updated: v0.26, April 3, 2026 | Tests: 426*

View File

@@ -1,6 +1,6 @@
# Hermes Web UI -- Forward Sprint Plan
> Current state: v0.25 | 423 tests | Daily driver ready
> Current state: v0.26 | 426 tests | Daily driver ready
> This document plans the path from here to two targets:
>
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
@@ -663,5 +663,5 @@ and switchToProfile() didn't refresh workspaces or sessions.
---
*Last updated: April 3, 2026*
*Current version: v0.25 | 423 tests*
*Current version: v0.26 | 426 tests*
*Next sprint: Sprint 24 (Desktop Application)*

View File

@@ -1,478 +0,0 @@
# 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: <agent>
```
---
## 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
```

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.25</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.26</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>