currentSid is snapshotted before the awaited /api/session fetch; if the user
clicks a healthy session while a boot-time restore is in flight and that boot
load then fails non-404, _clearStuckSessionOnBoot(sid, null) would wipe the
healthy session's localStorage/URL. Guard the catch block on
_loadingSessionId !== sid (a newer load superseded this one) — re-arm the active
stream and bail before any self-heal/DOM mutation. Protects both the non-404 and
404 inline self-heal paths. + regression test.
Two scroll-state bugs cause the viewport to snap to the bottom after
streaming completes, even when the user has scrolled up to read:
1. _scrollAfterMessageRender (ui.js): when preserveScroll=true and
_scrollPinned=true (re-set by the 250px near-bottom hysteresis),
_followMessagesAfterDomReplace() calls scrollToBottom(), overriding
the user's position. Added !_messageUserUnpinned guard so explicitly
scrolled-up users get their snapshot restored instead.
2. _finishDone (messages.js): the explicit scrollToBottom() at L3302
fires when shouldFollowOnDone is true, but that flag only checks a
120px near-bottom threshold. Added _isMessagePaneNearBottom(250)
gate so users reading 250px+ above bottom keep their position.
Also adds a new 'auto_scroll_follow' setting (default: off) that gates
scrollIfPinned() and _shouldFollowMessagesOnDomReplace(). When disabled,
the viewport never auto-scrolls during streaming — the user controls
scroll position manually and uses the ↓ button to jump to bottom.
7-file setting-add pattern applied:
- api/config.py: _SETTINGS_DEFAULTS + _SETTINGS_BOOL_KEYS
- static/boot.js: both init paths
- static/ui.js: scrollIfPinned() + _shouldFollowMessagesOnDomReplace()
- static/index.html: checkbox in appearance settings
- static/i18n.js: en + zh-CN translations
- static/panels.js: payload, autosave, populate, full save
- messages.js: no additional changes (gated via _shouldFollowMessagesOnDomReplace)
Tests updated: test_tars_scroll_reset_regressions, test_issue1690,
test_issue3545. All 116 scroll-related tests pass.
If startSessionStream(sid) runs while the tab is ALREADY hidden (session loaded/
restored in a background tab), it set _sessionStreamSessionId then returned at the
hidden-skip WITHOUT recording _sessionStreamHiddenSid — so the visibility handler
(which reopens only from the holder) never reattached, silently dropping
bg_task_complete/server_turn_started until the next explicit startSessionStream.
Set _sessionStreamHiddenSid = sid on the hidden-skip return. Adds a regression test.
#3996 closes the gateway + per-session SSE on hidden tabs to free HTTP/1.1
connection-pool slots (#3992). But the per-session-stream visibility hook
guarded its reopen on _sessionStreamSessionId, which stopSessionStream() nulls —
so the per-session SSE closed on hide but never reopened on re-show (regressing
live-stream reattach). Capture the id into a dedicated _sessionStreamHiddenSid
before the close and reopen from it on visibility return. Gateway hook (sessions.js)
was already correct (reopens unconditionally + re-guards internally).
On 401, api() redirects to /login and returns undefined. The browser
navigates away immediately, so this code rarely runs. But even if it
did, clearing localStorage on transient auth expiry is wrong — it wipes
the saved session id and sends users to empty state after re-login.
Keep the self-heal in the catch-block else branch for non-401, non-404
errors (400/403/500/network) which genuinely trap the user on recurring
boot failures. 401 has no such property because the redirect already
breaks the retry loop.
Greptile: 'Keep the if (!data) early-return clearing-free (its old
behavior), and only call _clearStuckSessionOnBoot() from the else
branch of the catch.'
(#4028 follow-up)
The api() function returns undefined on 401 (redirects to login) rather
than throwing. So the 401 path exits via the if(!data) guard at line ~988,
not through the catch block — the e.status===401 branch inside the catch
is dead code (#4028 follow-up).
Greptile flagged that currentSid===sid also fires on same-session
force-reloads (e.g. background poll). If the server returns a transient
500 or network error, currentSid===sid is true — the helper wipes
hermes-webui-session from localStorage and resets the URL, even though
the session still exists on the server.
Limit the self-heal to boot-time failures only (!currentSid), where the
stored session ID is definitely stale. When currentSid is set (already
viewing a session), a non-404 failure could be transient and wiping
localStorage is unnecessarily destructive.
The 404 inline self-heal (line 932) already uses this tighter guard,
so this brings the non-404 path into alignment.
When loadSession() fails during boot with a non-404 error (401, 400,
500, network), the session ID stays stuck in localStorage and the URL,
causing repeated failures on every page refresh.
The 404 path already had inline self-heal (clears localStorage + URL).
This extends it to all error cases:
- Added _clearStuckSessionOnBoot() helper to consolidate the self-heal
logic for non-404 errors.
- On 401 redirect (api() returns undefined): clears the stuck session
ID and shows a more informative message.
- On other non-404 errors: clears the stuck session ID and shows a
better error message distinguishing auth failures from other errors.
Preserves the guard that prevents clearing localStorage when clicking
into a *different* dead session while already viewing a healthy one.
Both reviewers noted the _preservedLiveTurn.dataset.sessionId stamp was a dead
write in the segment-swap path (only the segment enters the DOM there; the
rebuilt turn was already stamped at build time). Move the stamp into the two
whole-turn paths where the preserved turn actually enters the DOM. No behavior
change; removes the dead write.
Codex round 2 caught that _structuralCount() only enumerated .tool-call-group/
.tool-card-row/.tool-worklog-group but NOT the live Worklog shell
(.live-worklog[data-live-worklog-shell=1]) or its .wl-reason content (built by
_flushPendingSegmentRender / ensureLiveWorklogContainer, see messages.js:2386 +
ui.js:6712/6655). So a turn whose live-ahead structure is a Worklog (not a bare
tool-call-group) undercounted preserved structure → wrongly took the segment-swap
path → detached the visible live Worklog container for a frame.
Fix: add .live-worklog[data-live-worklog-shell=1] and .wl-reason to the structural
count (mirrors the canonical 'has current worklog content' selector set at
sessions.js:1125-1131). Added a structural-test assertion + verified live: a
preserved turn with a live worklog .wl-reason the rebuild lacks now takes the
whole-turn restore path and the worklog + parser both survive.
Convergence: Codex findings narrowing each round (tie-guard → tail-segment →
worklog-selector), all real, all fixed + empirically verified.
Opus flagged a tool-card-frame-loss edge in the segment-only swap: the premise of
#3877 is that the live DOM can be AHEAD of S.messages, so a tool/worklog group can
land in the live turn between the last throttled persist and a mid-stream rebuild.
The rebuild (built from the lagging S.messages) would then lack that group, and a
segment-only swap would drop it for a frame (empirically confirmed: a live-only
tool card present before the rebuild was absent after).
Fix: count structural blocks ([data-live-assistant=1] + tool/worklog/thinking
groups) on both turns. Segment-swap only when the rebuild is the structural
SUPERSET (rebuilt >= preserved); otherwise restore the WHOLE preserved turn so
live-only structure the user already saw never vanishes. Verified live: tool-card-
ahead → whole-turn restore keeps the card + parser; single-segment tie → segment
swap, parser connected; multi-live-segment tail → tail parser connected, both
segments kept.
Also addresses Opus's dead-code note implicitly — the dataset.sessionId stamp now
matters on the whole-turn restore path (it's the branch that actually inserts the
preserved wrapper).
Codex caught an asymmetry in the first cut: the rebuilt side selected the tail
live segment ([...].pop()) but the preserved side used querySelector() = the
FIRST [data-live-assistant=1] segment. In a multi-live-segment turn (reconnect /
post-tool activity boundaries — see messages.js ensureAssistantRow re-attaching
to the LAST live segment), the smd parser writes into the tail segment, so
swapping the first preserved segment would move the wrong node and leave the
parser-owned tail detached — re-introducing the flicker for multi-segment turns.
Fix: select the preserved segment from querySelectorAll, defaulting to the LAST
(tail), and prefer the one whose data-live-segment-seq matches the rebuilt tail;
compute _preservedLen from that segment. Added a structural regression test
pinning the tail selection.
Verified live: a 2-live-segment turn (seq 1 + seq 2, parser on seq 2) keeps the
parser-owned tail connected across a mid-stream rebuild (was orphaned with the
first-segment selection).
The #3877 fix (#3892) preserves the live assistant turn's DOM node across a
mid-stream renderMessages() rebuild and swaps it back in when the rebuilt turn
has less streamed text than the preserved (smd-parser-referenced) node. But the
guard was strict: _rebuiltLen < _preservedLen. At the throttled session
write-back boundary the rebuilt turn's live segment can carry EXACTLY the same
text length as the preserved node, so the strict guard skipped the swap and left
the streaming parser writing into the now-detached original node — the residual
'disappears, then reappears' frame the reporter still saw on v0.51.347.
Fix:
- Relax the guard to _rebuiltLen <= _preservedLen so the equal-length tie also
restores the parser-referenced node (on a tie the preserved node is strictly
preferable: it holds the live parser reference and nothing is lost). When the
rebuilt turn genuinely has MORE content (reconnect where S.messages caught up
past the parser) the guard still skips and the parser re-resolves to the fuller
node.
- Swap at the SEGMENT level (replace only the rebuilt live segment with the
preserved one) instead of a whole-turn replaceWith, so a multi-segment turn
(earlier settled segments + tool/worklog groups built by the rebuild) keeps
that rebuilt-only structure. Whole-turn replace remains the fallback when the
rebuilt turn has no live segment to target.
Verified live (isolated server on the edited ui.js, real shipped renderMessages):
- single-segment tie: parser node stays connected (was orphaned on master);
- multi-segment tie: parser connected AND both assistant segments preserved;
- rebuilt-longer: correctly keeps the fuller rebuilt node, no content loss.
Updated the #3877 structural tests to pin the <= guard + segment-level swap.
Closes#3877.
Adds an opt-in, default-off, desktop-only floating panel that lists the user's
questions in a conversation as a numbered jump-list (click to scroll + flash the
message). Gated behind a Settings → Preferences toggle (show_conversation_outline)
wired through the existing show_* preference boot/load/autosave/save paths.
Review follow-ups applied on absorb:
- Outline is now strictly chat-only: leaving the chat view (Settings, Tasks,
Insights, …) hides the toggle button AND closes the panel; returning to chat
restores the toggle (panel stays closed until re-opened). Implemented by gating
_outlineAllowed() on the active panel and re-evaluating via a MutationObserver on
the <main> showing-<panel> class (switchPanel is a global fn declaration that
can't be reliably wrapped from this script).
- Fixed a latent visual bug: #outlinePanelWrapper{display:flex} (id selector)
outranked the UA [hidden]{display:none}, so wrapper.hidden=true never actually
hid the panel — the × close button and auto-close had no visual effect. Added
#outlinePanelWrapper[hidden]{display:none;}.
- Regression tests for both.
Closes#2124.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Adds a server-side run-journal live snapshot (_run_journal_live_snapshot) returned
in GET /api/session as runtime_journal_snapshot, so a FRESH client (another device,
or a tab with no in-memory snapshot) opening an in-progress session immediately sees
the already-streamed assistant text + tool cards rebuilt from the server. Composes
with the existing _replay_run_journal cursor path (seeds lastRunJournalSeq so replay
resumes from the snapshot cutoff, not duplicating it) and keys tool cards by the same
5 id aliases (tid/id/tool_call_id/tool_use_id/call_id) as #3763 so SSE replay replaces
rather than duplicates snapshot cards. Payload values truncated; redaction test added.
Co-authored-by: t3chn0pr13st <technopriest@live.ru>
Global j/k keydown bindings navigate the session list (j=next, k=prev), guarded
by _isInteractiveSwipeTarget so they never fire while typing in the composer or
any input/textarea/contenteditable. Modifier-key combos are ignored.
Closes#3845.
Co-authored-by: Rod Boev <rod.boev@gmail.com>