Skip to content

fix(ui): ephemeral sessions — untitled 0-message sessions never appear in sidebar#1182

Closed
nesquena-hermes wants to merge 2 commits intomasterfrom
fix/ephemeral-untitled-sessions
Closed

fix(ui): ephemeral sessions — untitled 0-message sessions never appear in sidebar#1182
nesquena-hermes wants to merge 2 commits intomasterfrom
fix/ephemeral-untitled-sessions

Conversation

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

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 + title Untitled never 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 localStorage has a stored session ID, we now check message_count after 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 withMessages filter before the profile/project/archive chain. Even if a 0-message session somehow appears in _allSessions from a stale cache, it won't render.

Test

test_workspace_panel_restore_before_sync updated: it now looks for hermes-webui-workspace-panel-pref (the key read in the normal message-restore path) and uses rfind for the last syncWorkspacePanelState() call, since the new early-exit path for 0-message sessions doesn't read panel prefs.

2644 tests passing.

Browser QA (to verify manually)

  1. Open WebUI → click New Conversation → without sending a message, reload → should show empty state, no session in sidebar
  2. Open WebUI → click New Conversation 5× → sidebar stays empty (no Untitled pile-up)
  3. Send a message → session immediately appears in sidebar
  4. Reload → session correctly restored

…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]>
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 ✅ (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:

  1. 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.
  2. 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.
  3. Client filter (static/sessions.js:749-754) — withMessages filter in renderSessionListFromCache as belt-and-suspenders against stale _allSessions snapshots.

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.py use monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file) against a temp path — but never create the file. So the test fixture forces SESSION_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:

  1. api/models.py full-scan fallback: same removal of the grace clause + a comment explaining the consistency intent.
  2. 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) populates S.session from 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 ⚠️ minor; rename without message+save sequence is unusual
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 uses rfind for the last syncWorkspacePanelState call (normal path) and workspace-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_sprint3 quirk; 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 --check passes on boot.js, sessions.js.
  • _hide_from_default_sidebar ordering: still applied AFTER the empty-session filter in both paths — cron/internal sessions remain filtered. ✅
  • time module still used elsewhere in api/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_sidebar filter 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 _allSessions cache OR clause: S.session && s.session_id === S.session.session_id && (S.session.message_count||0) > 0 — the inner check S.session.message_count > 0 is redundant because if S.session.message_count > 0 the cache entry's s.message_count > 0 would 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, loadSession handles 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.

nesquena-hermes added a commit that referenced this pull request Apr 27, 2026
)

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)!
@nesquena-hermes
Copy link
Copy Markdown
Collaborator Author

Merged as v0.50.229 via #1183. Thank you nesquena-hermes!

nesquena added a commit that referenced this pull request Apr 28, 2026
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-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 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
…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)!
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