fix(ui): workspace files stay visible on repeated blank-page reloads#1196
fix(ui): workspace files stay visible on repeated blank-page reloads#1196nesquena-hermes wants to merge 2 commits intomasterfrom
Conversation
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]>
nesquena
left a comment
There was a problem hiding this comment.
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_A → removed 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:
-
Sidebar pollution: would the empty
session_Ashow 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. -
In-memory state: the guard still sets
S.session=nullandS.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. -
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) andlocalStorage.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
SESSIONSdict (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.sessionstaysnull(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:
test_ephemeral_guard_does_not_remove_session_localstorage_key— locks the actual fix: the guard block must NOT containlocalStorage.removeItem('hermes-webui-session'). If a future "cleanup" reintroduces the call, this test fires.test_ephemeral_guard_still_clears_in_memory_session_state— locksS.session=nullandS.messages=[]so the in-memory clear isn't accidentally removed alongside the localStorage cleanup.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_sprint3quirk is now fixed by my own #1186 which landed in v0.50.231 batch — visible ingit log). - CI on PR after my push: ✅ test (3.11), ✅ test (3.12), ✅ test (3.13).
Other audit — confirmed correct
- JS syntax:
node --checkpasses onboot.js. - No agent coupling: pure UI state flow.
renderSessionListFromCacheactive-session preservation: withS.session=null, the OR clauseS.session && s.session_id===S.session.session_idis 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:
loadSessioncould detect 404 and clearlocalStorageitself, 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.
…, 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.
|
Merged as v0.50.232 via #1198. Thank you @nesquena-hermes! |
…, 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.
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 callsloadDir().Exact sequence with the bug:
session_A_idsession_A_id→ removed by guardnull→ "no saved session" pathnullFix
Remove the
localStorage.removeItem()call. The session ID stays in localStorage so every blank-page refresh takes the same path:The session ID remaining in localStorage is safe:
S.sessioncleared from memory → no active session context/api/sessionsfilter removes Untitled+0-msg entries)newSession()creates a real session and overwrites the localStorage key normallyTesting
2729 tests passing. Manual sequence verified: