fix: batch v0.50.229 — session perf, ephemeral sessions, iOS zoom#1183
Merged
nesquena-hermes merged 13 commits intomasterfrom Apr 27, 2026
Merged
fix: batch v0.50.229 — session perf, ephemeral sessions, iOS zoom#1183nesquena-hermes merged 13 commits intomasterfrom
nesquena-hermes merged 13 commits intomasterfrom
Conversation
…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
…er truncation reset
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]>
This was referenced Apr 27, 2026
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)!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Batch release v0.50.229 — 3 PRs.
PRs included
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_loadOlderMessagesfixed by reviewer (d974388)#1182 — Ephemeral sessions fix:
7e20006)#1180 — iOS zoom fix:
@media (hover:none) and (pointer:coarse)appliesfont-size: max(16px, 1em)to all inputsa5ad154)Test results
2678 tests passing.