Skip to content

fix(ui): workspace panel persists across reload on empty-session boot#1187

Closed
nesquena-hermes wants to merge 2 commits intomasterfrom
fix/workspace-panel-persists-on-empty-boot
Closed

fix(ui): workspace panel persists across reload on empty-session boot#1187
nesquena-hermes wants to merge 2 commits intomasterfrom
fix/workspace-panel-persists-on-empty-boot

Conversation

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Summary

Fixes a bug where the workspace panel closes and stays stuck closed after:

  • Refreshing the page after clicking New Conversation (with no messages sent)
  • Any page load where no session is stored in localStorage

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.js working together:

1. syncWorkspacePanelState() always closed the panel with no session:

if(!S.session){
  _setWorkspacePanelMode('closed');  // always, regardless of pref
  return;
}

2. The ephemeral-session path (refresh after empty New Conversation) didn't restore panelPref before calling sync:

// Our PR #1182 added this path — correctly clears the empty session on reload,
// but missed restoring the workspace panel preference first.
S.session = null; S.messages = [];
syncTopbar(); syncWorkspacePanelState();  // ← _workspacePanelMode still 'closed', gets force-closed

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' from panelPref before calling syncWorkspacePanelState(). The two new-session paths simply didn't.

Fix

Four targeted changes, all in static/boot.js:

1. syncWorkspacePanelState() — when S.session is null and mode is already browse, call syncWorkspacePanelUI() instead of force-closing. Only preview mode (which requires a session to fetch file content) gets closed:

if(!S.session){
  if(_workspacePanelMode==='preview') _setWorkspacePanelMode('closed');
  else syncWorkspacePanelUI();  // keep browse mode alive
  return;
}

2 & 3. Ephemeral + no-session boot paths — read panelPref from localStorage and apply it before calling syncWorkspacePanelState(), exactly matching the session-restore path.

4. canBrowse / openWorkspacePanel() — include S._profileDefaultWorkspace so the toggle button stays enabled and clickable when a profile workspace is configured.

Affected scenarios

Scenario Before After
Reload after empty New Conversation Panel closes, button disabled Panel stays open ✅
First-ever load with pref='open' Panel closed Panel opens ✅
Reload after session with workspace ✅ worked ✅ still works
Clicking past conversation ✅ worked ✅ still works

Testing

  • 2685 tests passing
  • Test updated: test_boot_sets_profile_default_workspace_in_settings_block now finds the specific assignment expression rather than any usage of the identifier

… (#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.
@nesquena-hermes
Copy link
Copy Markdown
Collaborator Author

Good fix, clean targeted changes. A few notes:

Root cause analysis looks right: The three-way interaction in boot.js was subtle — syncWorkspacePanelState() had a hard if(!S.session) → force close rule, but the two new-session paths (ephemeral empty session + no saved session) never had a chance to set _workspacePanelMode from panelPref before that check ran. The session-restore path already did this correctly, so the fix is essentially normalising all boot paths to the same pattern.

Fix for preview mode: Keeping the force-close only for preview mode when there's no session makes sense — preview requires an active session to fetch file content, so there's nothing to display. browse mode can stay alive since it just shows the directory listing of the configured workspace.

canBrowse / openWorkspacePanel() expansion: Including S._profileDefaultWorkspace in the canBrowse condition is the right companion fix — without it, a user with a profile workspace but no active session would see the panel open but the toggle button disabled, which is confusing.

Test update: The test_boot_sets_profile_default_workspace_in_settings_block change (searching for the specific assignment expression rather than any identifier use) is a good test hygiene improvement — the original first-find approach was fragile given new uses of _profileDefaultWorkspace elsewhere in the file.

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]>
Copy link
Copy Markdown
Owner

@nesquena nesquena left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. syncWorkspacePanelState() (boot.js:62-75) — when S.session is null, only force-close if mode is 'preview' (which legitimately requires a session). For 'browse' mode, call syncWorkspacePanelUI() instead so the panel renders its no-workspace placeholder rather than vanishing.

  2. Ephemeral-session boot path (boot.js:908-912) — read panelPref from localStorage and set _workspacePanelMode='browse' BEFORE the sync call, matching what the existing session-restore path at boot.js:921-925 already does.

  3. No-saved-session boot path (boot.js:933-937) — same pref-restore.

  4. canBrowse and openWorkspacePanel() — extended to include S._profileDefaultWorkspace so 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 via syncWorkspacePanelUI()

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:

  1. syncWorkspacePanelState checks _workspacePanelMode==='preview' before force-closing
  2. The non-preview branch calls syncWorkspacePanelUI() rather than force-closing
  3. Both new boot paths read 'hermes-webui-workspace-panel-pref' and set _workspacePanelMode='browse' before sync
  4. canBrowse and openWorkspacePanel early-return both include S._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 _profileDefaultWorkspace references 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 --check passes on boot.js.
  • No agent-side coupling: pure UI state machine.
  • No race against settings fetch: S._profileDefaultWorkspace is set at line 822 in the settings fetch IIFE, well before the boot path runs around line 880-940.
  • loadSession doesn'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 panelPref read 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 (-pref AND non-pref) is documented in panels.js but 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.

nesquena-hermes added a commit that referenced this pull request Apr 28, 2026
…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.
@nesquena-hermes
Copy link
Copy Markdown
Collaborator Author

Merged as v0.50.231 via #1194. Thank you @nesquena-hermes!

@nesquena-hermes nesquena-hermes deleted the fix/workspace-panel-persists-on-empty-boot branch April 28, 2026 00:44
nesquena added a commit that referenced this pull request Apr 28, 2026
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]>
JKJameson pushed a commit to JKJameson/hermes-webui that referenced this pull request Apr 29, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants