fix(ui): ephemeral sessions — untitled 0-message sessions never appear in sidebar#1182
fix(ui): ephemeral sessions — untitled 0-message sessions never appear in sidebar#1182nesquena-hermes wants to merge 2 commits intomasterfrom
Conversation
…rst message sent Closes the full class of empty-session UX issues: 1. **Server (api/models.py)**: removed the 60-second grace window for 0-message Untitled sessions. They are now hidden from /api/sessions immediately and permanently until a message is sent. A session only becomes 'real' once message_count > 0. 2. **Boot (static/boot.js)**: on page load, if the stored session has 0 messages, treat it as ephemeral and show the empty state instead. localStorage is cleared so the first New Conversation or message creates a fresh session. The orphaned empty session is left on disk for cleanup but never surfaced. 3. **Sidebar (static/sessions.js)**: renderSessionListFromCache now filters out 0-message sessions before the profile/project/archive filters, so no empty session can flash into the list from a stale _allSessions snapshot during a render cycle. Previously: hitting New Conversation on a fresh page, then reloading, left an 'Untitled' entry in the sidebar that couldn't be recovered. Previously: the sidebar would show 0-message sessions for up to 60s. Now: the sidebar only ever shows sessions with at least one message. Test updated: test_workspace_panel_restore_before_sync now uses rfind for the final syncWorkspacePanelState() call (normal restore path) and finds workspace-panel-pref specifically, since the new early-exit branch does not read panel prefs (there's no session workspace to restore). Closes #1171 (follow-up hardening — the button guard from #1176 was a patch; this is the correct full fix at the data model level)
…1171) The PR removed the 60-second grace window from the index-path filter at api/models.py:558-567 but left the same filter at the full-scan fallback (line 589-594) with the grace window intact. The fallback path is only hit when SESSION_INDEX_FILE doesn't exist or fails to parse — rare in production but it's also the path used by tests/test_issue789.py (monkeypatch sets SESSION_INDEX_FILE to a temp path that's never created), which is why those existing assertions kept passing on the PR even though they assert the OLD "60s grace" behavior. Make both filter sites consistent: empty Untitled sessions are hidden regardless of age in BOTH paths. Update test_issue789.py assertions to reflect the new contract documented in the PR description ("a session only exists from the user's perspective once the first message is sent"). The two visibility-while-young tests are flipped to assert the new hidden behavior; the old-age tests already assert hidden so they continue to pass. Without this commit: - production: index path is always taken, PR works as documented - legacy installs (no _index.json): old grace behavior persists - tests: silently pass against the old fallback path, masking the inconsistency 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 ✅ (approved after fallback-path consistency fix pushed)
What this ships
Follow-up to #1171 / #1176 — addresses the data-model root cause that the button guard in #1176 only patched at the input layer. A session now becomes "real" only once message_count > 0. Three coordinated changes:
- Server filter (api/models.py:564-567) — removed the 60-second grace window from the index-path filter. Empty Untitled sessions are filtered regardless of age.
- Boot path (static/boot.js:894-907) — when the page loads and
S.session.message_count === 0, treat as fresh start: clear stored session ID, render empty state, return early. - Client filter (static/sessions.js:749-754) —
withMessagesfilter inrenderSessionListFromCacheas belt-and-suspenders against stale_allSessionssnapshots.
Traced against upstream hermes-agent
Pulled fresh nousresearch/hermes-agent tarball.
The change is purely WebUI list-presentation. Empty sessions remain on disk under ~/.hermes/webui/sessions/*.json and the agent's CLI/Telegram session-search would still see them via the state.db join. WebUI just stops surfacing them. No agent coupling.
What I caught — second filter site missed; tests silently masked it
The PR removed the 60s grace from all_sessions() at the index path (lines 558-567), but the full-scan fallback path at api/models.py:589-594 still had:
result = [s.compact(...) for s in out if not (
s.title == 'Untitled'
and len(s.messages) == 0
and (_now - s.updated_at) > 60 # ← still has the grace
)]Production behaviour: index-path filter is always taken (the index file is auto-created), so the PR works as documented. But:
- Legacy installs without
_index.json: fall back to the unchanged path with the old grace → empty sessions still surface for 60s. - The existing regression tests in
tests/test_issue789.pyusemonkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)against a temp path — but never create the file. So the test fixture forcesSESSION_INDEX_FILE.exists() == False, which means tests run against the fallback path. The PR's CI passed because the fallback path's behaviour was unchanged — but it asserted the OLD behaviour the PR's description claims to have replaced. The two paths had diverged.
Pre-fix behavioural verification:
$ pytest tests/test_issue789.py
test_new_untitled_session_is_visible_in_sidebar PASSED # old behaviour, fallback path
test_recent_untitled_session_under_60s_is_visible PASSED # old behaviour, fallback path
test_mixed_sessions_correct_visibility PASSED # old behaviour, fallback path
After making the fallback path consistent (both filters now have no grace), those three tests fail because they assert the old behaviour:
$ pytest tests/test_issue789.py
FAILED test_new_untitled_session_is_visible_in_sidebar
FAILED test_recent_untitled_session_under_60s_is_visible
FAILED test_mixed_sessions_correct_visibility
What I pushed — 7e20006
Two changes, single commit:
api/models.pyfull-scan fallback: same removal of the grace clause + a comment explaining the consistency intent.tests/test_issue789.py: updated three test assertions to match the new contract (renamed two tests to_is_hidden_*, flipped their assertion polarity, updated the mixed-bag test). The other 5 tests in the file still pass because they were testing the old-age path which already returned hidden.
The test file's docstring also got updated to record the contract change so future readers know the original #789 fix was superseded by #1171.
CI re-ran green on 7e20006: 3.11 ✅, 3.12 ✅, 3.13 ✅.
End-to-end trace
Server filter (#1182 + my fix)
Both filter sites now have the same shape:
result = [s for s in result if not (
s.title == 'Untitled' and s.message_count == 0
)]Filters Untitled+0-message sessions regardless of age. ✅
Boot restore early-exit (static/boot.js:891-907)
await loadSession(saved);
if(S.session && (S.session.message_count||0) === 0){
localStorage.removeItem('hermes-webui-session');
S.session=null; S.messages=[];
S._bootReady=true;
syncTopbar();syncWorkspacePanelState();
$('emptyState').style.display='';
await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();
return;
}loadSession(saved)populatesS.sessionfrom server- If empty: clear localStorage, null out S.session, render empty state
- Returns BEFORE the panel-pref restore block (which is the normal-path restore) — correct, there's no workspace to restore for an empty session
The orphaned empty session file remains on disk. That's acceptable per the PR description. A periodic cleanup task could prune them later.
Client withMessages filter (sessions.js:749-754)
const withMessages=allMatched.filter(s=>(s.message_count||0)>0 || (S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0));Belt-and-suspenders. The OR clause preserves the active session in the list only if it has messages — so an empty active session is also filtered (matches the data model contract). Subsequent profile/project/archive filters operate on this trimmed set.
The otherProfileCount calculation on sessions.js:806 was also updated to use withMessages — keeps the "show other profiles" toggle count consistent with the actual rendered list.
Edge-case trace
| Scenario | Expected | Actual |
|---|---|---|
| New empty session created | hidden from sidebar immediately | ✅ both filter sites (after my fix) |
| User reloads on empty session | empty state shown, no Untitled in sidebar | ✅ boot.js early-exit |
| User clicks "+" 5x in fresh state | one ephemeral session created (per #1176 button guard), no sidebar entry | ✅ guard + filter combine |
| User sends first message | session appears in sidebar | ✅ message_count > 0 → not filtered |
Stale _allSessions cache shows 0-msg |
client filter strips it | ✅ withMessages |
Active session still has 0 msg in cache but S.session.message_count > 0 (just sent) |
preserve | ✅ OR clause for active session |
Legacy install without _index.json |
fallback path also filters | ✅ after my fix |
_index.json exists but parse fails |
falls through to fallback (consistent now) | ✅ after my fix |
| Inline rename of 0-msg session before message | server doesn't know yet — title is "Untitled", gets filtered | |
| Cron / CLI sessions with 0 messages | not affected (filtered by _hide_from_default_sidebar separately) |
✅ |
Tests
- PR's targeted test update (
test_workspace_panel_restore_before_sync): passes; correctly usesrfindfor the last syncWorkspacePanelState call (normal path) andworkspace-panel-pref(only present in normal path). - My updated tests (8 in
test_issue789.py): all pass after assertion polarity flip. - Local full suite: 2596 passed, 47 skipped, 1 PR-unrelated pre-existing failure (macOS-only
test_sprint3quirk; verified pre-existing on master). - 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,sessions.js. _hide_from_default_sidebarordering: still applied AFTER the empty-session filter in both paths — cron/internal sessions remain filtered. ✅timemodule still used elsewhere inapi/models.py(line 50, 327, 328, 351), so the unused import isn't a concern after removing_now = time.time()from the fallback path.- No regression in cron-completion flow: cron sessions have
source='cron', hit the_hide_from_default_sidebarfilter independently of the empty-Untitled filter. Title/message_count don't matter for them.
Minor observations (non-blocking)
- Orphaned empty sessions accumulate on disk forever. The PR description acknowledges this ("left on disk for cleanup but never surfaced"). Worth a follow-up sweep that deletes Untitled+0-msg sessions older than N days from the SESSION_DIR. Out of scope for this PR.
- Stale
_allSessionscache OR clause:S.session && s.session_id === S.session.session_id && (S.session.message_count||0) > 0— the inner checkS.session.message_count > 0is redundant because ifS.session.message_count > 0the cache entry'ss.message_count > 0would catch it on the next refresh. The OR clause specifically handles the window between "user sent first message" (S.session updated) and "cache refreshed" (s.message_count still 0). Acceptable; the comment explains it. - The boot early-exit doesn't call
loadDir('.')— but that's fine because there's no session workspace to load. If the user then clicks "+" and gets a session,loadSessionhandles dir loading.
Recommendation
Approved (after consistency fix pushed). Solid root-cause fix at the data-model layer. The inconsistency I caught between the two filter sites was real — production worked but the test suite was silently testing the wrong code path, and legacy installs would have gotten the old behaviour. After my fix both paths converge on the documented contract: a 0-message Untitled session is never surfaced in the sidebar, regardless of age. CI green; full suite 2596 passed; updated test_issue789.py assertions document the contract change. Parked at approval — ready for the release agent's merge/tag pipeline.
) Merged as v0.50.229. 2678 tests passing. Browser QA 21/21. All three PRs were independently reviewed and approved by @nesquena with reviewer commits pulled in: - #1181 (#1158): `d974388` (stale-response race in _loadOlderMessages) - #1182: `7e20006` (full-scan fallback path consistency) - #1180: `a5ad154` (regression test for iOS zoom threshold) Thanks @jasonjcwu (#1158)!
|
Merged as v0.50.229 via #1183. Thank you nesquena-hermes! |
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]>
…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.
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]>
…squena#1183) Merged as v0.50.229. 2678 tests passing. Browser QA 21/21. All three PRs were independently reviewed and approved by @nesquena with reviewer commits pulled in: - nesquena#1181 (nesquena#1158): `d974388` (stale-response race in _loadOlderMessages) - nesquena#1182: `7e20006` (full-scan fallback path consistency) - nesquena#1180: `a5ad154` (regression test for iOS zoom threshold) Thanks @jasonjcwu (nesquena#1158)!
…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
This is the proper fix for the ephemeral empty session problem. The button guard in #1176 (don't create a new session if already on an empty one) was a patch — this addresses the underlying model: a session only exists from the user's perspective once the first message is sent.
Three coordinated changes:
1. Server: remove the 60-second grace window (
api/models.py)Previously, empty Untitled sessions were hidden from the sidebar after 60 seconds. Now they are hidden immediately and permanently —
message_count == 0+ titleUntitlednever appears in/api/sessions, regardless of age.2. Boot: don't restore empty sessions on page reload (
static/boot.js)When the page loads and
localStoragehas a stored session ID, we now checkmessage_countafter loading it. If it's zero, we treat the load as a fresh start: clear the stored ID, show the empty state, and let the user hit+or send a message when they're ready. The orphaned empty session is left on disk (it will be ignored by the list filter) but never surfaced.Before: reload after clicking New Conversation → "Untitled" in sidebar, loaded as active session
After: reload → clean empty state, no session shown, no session loaded
3. Client filter: belt-and-suspenders in
renderSessionListFromCache(static/sessions.js)Added an explicit
withMessagesfilter before the profile/project/archive chain. Even if a 0-message session somehow appears in_allSessionsfrom a stale cache, it won't render.Test
test_workspace_panel_restore_before_syncupdated: it now looks forhermes-webui-workspace-panel-pref(the key read in the normal message-restore path) and usesrfindfor the lastsyncWorkspacePanelState()call, since the new early-exit path for 0-message sessions doesn't read panel prefs.2644 tests passing.
Browser QA (to verify manually)