Skip to content

fix(ui): workspace files stay visible on repeated blank-page reloads#1196

Closed
nesquena-hermes wants to merge 2 commits intomasterfrom
fix/workspace-files-persist-on-empty-reload
Closed

fix(ui): workspace files stay visible on repeated blank-page reloads#1196
nesquena-hermes wants to merge 2 commits intomasterfrom
fix/workspace-files-persist-on-empty-reload

Conversation

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Summary

Fixes a secondary bug left after PR #1187: the workspace file tree goes blank on the second refresh of an empty new conversation, even though the first refresh works fine.

Root cause

The ephemeral guard (added in the ephemeral-sessions PR) called localStorage.removeItem('hermes-webui-session') after detecting a 0-message session. This made the key absent on the next page load, sending the boot sequence into the "no saved session" path — which never calls loadDir().

Exact sequence with the bug:

Event localStorage key loadDir called? Files visible?
Click + (new session) session_A_id ✅ (by newSession)
Refresh 1 session_A_idremoved by guard ✅ (by loadSession)
Refresh 2 null → "no saved session" path ❌ never called ❌ blank
Refresh 3, 4… null ❌ still blank

Fix

Remove the localStorage.removeItem() call. The session ID stays in localStorage so every blank-page refresh takes the same path:

loadSession() → loadDir() → files populate DOM
→ ephemeral guard fires → S.session = null in memory
→ workspace panel stays open, files visible

The session ID remaining in localStorage is safe:

  • Ephemeral guard still fires on every refresh → S.session cleared from memory → no active session context
  • Session stays invisible in the sidebar (server-side /api/sessions filter removes Untitled+0-msg entries)
  • No "Untitled" entry accumulates — the server filter was already handling that before the localStorage removal was added
  • When the user sends their first message, newSession() creates a real session and overwrites the localStorage key normally

Testing

2729 tests passing. Manual sequence verified:

  1. Click New Conversation
  2. Refresh → workspace panel open, files visible ✅
  3. Refresh again → workspace panel open, files still visible ✅
  4. Refresh N more times → always shows files ✅
  5. Send a message → new session created, sidebar entry appears ✅

After PR #1187 fixed the workspace panel persisting on empty-session reload,
a secondary bug remained: the file tree went blank on the *second* reload of
an empty new conversation.

Root cause: the ephemeral guard (message_count=0) called
  localStorage.removeItem('hermes-webui-session')
which removed the stored session ID after the first blank-page refresh.

Sequence:
  Refresh 1 — saved=session_A_id in localStorage
    → loadSession(A) runs → loadDir('.') → files populate the DOM
    → guard fires: localStorage key REMOVED, S.session=null
    → files still visible (DOM populated by loadDir above) ✅

  Refresh 2 — saved=null (key was removed)
    → falls to 'no saved session' path
    → loadDir() is never called in that path
    → file tree is empty / never populated ✗

Fix: remove the localStorage.removeItem() call. The session ID stays in
localStorage so every refresh takes the same path:
  loadSession() → loadDir() → files populate → guard fires → S.session=null
  → workspace panel stays open with files visible ✅

The session ID remaining in localStorage is harmless: the ephemeral guard
still fires, S.session is cleared in memory, the session stays invisible
in the sidebar (server-side filter), and no phantom 'Untitled' entry appears.

2729 tests passing.
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]>
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

A 1-line removal in static/boot.js:906-918: the empty-session guard added in #1182 was calling localStorage.removeItem('hermes-webui-session'). That made the FIRST refresh of an empty conversation work (loadSession → loadDir runs, then guard fires and clears the key) but the SECOND refresh fell into the "no saved session" boot path which never calls loadDir() — file tree went permanently blank until the user clicked + or sent a message.

Removing the removeItem() call means every refresh takes the same path:

loadSession(saved) → loadDir('.') populates the workspace tree
  → ephemeral guard fires (message_count=0)
  → S.session=null in memory only
  → workspace panel stays open with files visible

