Skip to content

fix: batch v0.50.229 — session perf, ephemeral sessions, iOS zoom#1183

Merged
nesquena-hermes merged 13 commits intomasterfrom
stage/batch-229
Apr 27, 2026
Merged

fix: batch v0.50.229 — session perf, ephemeral sessions, iOS zoom#1183
nesquena-hermes merged 13 commits intomasterfrom
stage/batch-229

Conversation

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Batch release v0.50.229 — 3 PRs.

PRs included

PR Author Fix
#1181 (#1158) @jasonjcwu Session switch parallelization + message pagination
#1182 @nesquena-hermes Ephemeral untitled sessions never appear in sidebar
#1180 @nesquena-hermes iOS Safari auto-zoom on input focus (#1167)

All three PRs have been independently reviewed and approved by @nesquena.

What's in each PR

#1181 — Session switch perf + pagination:

  • Promise.all() for directory pre-fetches (N×RTT → 1×RTT)
  • ThreadPoolExecutor(max_workers=3) for parallel git info subprocesses
  • 30-message tail window on session switch; scroll-to-top loads older
  • Stale-response race in _loadOlderMessages fixed by reviewer (d974388)

#1182 — Ephemeral sessions fix:

  • Empty Untitled sessions suppressed immediately (removed 60s grace)
  • Full-scan fallback path fixed to match index path (reviewer fix 7e20006)
  • Boot path skips restoring a zero-message session

#1180 — iOS zoom fix:

  • @media (hover:none) and (pointer:coarse) applies font-size: max(16px, 1em) to all inputs
  • Regression test added by reviewer (a5ad154)

Test results

2678 tests passing.

Hermes Agent and others added 13 commits April 27, 2026 23:23
…ont-size on touch devices (#1167)

iOS Safari and most mobile browsers automatically zoom in when a focused
input or select has font-size < 16px, and don't reset zoom after blur.
This leaves the viewport zoomed in until the user manually pinches out.

The textarea#msg composer was already 16px and unaffected. The inputs
that triggered the bug: sidebar search (13px), session rename input (13px),
settings selects (12px), and dialog inputs (14px).

Fix: one CSS media query targeting touch-primary devices
(@media (hover:none) and (pointer:coarse)) that bumps all input/textarea/select
to max(16px, 1em). Uses !important to override the per-element font-size rules
without touching those rules individually. This keeps desktop layouts unchanged.

Closes #1167
)

Add a regression test that asserts the global mobile media query bumping
input/textarea/select to font-size:max(16px,…) is present, so a future
per-element font-size tweak (e.g., adding a new 13px input class) cannot
silently re-introduce iOS Safari's auto-zoom-on-focus.

The existing test_composer_textarea_font_size_mobile only locks the
composer textarea — the global rule that closes #1167 across sidebar
search, rename inputs, settings selects, and dialog inputs deserves its
own assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…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]>
Three changes to speed up session switching:

1. workspace.js: Parallelize expanded-dir pre-fetching in loadDir().
   Previously, expanded directories were fetched sequentially with
   for-await, creating a waterfall of N API calls. Now uses
   Promise.all() to fetch all expanded dirs concurrently.

2. sessions.js: Overlap loadDir() network requests with highlightCode().
   The idle path ran highlightCode() (CPU-bound Prism.js) before loadDir,
   delaying the file-tree fetch. Now loadDir() is kicked off first so the
   HTTP request is in-flight while Prism runs, then awaited.

3. workspace.py: Parallelize git_info_for_workspace() subprocess calls.
   git status, rev-list (ahead), and rev-list (behind) were called
   serially (3 subprocess.run calls, each up to 3s timeout). Now they
   run concurrently via ThreadPoolExecutor, reducing wall time by ~60%.
6 tests across 3 classes:
- TestLoadDirParallelPrefetch: static analysis of Promise.all in workspace.js
- TestLoadSessionIdleOverlap: static analysis of loadDir-before-highlightCode ordering
- TestGitInfoParallel: runtime tests with threading.Barrier concurrency proof
  and wall-clock timing (parallel ~0.1s vs serial ~0.3s for 3 git commands)
