* stage-3955: blur composer on Escape for j/k nav + IME guard (Opus nit)
* stage-3955: update test to match IME-guarded blur branch
* Release OX: blur composer on Escape for j/k message nav (#3952)
Self-rebased TomBanksAU's #3955 onto v0.51.436; full Codex+Opus+suite gate.
Escape now blurs the focused composer so j/k session-nav works, after higher-
priority Escape actions run; dropdown-Escape stops propagation; IME-composition
guarded (Opus nit). Also restored the v0.51.434/435 CHANGELOG headings that an
earlier batch left folded under v0.51.436 (hygiene fix).
Co-authored-by: TomBanksAU <TomBanksAU@users.noreply.github.com>
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: TomBanksAU <TomBanksAU@users.noreply.github.com>
* stage-3921: cron-session unread disambiguation (#3460, rodboev)
* Release OW: cron sessions mark unread on new runs (#3460)
Self-rebased rodboev's #3921 onto v0.51.435; full Codex+Opus+suite gate.
Fixes cron-session unread cross-attribution: resolver now considers all known
job ids and attributes each session to the longest matching cron_<jobid>_ prefix
(provably unambiguous). Read-only state.db scan, fires only on real completions.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(#3988): default show_cli_sessions to true for new installs, grandfather existing OFF
Combines the best of #3997 (rodboev) and #4222 (Sanjays2402):
- config.py default flipped True so NEW installs surface CLI/TUI/Telegram/
Discord/Hermes One sessions in the sidebar without hunting for the toggle.
- load_settings() backfill grandfathers ESTABLISHED installs OFF: when the saved
settings predate the flip (onboarding_completed True + key absent), pin False so
an existing user's sidebar doesn't silently change. Fresh installs get True.
- Default-true hydration at every read site (config.py / boot.js / panels.js
checkbox) via the !==false idiom — no config-ON-but-UI-OFF mismatch (#4006 class).
- 10 tests: config default, bool-key round-trip, grandfather matrix
(fresh/mid-onboarding/established-absent/explicit-true/explicit-false/
save-preserves-grandfather), and both frontend hydration sites.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
* fix(#4217): guard /api/file/delete and /api/file/rename against symlink targets
* chore(release): stamp v0.51.427 (Release ON) for #3988 + #4217
* chore: drop unused imports (ruff F401) in absorbed test files
* fix(#4217,#3988): address review — dangling-symlink ordering + boot.js catch fallback
Greptile review on #4230 + the related-PR reviews surfaced two fixes on the
shipped code:
1. (#4217) _handle_file_delete/_handle_file_rename checked target.exists()
BEFORE is_symlink(), so a DANGLING workspace symlink resolved to a missing
target -> misclassified as 404 'File not found' and left permanently
undeletable/unrenameable. Moved the no-follow is_symlink() guard before the
follow-based exists() check in both handlers. +2 dangling-symlink tests.
2. (#3988) boot.js settings-load-FAILURE catch block hardcoded
_showCliSessions=false, inconsistent with the new True default (the #4006
catch-block-fallback class). Now defaults true. +1 test pinning the fallback.
Both confirmed by the new tests (20/20 in the two files).
* fix(#3988,#4217): address Opus review — broaden grandfather signal + move-handler parity
Opus #4230 ship-review surfaced 2 LOW findings, both now fixed in-line:
1. (#3988) The grandfather keyed only on onboarding_completed, so a CLI-configured
user who tweaked a WebUI setting but never ran the wizard would get flipped ON
(and onboarding.py later auto-persisting onboarding_completed=True would make it
permanent). Now: established = show_cli_sessions absent AND (onboarding_completed
OR any persisted setting other than a falsy onboarding flag). A genuinely new /
still-mid-onboarding file still falls through to True. +1 test.
2. (#4217) _handle_file_move still checked exists() before is_symlink() — the same
dangling-symlink-404 bug just fixed in delete/rename. Reordered for parity so a
dangling symlinked source is rejected 400, not 404.
22 tests across the two files pass; ruff clean.
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
* fix: bound paginated session tails by visible messages
* fix(#4070): always merge state.db tail rows (drop the stale-tail timestamp shortcut)
Deep-review finding: the sidecar-only timestamp shortcut returned the sidecar
verbatim whenever state.db had no strictly-newer rows, but a state.db row
at-or-before the sidecar's newest timestamp (recovery / edited-in-place /
missing-timestamp) is still absent from the sidecar -> the paginated tail
diverged from the full merge path (empirically 3/6 adversarial cases). Always
run the cheap in-memory append-only merge; the real latency win (skipping the
lineage-parent DISK load) is preserved.
Co-authored-by: t3chn0pr13st <t3chn0pr13st@users.noreply.github.com>
* fix(#4070): keep matching trailing tool-result row in paginated window
Codex ship-gate finding: _message_window_for_display pulled end_idx back to the
last renderable (user/assistant) row, dropping a trailing role:tool result row
even when it's the result of the newest assistant tool-call. The renderer
rebuilds tool cards from these rows (CLI-origin / empty S.toolCalls path,
static/ui.js resultsByTid), so the card lost its result snippet. Now extend
end_idx forward through trailing tool rows whose tool_call_id matches a tool-call
present in the window; orphan trailing tool rows (no match) are still skipped to
preserve the visible-row budget. +2 regression tests.
Co-authored-by: t3chn0pr13st <t3chn0pr13st@users.noreply.github.com>
* chore(#4070): remove now-dead _max_message_timestamp + its import
Opus gate nit: _max_message_timestamp was only called by the removed
sidecar-timestamp shortcut, and the _message_timestamp_as_float import existed
solely to feed it. Both removed; _message_timestamp_as_float still lives in
api/models.py for its real callers.
* chore: add #4069 CHANGELOG entry (visible-message tail pagination)
* chore(release): stamp v0.51.424 (Release OK) for #4069
---------
Co-authored-by: t3chn0pr13st <technopriest@live.ru>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: t3chn0pr13st <t3chn0pr13st@users.noreply.github.com>
* perf(chat): virtualize long message transcripts (#500)
* fix(chat): preserve virtual height cache on append (#500)
* fix(chat): bound virtual tail follow-up (#500)
* fix(chat): surface older history and bound virtual rerenders (#500)
* fix(chat): stabilize virtual history fallback (#500)
* fix(chat): preserve virtual compression anchors (#500)
* Keep virtualization regressions aligned with rebased sources (#500)
* fix(chat): preserve virtual heights across prepends (#500)
* test(update): isolate restart timing checks (#814)
* fix(chat): restore render contracts around virtual cache
* fix(chat): preserve legacy assistant anchor predicates
* fix(#3714): restore worklog-disclosure capture after HTML-cache early-return
Deep-review finding: the capture of _captureWorklogDetailDisclosureState was
moved before the HTML-cache fast-path return, failing the invariant test
test_render_rebuild_preserves_worklog_detail_disclosure_click_state (it must
not traverse the previous-session DOM when the cache path returns early).
Moved it back to just before inner.innerHTML='' (after the cache return).
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3714): restore Transparent Stream rehydration in HTML-cache fast path
Full-suite gate caught test_issue3820_chat_activity_display_mode failure: the
virtualization rewrite of the renderMessages HTML-cache fast-path block dropped
the _rehydrateTransparentStreamDom(inner) call (master has it; PR had 2 of 3
occurrences). On a cache-restore session switch, Transparent Stream controls
lost their property-assigned handlers. Restored the call in the same position
master has it (right after _sessionHtmlCacheSid=sid).
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3714): preserve live turn on scroll-rebuild even with zero streamed text
Opus ship-review residual (now fixed, not deferred): the live-turn re-attach
after a scroll-triggered virtualized rebuild was gated on _preservedLen>0, so a
turn showing only Activity/tool/worklog blocks with no streamed text yet (mid
tool-call) would not be preserved — scrolling up into history on a >80-row
transcript mid-stream could blink those live-only blocks for a frame. Broaden
the gate to also restore when the preserved turn has MORE structural blocks than
the rebuilt (lagging-S.messages) turn. Hoisted the _structuralCount helper above
the gate; whole-turn-vs-segment restore logic unchanged.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* chore(release): stamp v0.51.423 (Release OJ) for #500 transcript virtualization
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* feat(workspace): add Todos tab to workspace panel behind a setting (#2942)
* fix(workspace): use DOM attribute to check active tab across script scope
* Prefer the live todos snapshot in the workspace tab
* Fill the missing workspace Todos locale keys
* feat(workspace): add optional Todos tab to workspace panel (#3564)
Rebased rodboev's #3564 onto current master + CHANGELOG entry.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3564): apply workspace Todos tab visibility on cold boot
Codex gate: boot.js set window._workspaceTodosTab from saved settings but
never called _applyWorkspaceTodosTabVisibility(), so a previously-enabled
Todos tab stayed hidden on a normal page reload (only re-appeared after
opening Settings). Call the visibility helper after assigning the flag in
both the settings-apply and the fallback/default paths.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* chore(release): stamp v0.51.422 (Release OI) for #3564
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Combined light-UX release NW. Both rebased onto current master (were 96 and
247 behind; net 3-dot diffs applied, byte-identical to PR heads). Both reuse
existing renderers (chat image lightbox / chat CSV table renderer).
Review fix (#4025, Codex): the CSV table preview previously stripped the
workspace Edit affordance .csv had when it fell through to the code preview.
Restored: csv mode is editable, renderCsvPreviewContent caches raw content for
the edit textarea, and saving an edited CSV re-renders the table (not markdown).
Verified the full edit round-trip live + regression test.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Re-pushed by rodboev fixing both bounce findings (parent streaming-state
isolation + nested-fork batch-select) plus a select-handler selector fix.
On current master (0 behind); sessions.js byte-identical to PR head.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
In Transparent Stream mode a live multi-round turn (narrate -> tools ->
narrate -> tools) rendered all assistant prose bunched at the top with
every tool/thinking row clustered below; it self-healed only once the
turn settled.
_syncLiveWorklogReasonsForAnchor() runs on every live segment render
(_flushPendingSegmentRender + the RAF _doRender in messages.js) and
unconditionally built the top-anchored live-worklog rail, mirrored each
round's prose into a wl-reason row there, and hid the real inline
assistant-segment via assistant-segment-worklog-source (display:none).
That prose-folding is the Compact Worklog presentation (#3401) and must
not run in Transparent Stream mode.
Gate the helper on isCompactWorklogMode() so transparent mode keeps prose
as visible, chronologically-placed inline segments interleaved with tool
rows (matching the already-correct settled render). Compact Worklog
folding is unchanged. Verified in an isolated mock-LLM env via an
on-screen visual-position probe (before: textAfterFirstTool=0,
allTextAboveAllTools=true; after: textAfterFirstTool=3,
allTextAboveAllTools=false) and confirmed compact_worklog still folds.
Adds a behavioral regression test asserting the gate is the first
statement in the helper (precedes the rail build), red/green validated.
The #4151 focus hook reopened both global sidebar streams on `focus` with a
bare `startGatewaySSE()`. But `startGatewaySSE()` is NOT idempotent — it begins
with an unconditional `stopGatewaySSE()` (sessions.js:3778), which closes the
live gateway EventSource, cancels its poll fallback (`stopGatewayPollFallback`),
and resets `_gatewayProbeInFlight` / `_gatewaySSEWarningShown`.
So on a transient blur shorter than the 1s debounce — where the blur-close
timer is cleared on the following focus and the stream was never torn down —
the focus handler still dropped and reconnected the gateway on every
window-switch, cancelling its fallback and resetting probe/warning state. That
is the exact thrash the debounce exists to prevent, in the multi-window PWA
scenario this PR targets. (`ensureSessionEventsSSE()` is already idempotent via
`if(_sessionEventsSSE) return`, so the session-events stream was unaffected;
only the gateway churned. greptile P1.)
Guard the reopen on the gateway actually being closed: `if(!_gatewaySSE)
startGatewaySSE()`, mirroring the session-events idempotency. A sustained blur
(timer fires → both streams closed) still reopens the gateway on focus; a
transient blur leaves the still-open gateway untouched. Verified with a
blur/focus/timer harness (transient → no teardown/reconnect; sustained → close
then reopen; CLI-disabled → stays closed) and a new regression guard
`test_focus_reopen_does_not_thrash_the_gateway_stream`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two side-by-side PWA standalone windows saturate the browser's per-origin
HTTP/1.1 connection limit, causing 'Request timed out' toasts to cycle every
~30s. The idle-SSE close added in #3992/#3996 keys off the Page Visibility API
(visibilitychange / document.hidden), but a PWA standalone window does not
reliably fire visibilitychange when it loses focus to another window of the
same app -- document.hidden only flips on minimize. So both windows stay
'visible', each holds its two global sidebar SSE streams (session-events +
gateway) open, 2x3 = 6 hits the connection cap, and subsequent fetch() calls
(the 30s background polls) queue behind the saturated pool and time out.
The two global sidebar streams now also close on a sustained window blur (gated
on document.hasFocus(), the signal visibilitychange misses) and reopen --
catching up the session list -- on focus, via a shared _sidebarSseBackgrounded()
predicate and a debounced _installSidebarSseFocusHook().
The per-session live stream (messages.js startSessionStream) is intentionally
left visibility-only: it carries live bg_task_complete toasts and
server_turn_started live-view that an unfocused-but-visible window must still
receive (the OS-notification path is gated on document.hidden, so the in-app
toast is the only completion signal a visible-unfocused window gets). Closing
it on blur would regress the multi-window live-view UX.
Reported by @ddiall with a thorough root-cause analysis.
Co-authored-by: ddiall <ddiall@users.noreply.github.com>
Codex found leak paths across 3 rounds (settled-restore, stream-end-fallback, reconnect-
preflight-inactive, stale-bailout, non-current-pane error, external cancelSessionStream).
Rather than chase every current+future teardown route, schedule an identity-guarded cleanup
at registry CREATION (600s) so window._liveAnchorRegistries self-expires no matter how the
stream ends — including external paths (boot.js cancelSessionStream) that bypass the SSE
handlers. Explicit terminal-path calls just expire it sooner. Shadow-only + identity-guarded,
so a rare >10min active stream only loses reconnect-continuity of an unconsumed registry.
Two more early-return paths created the registry (early in attachLiveStream) then
returned without scheduling cleanup: (1) reconnect preflight when /api/chat/stream/status
is inactive/no-replay (~3966); (2) _bailOutOfTerminalEventsFromStaleStream (~1490) when a
stale stream's terminal event arrives after a newer stream owns the session. Both now
schedule identity-guarded cleanup (map.get(streamId)===registry, keyed on the stale
closure's own streamId so the newer registry is untouched).
Codex found window._liveAnchorRegistries retained registries for streams ending via
_restoreSettledSession() and _finalizeStreamEndFallback() (both terminal-close without
cleanup). Added _flushReasoningToAnchor()+_scheduleAnchorRegistryCleanup() to both —
identity-guarded delete already prevents clobbering a newer registry. All anchor helpers
are nested in attachLiveStream's closure (hoisted), ESLint runtime gate clean.
Production fixes (Tamaz-sujashvili, reviewed sound by maintainer):
- loadSession clears S.busy/S.activeStreamId as soon as metadata confirms no active_stream_id, before the async message-load gap (idle session no longer shows streaming chrome).
- Snapshots the live turn before wiping msgInner + seeds INFLIGHT, restores on the active-stream return path (timer/trace survive switch-back).
Re-anchored the 2 brittle regression tests per maintainer review: test_..snapshots.. now anchors on the unique 'Loading conversation...' marker (was matching the no-space 'Session not available' error path); test_..restores.. now asserts the LIVE Phase 2a restore (after loadInflightState) instead of the unreachable Phase-2b/1184 branch. CHANGELOG stamped v0.51.384 (MW).
_captureWorklogDetailDisclosureState stamps state._sid; _restoreWorklogDetailDisclosureState
skips restore on sid mismatch. Cross-session isolation no longer depends solely on the
sessions.js #msgInner wipe invariant — a future change to that loading-placeholder
behavior can't silently replay one session's collapse state onto another.
panels.js _autosaveAppearanceSettings (6309) + _applySavedSettingsUi (7837) read
auto_scroll_follow from a settings/body object — use !==false (default True) for
consistency with boot.js + the checkbox render, so a future partial-body refactor
can't silently default the True setting OFF. The .checked reads (6223/8170) keep !!
(a real bool).
1. _settleMessageScrollToBottom(force, explicit): when explicit (End-button /
scrollToBottom), late-layout settling runs even with Auto-follow OFF — the
setting only suppresses AUTOMATIC follow, not a deliberate jump. The RO + 2s
fallback guards now bail on (!_autoScrollFollow && !explicit). scrollToBottom()
passes explicit=true; the automatic scrollIfPinned() path stays guarded.
2. renderMessages now captures the pre-wipe scrollSnapshot when (Auto-follow off
&& user unpinned) too, so the non-preserve restore lands the reader where they
were instead of receiving a null snapshot (no-op).
The keys shipped only in en+zh; added it/ja/ru/es/de/zh-Hant/pt/ko/fr/tr/pl so the
Settings → Appearance auto-follow toggle isn't English-only outside those two.
Codex found that with Auto-follow OFF the #3920 ResizeObserver settle + 2s fallback
+ the non-preserve render path still force-scrolled to bottom (the setting was
incomplete vs the scroll machinery shipped in #3920). Fixes:
1. RO settle guard (ui.js:3153): bail when !_autoScrollFollow.
2. 2s static fallback guard (ui.js:3186): bail when !_autoScrollFollow.
3. non-preserve render fall-through (ui.js:8653): when Auto-follow off AND the user
has unpinned, restore the scroll snapshot instead of scrollToBottom() — also
closes the send() race (renderMessages runs before S.activeStreamId is set).
A fresh session load (not unpinned) still lands at the bottom. Explicit
user-initiated scrollToBottom() (End button) is untouched.
Codex SHIP-ONLY-WITH-FIXES (3 real issues; Codex+Opus disagreed on #1, stricter wins):
1. CORE — .messages lost overflow-anchor:none, reopening the #1360 (d21c9720) streaming-scroll-anchor regression. Restored it (native anchoring stays OFF; the RO does the settle). Opus had judged this removal acceptable; took Codex's stricter call.
2. SILENT — the ResizeObserver observed #messages (the scroll container, fixed by flex layout, never resizes) instead of #msgInner (.messages-inner, the growing transcript node) → callback would never fire. Now observes #msgInner.
3. SILENT — stale RO callbacks mutated the global _settleRO, which could disconnect a newer active observer after settle re-entry. Now instance-owned: close over const ro, only clear the global when _settleRO===ro.
3 maintainer-review items the contributor hadn't addressed:
1. (blocking) restored .session-list overflow-anchor:none — the PR accidentally reverted the deliberate Firefox sidebar fix (2dfe765b); only the on-topic .messages removal is kept.
2/3. _settleFinalScroll was dead code + the promised 2s static-content safety timeout was missing — wired _settleFinalScroll(token) via a single 2s top-level fallback after _settleRO.observe(el), declared _settleFinalTimer + cleared it in _cancelBottomSettle and at settle re-entry.
Opus final gate caught that the staged re-implementation placed the
window._showThinking===false gate in _worklogReasonNodeFromText, which has
ZERO callers — so live/settled reasoning rows (built by _syncWorklogReasonFromAnchor
and _appendWorklogReason) were never actually hidden, and removeThinking swept
data-worklog-reason-source=reasoning which the real rows (data-worklog-anchor-reason=1)
don't carry. The string-match tests passed against the dead function = green-suite blind spot.
Fix:
- Gate _syncWorklogReasonFromAnchor (live + settled): when _showThinking===false, remove any
existing reasoning row and bail before building.
- Gate _appendWorklogReason (settled rebuild): return null when _showThinking===false.
- removeThinking + the settled-rebuild cleanup now sweep BOTH data-worklog-anchor-reason=1
(the real attribute) and the legacy data-worklog-reason-source=reasoning.
- Re-anchor the tests to assert against the real render functions + the correct attribute.
- _applyTransparentRowFading was only called from the settled loop (which the fn skips), so live-turn recency fade never applied — now called from _syncTransparentEventControls (internally live-gated, no-ops on settled).
- _attachProgressBar isCompleted now includes Interrupted so an interrupted row's bar fills 100% instead of rendering empty.
- C1-r2 (both Codex+Opus): _rehydrateTransparentStreamDom now also rewires the root element when it IS the assistant turn (querySelectorAll only matched descendants), so turn-toggle + event-controls re-bind after a live-turn restore.
- O-Bug2-r2: preserve the Full/Output detail tab regardless of open state.
- r2 #4: drop the 'Running: ' name prefix — the legible status badge already carries it (V6 redundancy class).
Functional (Codex+Opus trifecta findings):
- C1: restoreLiveTurnHtmlForSession now calls _rehydrateTransparentStreamDom (controls were dead after active-session live-turn restore).
- O-Bug1: per-turn echoed-thinking dedup in the transparent settled path (mirrors the compact seenReasons), fixes duplicate out-of-order Thinking rows.
- O-Bug2: tool completion preserves the row's open state + Full/Output tab.
- O-Edge: settled/reloaded done===false tools show 'Interrupted' (not a permanent Running shimmer); progress bar clears for Interrupted too.
Visual (trifecta CSS):
- V1: reset .thinking-card to flat/transparent (was the heaviest object in the stream).
- V2: failed=error color + left border, running/interrupted legible (was an invisible muted badge).
- V3: scoped !important reset beats the 6 per-skin .tool-card re-card rules.
- V4: inline key:value args (was a tall stacked form).
- V5/V7: output pre flattened to a quiet left-rail, max-height:none (one expanded zone, no nested scroll).
- V6: drop redundant tool/status pseudo-args from the detail body.
- V8: row fading gated to the live turn only + floor raised for WCAG; settled history stays full-opacity.
- V9: entrance animation scoped to #liveAssistantTurn (was replaying on every renderMessages).
- V10: larger mobile header tap targets + active detail-tab underline.
O-Bug3 (live thinking overwrite when no seq) intentionally deferred — documented follow-up; settled render is already correct and a live heuristic risks a streaming regression.
Opus advisor found a key-space asymmetry: _pruneLineageReportCacheToVisibleSessions
built visibleKeys from RAW rows via _sidebarLineageKeyForRow, but the render loop
keys the lineage-report cache by _sidebarLineageKeyForRow on the COLLAPSED row,
which can differ when collapse merges segments. On a malformed/edge chain the
expanded row's cache could be evicted every payload and re-fetched ~every 5s
(partial regression of the bug #4020 fixes). Fold the collapsed rows' cache keys
into the visible set too, mirroring the _resolveSessionIdFromSidebarLineage
precedent, behind a defensive try/catch.
docs(changelog): stamp #4020+#4055 as v0.51.373 (Release ML)
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>