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:
50
CHANGELOG.md
50
CHANGELOG.md
@@ -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
|
## [v0.25] Sprint 23 -- Profile/Workspace/Model Coherence
|
||||||
*April 3, 2026 | 423 tests*
|
*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*
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Hermes Web UI -- Forward Sprint Plan
|
# 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:
|
> 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
|
> 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*
|
*Last updated: April 3, 2026*
|
||||||
*Current version: v0.25 | 423 tests*
|
*Current version: v0.26 | 426 tests*
|
||||||
*Next sprint: Sprint 24 (Desktop Application)*
|
*Next sprint: Sprint 24 (Desktop Application)*
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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.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">
|
<div class="sidebar-nav">
|
||||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user