Add msg_limit and msg_before parameters to /api/session endpoint:
- Initial load fetches only last 30 messages (tail window)
- Scroll-to-top triggers lazy loading of older messages via cursor
- Payload reduced by ~70% for large sessions (100+ messages)
- undo/retry/compress auto-reset truncation state
- Export uses server-side generation, unaffected

Backend (api/routes.py):
- msg_limit: returns only the last N messages with truncation flag
- msg_before: cursor-based paging for scroll-to-top loading

Frontend (static/sessions.js):
- _INITIAL_MSG_LIMIT=30 for initial paginated load
- _messagesTruncated tracks pending older messages
- _loadOlderMessages() prepends older messages on scroll
- _ensureAllMessagesLoaded() for operations needing full history

Frontend (static/ui.js):
- Scroll-to-top detection (scrollTop < 80px) triggers lazy load
- 'Load older messages' visual indicator when truncated

Tests: 19 total (13 new for message pagination), all passing
Issues found and fixed:

1. workspace.js: S._expandedDirs||[] fallback produced Array (no .size)
   → changed to ||new Set()

2. routes.py: _messages_truncated flag was wrong for msg_before paging
   → separate truncation logic per code path, compare against _slice
   not _all_msgs

3. routes.py: msg_before used timestamp comparison which fails when
   messages lack _ts (827 msgs with _ts=0 in real data) or have
   duplicate timestamps
   → changed to index-based cursor (0-based position in full array)
   → added _messages_offset field so frontend knows cursor position
   → added input validation (try/except, max(1,...), bounds clamping)

4. sessions.js: _oldestIdx tracks cursor position, reset on session
   switch, updated from _messages_offset on each page load

5. workspace.py: moved concurrent.futures import to module top level

6. tests: updated to match index-based cursor, added 8 new tests:
   - test_expanded_dirs_fallback_is_set
   - test_msg_before_index_based_slicing
   - test_msg_before_zero_returns_empty
   - test_msg_before_equal_total
   - test_truncation_flag_with_msg_before
   - test_messages_offset_initial_load/with_msg_before
   - test_oldest_idx_tracking/reset_on_session_switch
   - test_load_older_uses_index_cursor
   - test_msg_before_bounds_clamping

27/27 tests pass, 2583/2583 total (5 pre-existing failures unrelated)
Race condition: if user switches sessions while _loadOlderMessages() is
in-flight, the stale response could land on the new session's S.messages.

Guards verified:
- _loadOlderMessages checks _loadingSessionId !== sid after await (bails out)
- Guard appears BEFORE S.messages mutation (stale data cannot land)
- loadSession resets _loadingOlder=false on switch (no stale lock)
- loadSession resets _messagesTruncated=false and _oldestIdx=0 on switch

Added: _loadingOlder=false reset in loadSession() reset block.
Added: 5 tests in TestSessionSwitchCancellation proving all guards exist
and are ordered correctly.

32/32 tests pass.
- messages.js: reset _messagesTruncated in done handler (server response is always full)
- ui.js: use CSS class + i18n for load-older indicator
- style.css: add .load-older-indicator class
- i18n.js: add load_older_messages key
- CHANGELOG: add v0.50.229 entry
The cancellation guard at the top of _loadOlderMessages checks
_loadingSessionId !== null && _loadingSessionId !== sid. That catches
the case where a NEW session load is in progress when the older-messages
fetch returns. It misses the case where:

  1. user is on session A, _loadOlderMessages in flight
  2. user switches to session B; loadSession(B) completes; sets
     _loadingSessionId = null
  3. older-messages response for A returns
     -> _loadingSessionId === null
     -> guard evaluates to (null !== null) && ... -> false -> NO bail
     -> S.messages = [...olderMsgs, ...S.messages] prepends A's old
        messages onto B's S.messages

Tighten the guard by also comparing the captured sid against the
currently-active S.session.session_id. Together they cover both windows:
mid-switch (new sid loading) and post-switch (no load in progress).

Added a regression test (test_load_older_compares_against_active_session_id)
that asserts the new check is present and runs before any S.messages
mutation. Existing 5 cancellation tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@nesquena-hermes nesquena-hermes merged commit a091be6 into master Apr 27, 2026
3 checks passed
@nesquena-hermes nesquena-hermes deleted the stage/batch-229 branch April 27, 2026 23:27
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)!
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.

3 participants