Traced against upstream hermes-agent

Pure WebUI boot/state flow. Zero agent coupling.

End-to-end trace

Refresh sequence — pre-fix vs post-fix

Event localStorage loadDir? files visible?
Click + (new session) session_A ✅ via newSession
Refresh 1 (pre-fix) session_Aremoved by guard ✅ via loadSession
Refresh 2 (pre-fix) null → no-saved-session path ❌ blank
Refresh 1+ (post-fix) session_A (kept) ✅ via loadSession

Why keeping the localStorage key is safe

Three concerns I traced:

  1. Sidebar pollution: would the empty session_A show up as an "Untitled" entry? No — all_sessions() at api/models.py:564-567 filters Untitled+0-message sessions regardless of age (#1171/#1182). Server returns them filtered.

  2. In-memory state: the guard still sets S.session=null and S.messages=[] (boot.js:907) so the user isn't locked into the empty conversation as the active session. They're back to the empty-state UI.

  3. Recovery path: when the user actually creates a real session via "+" or sending a message, newSession() creates a fresh session in SESSIONS (per #1184 the empty session never went to disk anyway) and localStorage.setItem('hermes-webui-session', new_id) overwrites the key naturally.

Edge case: server restart with stale localStorage

Trace I walked through:

  • After #1184, empty sessions live only in SESSIONS dict (no disk write)
  • Server restart wipes SESSIONS
  • localStorage still points to the now-gone session_A
  • Refresh: loadSession(A) → API 404 → loadSession at sessions.js:131-143 sets msgInner to "Session not available in web UI", returns silently (no throw)
  • S.session stays null (loadSession doesn't reset it)
  • Ephemeral guard: if(S.session && ...) is false → falls through
  • User sees the "Session not available" message until they click + or send a message — recovery works via newSession overwriting the key

This isn't a regression introduced by the PR — same behaviour as before. Just noting that the localStorage key persisting across server restarts is benign (recovery is automatic).

What I pushed — 5d9b38c

3 static-analysis tests in tests/test_workspace_files_persist_on_empty_reload.py:

  1. test_ephemeral_guard_does_not_remove_session_localstorage_key — locks the actual fix: the guard block must NOT contain localStorage.removeItem('hermes-webui-session'). If a future "cleanup" reintroduces the call, this test fires.
  2. test_ephemeral_guard_still_clears_in_memory_session_state — locks S.session=null and S.messages=[] so the in-memory clear isn't accidentally removed alongside the localStorage cleanup.
  3. test_ephemeral_guard_still_restores_panel_pref — locks PR #1187's panel-pref restore so it can't regress in this same block.

Together these pin the three coupled invariants: localStorage key stays (this PR), in-memory state clears (#1182), panel pref restores (#1187). CI re-ran green on 5d9b38c.

Edge-case trace

Scenario Pre-fix Post-fix
First refresh after empty New Conversation files visible (from loadDir) files visible ✅
Second refresh, same empty session blank file tree files visible ✅
N+ refreshes blank files visible ✅
User clicks + after refresh new session, sidebar populates new session, sidebar populates ✅
User sends first message session becomes real, sidebar entry session becomes real, sidebar entry ✅
Server restart with stale localStorage "Session not available" until + or message "Session not available" until + or message (unchanged)
Phantom "Untitled" in sidebar filtered server-side filtered server-side ✅

Tests

  • My 3 new tests: pass.
  • Local full suite: 2684 passed, 47 skipped, 0 failures (the macOS test_sprint3 quirk is now fixed by my own #1186 which landed in v0.50.231 batch — visible in git log).
  • CI on PR after my push: ✅ test (3.11), ✅ test (3.12), ✅ test (3.13).

Other audit — confirmed correct

  • JS syntax: node --check passes on boot.js.
  • No agent coupling: pure UI state flow.
  • renderSessionListFromCache active-session preservation: with S.session=null, the OR clause S.session && s.session_id===S.session.session_id is false, so the empty session_A won't accidentally appear in the sidebar via that path either.
  • Cooperative with #1187: panel pref restore still runs in the same block; both fixes coexist.

Minor observations (non-blocking)

  • The persistent localStorage→deleted-session 404 path could be tightened in a follow-up: loadSession could detect 404 and clear localStorage itself, sending the next refresh into the no-saved-session path with the workspace panel correctly opening from _freshPanelPref. Not in scope here.
  • The sequence diagram in the PR description is excellent — accurate trace of the bug and the fix.

Recommendation

Approved. Surgical 1-line removal that resolves the second-refresh regression introduced by #1182. The PR description's bug analysis is correct: removing the localStorage key sent later refreshes into a path that doesn't call loadDir(). Keeping the key is safe because (a) the in-memory state still clears, (b) the server-side sidebar filter still hides the empty session, and (c) newSession() overwrites the key when the user creates a real session. Pushed 3 regression tests pinning the contract. CI green; full suite clean. 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
…, timestamp sync (#1198)

Batch release v0.50.232 — 4 fixes.

## PRs included

| PR | Author | Fix |
|---|---|---|
| #1192 | @nesquena-hermes | Model chip fuzzy-match false positive (#1188) |
| #1193 | @nesquena-hermes | openai-codex not detected in model picker (#1189) |
| #1196 | @nesquena-hermes | Workspace files blank after second empty-session reload |
| #1197 | @bergeouss | Session timestamps wrong with server/client clock drift (#1144) |

All four PRs independently reviewed and approved by @nesquena.

## Integration fixes applied

**#1193:** Updated misleading comment — `OPENAI_API_KEY` does NOT authenticate the default Codex OAuth endpoint (that uses `chatgpt.com/backend-api/codex` and requires a separate OAuth flow). The comment now accurately states the known limitation. Also replaced a fragile 400-char source-scan test with an isolation-safe unit test. Note: OAuth-authenticated users already get detected via `hermes_cli.auth` — this fix only addresses the env-var fallback path.

## Test results

**2764 passed, 2 skipped** (macOS-only workspace tests). Browser QA: **21/21**. `/api/sessions` confirmed returning `server_time` and `server_tz` fields.
@nesquena-hermes
Copy link
Copy Markdown
Collaborator Author

Merged as v0.50.232 via #1198. Thank you @nesquena-hermes!

@nesquena-hermes nesquena-hermes deleted the fix/workspace-files-persist-on-empty-reload branch April 28, 2026 01:40
JKJameson pushed a commit to JKJameson/hermes-webui that referenced this pull request Apr 29, 2026
…, timestamp sync (nesquena#1198)

Batch release v0.50.232 — 4 fixes.

## PRs included

| PR | Author | Fix |
|---|---|---|
| nesquena#1192 | @nesquena-hermes | Model chip fuzzy-match false positive (nesquena#1188) |
| nesquena#1193 | @nesquena-hermes | openai-codex not detected in model picker (nesquena#1189) |
| nesquena#1196 | @nesquena-hermes | Workspace files blank after second empty-session reload |
| nesquena#1197 | @bergeouss | Session timestamps wrong with server/client clock drift (nesquena#1144) |

All four PRs independently reviewed and approved by @nesquena.

## Integration fixes applied

**nesquena#1193:** Updated misleading comment — `OPENAI_API_KEY` does NOT authenticate the default Codex OAuth endpoint (that uses `chatgpt.com/backend-api/codex` and requires a separate OAuth flow). The comment now accurately states the known limitation. Also replaced a fragile 400-char source-scan test with an isolation-safe unit test. Note: OAuth-authenticated users already get detected via `hermes_cli.auth` — this fix only addresses the env-var fallback path.

## Test results

**2764 passed, 2 skipped** (macOS-only workspace tests). Browser QA: **21/21**. `/api/sessions` confirmed returning `server_time` and `server_tz` fields.
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