A reactivated older conversation wasn't bumped to the top / into Today until a
manual refresh when the viewing tab was backgrounded/unfocused: refreshSessionList
early-returns on document.hidden unless force=true, and the sidebar SSE closes on
blur — but the in-flight/pending coalescing only preserved the refresh REASON
string, dropping the opts (force/refreshActive). So a coalesced 'sessions_changed'
refresh lost its force flag and got skipped on a hidden tab. Now the pending
request preserves merged opts (force/refreshActive OR-merged), and a dedicated
resume-refresh path forces a catch-up when the sidebar regains focus, so
cross-device recency re-sorts land without a manual refresh.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Codex re-gate found 2 more false-skips (DOM state OUTSIDE the signature path):
- CORE: profile-switch skeleton stays visible if the new payload signature
matches the last one (empty/same-shaped profile) — skip fires over the skeleton.
- SILENT: stale 'Could not load conversations' banner persists when a transient
fetch failure heals with identical rows.
Fix: capture _hadSessionListSkeleton + _hadSessionListLoadError BEFORE clearing
them, and disallow the identical-signature skip when either was set. + regression
test. (messages.js:1462 finding is a base-comparison artifact for #5472/#5479/#5484,
not in #5467's diff.)
Codex gate found the render-skip signature omitted fields the sidebar renders:
- CORE: pending/running (active_stream_id/has_pending_user_message/pending_started_at)
- SILENT: attention dots
- SILENT: source/read-only/worktree/lineage/child/model/profile + _sidebarReferenceSessions
Any omitted field = a false-skip = stale sidebar.
Root-cause fix (most reliable): serialize the FULL applied row objects +
_sidebarReferenceSessions instead of a hand-picked allowlist — inherently
complete, can't rot when a new rendered field is added. Rewrote the
'ignores non-display fields' test into positive full-serialization + no-strip
regression assertions.
Add a dedicated visibilitychange hook for the 60s relative-time refresh poll so skipped hidden-tab ticks repaint immediately when the tab becomes visible, even when no streaming poll is active.
After a hidden interval that added persisted messages (post-turn bg-review
writes, sibling-tab writes, the just-finished main turn flushing), switching
the tab back caused refreshActiveSessionIfExternallyUpdated('visible'|'focus')
to hit remoteCount !== localCount and call loadSession(sid, {force:true}),
which synchronously did S.messages = [] before awaiting the metadata +
messages fetches. The visible result was the entire conversation transcript
blanking for the round-trip and reappearing — '对话突然消失,重刷才回来'.
sessions.js itself warns about this 'disappear and reappear' tradeoff at the
top of the remoteCount/remoteLast guard, but only short-circuits the
metadata-only branch (#5061). #5122's 4-probe staged reconnect covers a
different path (SSE error with ready_state=2 on a visible tab) and does not
apply when the SSE error arrives while visibility_state='hidden' — that
bottoms out through _deferStreamErrorIfPageHidden before the reconnect block
ever runs.
Fix:
- refreshActiveSessionIfExternallyUpdated maps the visibility/focus recovery
reasons ({visible, focus}) to a new keepStaleUntilLoaded option, forwarded
to loadSession. The post-stream idle reconcile and the poll/external paths
keep their existing behaviour.
- loadSession ANDs opts.keepStaleUntilLoaded with sameSessionForceReload (so
cross-session switches still clear synchronously — leaving a prior
session's transcript on screen during a navigation is the original bug
the clear was written for) and, on that path, skips the synchronous
S.messages/S.toolCalls/_messagesTruncated/_oldestIdx clear. The new
transcript is SWAPPED into S.messages by _ensureMessagesLoaded(sid,
{force:true}), so the user sees old DOM directly replaced by new DOM in a
single render frame.
- _ensureMessagesLoaded grows an opts.force escape hatch so its 'messages
already populated' early-return cannot skip the swap when stale messages
are still in place.
Verified live on a source 8701 build:
- before: minHtmlObserved 65874, minKidsObserved 11 — 372 samples at 16ms,
DOM html length NEVER dipped below the pre-call value during the reload.
- loadSession was called with force:true keepStale:true reason:'visible',
outcome 'reloaded', sMsgs 21 → 1037, after kids 11 → 134 (single-frame
swap).
Tests:
- tests/test_issue5177_hidden_tab_blank_gap.py (new, 7 cases): source-locks
the keep-stale guard shape, the recovery-reason map, the
_ensureMessagesLoaded opts.force escape hatch, and that the synchronous
clear is wrapped in if(!_keepStaleUntilLoaded).
- Updated the two existing source-lock tests whose anchor strings included
the loadSession call signature
(tests/test_webui_external_refresh_frontend.py +
tests/test_tars_scroll_reset_regressions.py) to match the new
loadSession(... keepStaleUntilLoaded:_keepStaleUntilLoaded) form. No
behavioural assertions changed.
Local: node --check static/sessions.js OK. 122 passed across
test_issue5177_hidden_tab_blank_gap.py + the full adjacent set
(external-refresh / journal-frontend / scroll-reset / reconnect-chronology /
inflight-stream-reuse / sse-error-multi-probe / issue4295 / issue4856).
On a hard refresh, boot-restore auto-opens the last session from localStorage,
but that load can finish after the user has started typing into the fresh
composer — and the saved-session draft restore clobbered the typed input.
_restoreComposerDraft already had a preserveActiveInput guard, but the boot
path never set it.
boot.js now calls loadSession(saved, {preserveActiveInput:true}); loadSession
threads it into the draft restore as
preserveActiveInput:!!opts.preserveActiveInput || (currentSid===sid&&forceReload),
preserving the prior same-session-force-reload behavior. A blank composer still
restores the saved draft (the guard only skips when active input is non-empty
and differs). opts is read via arguments[1] so the call is signature-safe.
The 4 one-line test changes refresh exact-string boot snapshots to the new call
signature; test_issue5060_boot_composer_preserve.py adds behavioral coverage
(2 assertions proven RED on master).
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
The active-session external-refresh check (refreshActiveSessionIfExternallyUpdated)
force-reloaded the whole transcript whenever last_message_at/updated_at advanced.
The post-turn background skill/memory review bumps those timestamps without adding
any chat messages, so the destructive loadSession(force) (clears S.messages, awaits
a round-trip) made the open conversation visibly disappear and reappear with no new
content.
The refresh now reloads only when the message count actually changed, and treats a
timestamp-only bump as metadata (advance the local last-seen marker + refresh the
lightweight sidebar list, no transcript teardown).
Maintainer fix on top of the contributor's PR (Codex gate): the count check fires
in BOTH directions — remoteCount !== localCount, not just >. A LOWER remote count
means another tab/client truncated/undid/retried/regenerated the transcript
(/api/session/truncate, /retry, /undo all shrink s.messages and persist a lower
message_count); the original >-only gate would have treated that as metadata-only
and silently kept the stale longer transcript forever. Added a regression test for
the count-decrease case. The pending-user-message count-inflation path is excluded
by the existing busy/active-stream guards.
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
Ships @dso2ng's fix: /api/session now reconciles stale CLI source flags
against the state.db WebUI-origin row, and _isExternalSession() refuses to
classify an explicit WebUI-source session as external — so a WebUI-native
transcript with a stale sidecar isn't force-reloaded like an imported one.
Rebased onto master (contributor's net code-diff applied; byte-identical to
PR head). CHANGELOG stamped v0.51.474.
Co-authored-by: dso2ng <dso2ng@users.noreply.github.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>
## Release v0.51.266 — Release IH (stage-r16)
One agent-authored APPROVED fix + two un-held streaming/SSE fixes.
### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3635 (#3637) | @nesquena-hermes (nesquena APPROVED) | Composer profile chip reads `S.activeProfile` again — a #3331 regression keyed it on the loaded session's profile, so opening a cross-profile session made the chip disagree with the dropdown checkmark and misrepresent where the next message routes. #3331's project/session-op scoping is unaffected. |
| #3587 (#3605) | @rodboev | Reasoning persists to the correct intermediate assistant message in multi-turn tool flows. The index only advanced in `on_interim_assistant` (suppressed for contentless tool-call messages) → post-tool reasoning was mis-attributed; it now also advances at the `on_tool` boundary, guarded against over-increment. **(un-held — finding resolved)** |
| #2660 (#3558) | @franksong2702 | Session-event SSE no longer wakes every tab across profiles and never drops a relevant refresh — profile attached when known, root/`default` aliases stay unscoped, and the `maxsize=1` queue falls back to unscoped refresh-all on a profile-mismatch coalesce. **(un-held — both findings resolved)** |
### Gate
- Full pytest suite: **7770 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — chip matches dropdown/routing (no #3331 scoping regression), reasoning-index advance composes with the agent's tool/interim callback ordering, session-events coalesce safely with no dropped refresh and no profile data leak (`/api/sessions` still server-side filtered).
Co-authored-by: nesquena <nesquena@users.noreply.github.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Capture a same-session force-reload hint (loaded renderable/message counts,
known count, truncation flag) BEFORE clearing the in-memory transcript, so the
authoritative reload requests a width that preserves what was loaded instead of
collapsing a long session to the default 30-message tail window mid-read.
Same-session force reloads render with preserveScroll. Resolves conflict with
the shipped #3306 carry-forward snapshot by keeping both (complementary).
Closes#3239.
Co-authored-by: viraatdas <viraatdas@users.noreply.github.com>
Force same-session external refreshes to dismiss stale approval and clarification prompts immediately so completed state.db updates do not leave the composer blocked.
When API server runs append messages directly to state.db, reconcile WebUI sidecar sessions with those canonical rows across API responses, model-facing streaming context, and active browser refresh.
Add append-only state.db merge helpers, metadata-only counts for refresh polling, and regression coverage for API visibility, context incorporation, and frontend refresh behavior.