fix(ui): workspace panel persists across reload on empty-session boot#1187
fix(ui): workspace panel persists across reload on empty-session boot#1187nesquena-hermes wants to merge 2 commits intomasterfrom
Conversation
… (#ws-persist)
When a user has the workspace panel open and refreshes the page after
clicking New Conversation (or on any empty/no-session boot), the panel
was unconditionally closed and the toggle button disabled — leaving them
with no way to reopen it except clicking a past conversation first.
Root cause: three issues in boot.js working together:
1. syncWorkspacePanelState() had a hard rule:
if(!S.session) → _setWorkspacePanelMode('closed')
This always closed the panel when there was no active session, ignoring
whether the user had explicitly opened it.
2. The ephemeral-session guard path (refresh after New Conversation with
no messages) called syncWorkspacePanelState() without first restoring
the panel preference from localStorage — so _workspacePanelMode was
still 'closed' going in, and the rule above left it closed.
3. The no-saved-session path (truly fresh load) had the same omission.
Fixes:
1. syncWorkspacePanelState(): when S.session is null and mode is 'browse',
call syncWorkspacePanelUI() instead of force-closing. Only 'preview'
mode (which requires an active session for the file content) is closed.
2. Both the ephemeral guard and no-saved-session paths now read panelPref
from localStorage and set _workspacePanelMode='browse' before calling
syncWorkspacePanelState() — matching exactly what the session-restore
path already does.
3. canBrowse in syncWorkspacePanelUI() now includes S._profileDefaultWorkspace
so the toggle button stays enabled when a profile workspace is configured
(allows user to reopen the panel via button click).
4. openWorkspacePanel() guard updated to allow browse mode when
S._profileDefaultWorkspace is set (same condition as canBrowse).
Affected paths:
- Reload after clicking New Conversation (no messages sent yet)
- First-ever page load with workspace pref='open'
- Any reload where no session is stored in localStorage
Test updated: test_boot_sets_profile_default_workspace_in_settings_block
now searches for the specific assignment expression
(S._profileDefaultWorkspace=s.default_workspace) rather than any use of
the identifier — our new uses of _profileDefaultWorkspace further down
in the file were pushing the first-find far away from the settings block.
|
Good fix, clean targeted changes. A few notes: Root cause analysis looks right: The three-way interaction in Fix for
Test update: The Scenario matrix in the PR description covers all the paths cleanly. The "Reload after empty New Conversation" case is the most common user-facing trigger for this bug. 👍 |
Static regression tests asserting the four invariants that prevent the
workspace panel from being silently force-closed on empty-session and
no-session boot paths:
1. syncWorkspacePanelState force-closes only 'preview' mode without a
session — 'browse' mode runs through syncWorkspacePanelUI() so the
panel renders rather than vanishes.
2. Both the empty-session guard path (#1182) and the no-saved-session
path read 'hermes-webui-workspace-panel-pref' from localStorage
before calling syncWorkspacePanelState().
3. canBrowse in syncWorkspacePanelUI() includes
S._profileDefaultWorkspace so the toggle button stays enabled when
a profile workspace is configured.
4. openWorkspacePanel('browse') early-return guard also includes
S._profileDefaultWorkspace so the toggle button can actually open
the panel.
These tests would have caught the original bug introduced when the
empty-session guard was added in #1182 without the corresponding panel
pref restoration.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
nesquena
left a comment
There was a problem hiding this comment.
Review — end-to-end ✅ (clean approve, regression test pushed)
What this ships
Fixes a regression introduced by #1182's empty-session guard: when a user has the workspace panel open and reloads the page after clicking New Conversation (without sending a message), the panel gets stuck closed and the toggle button is disabled. Recovery requires clicking a past conversation.
Four targeted changes in static/boot.js:
-
syncWorkspacePanelState()(boot.js:62-75) — whenS.sessionis null, only force-close if mode is'preview'(which legitimately requires a session). For'browse'mode, callsyncWorkspacePanelUI()instead so the panel renders its no-workspace placeholder rather than vanishing. -
Ephemeral-session boot path (boot.js:908-912) — read
panelPreffrom localStorage and set_workspacePanelMode='browse'BEFORE the sync call, matching what the existing session-restore path at boot.js:921-925 already does. -
No-saved-session boot path (boot.js:933-937) — same pref-restore.
-
canBrowseandopenWorkspacePanel()— extended to includeS._profileDefaultWorkspaceso the toggle stays enabled and clickable when a profile workspace is configured.
Traced against upstream hermes-agent
Pure WebUI frontend behaviour. Zero agent coupling.
End-to-end trace
syncWorkspacePanelState() semantics
Pre-fix: if(!S.session){ _setWorkspacePanelMode('closed'); return; } — unconditional close when no session.
Post-fix:
if(!S.session){
if(_workspacePanelMode==='preview') _setWorkspacePanelMode('closed');
else syncWorkspacePanelUI();
return;
}The mode-state machine has three values: 'closed', 'browse', 'preview'. With no session:
'preview': needs file content from a session → close ✅'browse': render panel chrome (file tree, no-workspace placeholder, profile-default tree if configured) → keep open ✅'closed': render closed UI → no-op viasyncWorkspacePanelUI()✅
localStorage pref-key parity
The new ephemeral and no-session paths read both keys with ||:
localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';This matches the existing session-restore path at boot.js:921-922. The two keys are documented in panels.js:2408-2417:
hermes-webui-workspace-panel-pref— user-preference checkbox in settings (priority key)hermes-webui-workspace-panel— runtime state from toolbar toggle
Pref takes priority so closing via toolbar X doesn't suppress the "keep open" setting.
Toggle-stays-enabled extension
canBrowse at boot.js:109:
const canBrowse=!!S.session||_hasWorkspacePreviewVisible()||!!(S._profileDefaultWorkspace);openWorkspacePanel() early-return at boot.js:77:
if(mode==='browse'&&!S.session&&!_hasWorkspacePreviewVisible()&&!S._profileDefaultWorkspace)return;Both gates now include S._profileDefaultWorkspace, set during the settings fetch at boot.js:822 which runs well before the IIFE.
Regression-cause confirmation
Bug timing: introduced by #1182's empty-session guard, exposed when a user had the panel open before reloading. The session-restore path at boot.js:921-925 already restored panelPref correctly, but #1182 added two new syncWorkspacePanelState() call sites (the ephemeral guard and the no-session path) that didn't.
Edge-case trace
| Scenario | Pre-fix | Post-fix |
|---|---|---|
| Reload after empty New Conversation, panel was open | Panel closes, toggle disabled | Panel stays open ✅ |
| Reload after empty New Conversation, panel was closed | Closed | Closed ✅ |
| Fresh page load, pref='open', no profile workspace | Closed | Closed (canBrowse=false → toggle disabled) ✅ |
| Fresh page load, pref='open', profile workspace set | Closed | Open with file tree ✅ |
| Reload after session WITH workspace, panel was open | Open | Open ✅ (existing path unchanged) |
| Reload, no session, mode was 'preview' | Closed | Closed ✅ (preview legitimately needs session) |
| User had panel open, manually closed via X, reload | Closed (pref='open' overrides) | Open ✅ (matches pref-priority rule) |
| Click "Open Panel" with profile workspace, no session | early-return blocked | Opens ✅ |
What I pushed — 2fbbb8d
Six static-analysis tests in tests/test_workspace_panel_persists_on_empty_boot.py locking the four invariants:
syncWorkspacePanelStatechecks_workspacePanelMode==='preview'before force-closing- The non-preview branch calls
syncWorkspacePanelUI()rather than force-closing - Both new boot paths read
'hermes-webui-workspace-panel-pref'and set_workspacePanelMode='browse'before sync canBrowseandopenWorkspacePanelearly-return both includeS._profileDefaultWorkspace
These would have caught the original regression — they're exactly the invariants #1182 inadvertently broke.
CI re-ran green on 2fbbb8d: 3.11 ✅, 3.12 ✅, 3.13 ✅.
Tests
- PR's targeted test update (
test_boot_sets_profile_default_workspace_in_settings_block): correctly tightened from "find first occurrence" to "find specific assignment expression" — the new_profileDefaultWorkspacereferences in canBrowse/openWorkspacePanel were pushing the first-find away from the settings block. - My 6 new tests: pass.
- Local full suite: 2643 passed, 47 skipped, 1 PR-unrelated pre-existing failure (
test_sprint3.py::test_workspace_add_rejects_system_paths— the macOS-only quirk that PR #1186 fixes; this PR is on top of master before #1186 lands). - CI on PR: ✅ test (3.11), ✅ test (3.12), ✅ test (3.13).
Other audit — confirmed correct
- JS syntax:
node --checkpasses onboot.js. - No agent-side coupling: pure UI state machine.
- No race against settings fetch:
S._profileDefaultWorkspaceis set at line 822 in the settings fetch IIFE, well before the boot path runs around line 880-940. loadSessiondoesn't mutate_workspacePanelMode: confirmed via grep — so the pre-sync mode set in the ephemeral path survives loadSession's call.
Minor observations (non-blocking)
- The
panelPrefread pattern is now duplicated three times (existing session-restore path + two new paths). A small DRY helper (_readPanelPref()) would tighten this. Cosmetic — easy refactor in a follow-up. - The two pref-key fallback (
-prefAND non-pref) is documented inpanels.jsbut the rationale ("pref priority over runtime state") is implicit in the||order. A one-line comment near each read site noting the priority semantics would help future readers. The existing session-restore path has this comment; the two new sites could pick it up too.
Recommendation
Approved. Tight, well-scoped fix for a real regression. The PR description's scenario table accurately characterises the four affected paths. State-machine reasoning ('preview' close vs 'browse' keep) is correct. The _profileDefaultWorkspace extension in canBrowse/openWorkspacePanel keeps the UI consistent (toggle enabled when there's something to show). Pushed 6 regression tests locking the new invariants. CI green; no agent coupling. Parked at approval — ready for the release agent's merge/tag pipeline.
…code leak (#1194) Batch release v0.50.231 — 3 fixes. ## PRs included | PR | Author | Fix | |---|---|---| | #1186 | @nesquena (Claude Code) | macOS `/etc` symlink bypass in workspace blocked-roots | | #1187 | @nesquena-hermes | Workspace panel stuck closed after empty-session reload | | #1190 | @bergeouss | Fenced code content leaking into markdown passes (#1154) | All three PRs were independently reviewed and approved by @nesquena. ## Test results **2729 passed, 2 skipped** (2 macOS-only tests correctly skipped on Linux). Browser QA: **21/21**. ## Key fix notes **#1186:** `_workspace_blocked_roots()` now returns both literal and `Path.resolve()` forms of each blocked root. macOS symlinks (`/etc → /private/etc`) previously let a resolved candidate slip past the literal check. New `_is_blocked_system_path()` helper with `/var/folders` and `/var/tmp` carve-outs for pytest temp dirs. **#1187:** Regression from #1182 — `syncWorkspacePanelState()` force-closed on any no-session state. Now only closes in `'preview'` mode. Both boot paths restore localStorage panel pref before sync. **#1190:** Fenced code blocks are now stashed as `\x00P<n>\x00` tokens through ALL markdown passes (list/heading/table regexes), restored at the very end. Previously, diff hunks and markdown headings inside code blocks triggered those regexes, injecting `<ul>/<li>/<h>` tags that broke `</pre>` closure.
|
Merged as v0.50.231 via #1194. Thank you @nesquena-hermes! |
Three static checks that catch the exact regression PR #1196 fixes: 1. The empty-session guard MUST NOT remove 'hermes-webui-session' from localStorage — that's the line whose presence broke the second refresh (no-saved-session path doesn't call loadDir). 2. The guard MUST still clear S.session=null and S.messages=[] so the empty scratch pad isn't surfaced as the active conversation. 3. The guard MUST still read 'hermes-webui-workspace-panel-pref' so PR #1187's panel-persist behaviour remains intact. Together they pin the contract: the localStorage key stays (this PR), the in-memory state clears (#1182), and the panel pref restores (#1187). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…code leak (nesquena#1194) Batch release v0.50.231 — 3 fixes. ## PRs included | PR | Author | Fix | |---|---|---| | nesquena#1186 | @nesquena (Claude Code) | macOS `/etc` symlink bypass in workspace blocked-roots | | nesquena#1187 | @nesquena-hermes | Workspace panel stuck closed after empty-session reload | | nesquena#1190 | @bergeouss | Fenced code content leaking into markdown passes (nesquena#1154) | All three PRs were independently reviewed and approved by @nesquena. ## Test results **2729 passed, 2 skipped** (2 macOS-only tests correctly skipped on Linux). Browser QA: **21/21**. ## Key fix notes **nesquena#1186:** `_workspace_blocked_roots()` now returns both literal and `Path.resolve()` forms of each blocked root. macOS symlinks (`/etc → /private/etc`) previously let a resolved candidate slip past the literal check. New `_is_blocked_system_path()` helper with `/var/folders` and `/var/tmp` carve-outs for pytest temp dirs. **nesquena#1187:** Regression from nesquena#1182 — `syncWorkspacePanelState()` force-closed on any no-session state. Now only closes in `'preview'` mode. Both boot paths restore localStorage panel pref before sync. **nesquena#1190:** Fenced code blocks are now stashed as `\x00P<n>\x00` tokens through ALL markdown passes (list/heading/table regexes), restored at the very end. Previously, diff hunks and markdown headings inside code blocks triggered those regexes, injecting `<ul>/<li>/<h>` tags that broke `</pre>` closure.
Summary
Fixes a bug where the workspace panel closes and stays stuck closed after:
After the bug triggers, the workspace toggle button is disabled and clicking it shows the "no workspace" icon. The only recovery is clicking a past conversation with a workspace. This was especially jarring because the workspace had been working fine before the refresh.
Root cause
Three issues in
boot.jsworking together:1.
syncWorkspacePanelState()always closed the panel with no session:2. The ephemeral-session path (refresh after empty New Conversation) didn't restore
panelPrefbefore calling sync:3. Same omission in the no-saved-session path (fresh load, no localStorage ID).
The session-restore path (loading an existing conversation) already did this correctly — it sets
_workspacePanelMode='browse'frompanelPrefbefore callingsyncWorkspacePanelState(). The two new-session paths simply didn't.Fix
Four targeted changes, all in
static/boot.js:1.
syncWorkspacePanelState()— whenS.sessionis null and mode is alreadybrowse, callsyncWorkspacePanelUI()instead of force-closing. Onlypreviewmode (which requires a session to fetch file content) gets closed:2 & 3. Ephemeral + no-session boot paths — read
panelPreffrom localStorage and apply it before callingsyncWorkspacePanelState(), exactly matching the session-restore path.4.
canBrowse/openWorkspacePanel()— includeS._profileDefaultWorkspaceso the toggle button stays enabled and clickable when a profile workspace is configured.Affected scenarios
Testing
test_boot_sets_profile_default_workspace_in_settings_blocknow finds the specific assignment expression rather than any usage of the identifier