From a53f3bdaba9a59563c78e495c29d222f62b4d147 Mon Sep 17 00:00:00 2001 From: TARS Date: Fri, 3 Jul 2026 18:32:29 +0200 Subject: [PATCH 1/7] perf(sidebar): skip full session-list rebuild when the payload is unchanged (#5455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 30s streaming poll and the gateway poll re-applied /api/sessions and ran a full sidebar DOM rebuild (list.innerHTML='' + one node tree per row) on every tick, even when the payload was byte-identical to what was already on screen — the common case on an idle sidebar. Add a signature over the applied rows + projects + coarse display state in _applySessionListPayload and skip renderSessionListFromCache() when it matches the last rendered signature and no entrance animation is pending. A streaming row's fields advance each poll so its signature changes and it still repaints; client-only display toggles (search, active, density, select, lineage expansion) render through their own paths. The per-refresh INFLIGHT cleanup is still run on the skip path. The 60s relative-time refresh and all other render triggers call renderSessionListFromCache directly and are unaffected. Output-neutral: a skip only happens when the render would produce the same DOM. node --check + ESLint runtime guard clean. Co-Authored-By: Claude Opus 4.8 --- static/sessions.js | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/static/sessions.js b/static/sessions.js index 441d5ab8..01015ef0 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -4346,6 +4346,31 @@ function _syncSessionAttentionSoundState(sessions){ next.forEach((sig,sid)=>_sessionAttentionSoundState.set(sid,sig)); } +// Signature of everything the sidebar render reads from the applied payload + +// the coarse display state. Used to skip the full DOM rebuild when a poll +// returns data identical to what is already on screen (the common idle case). +// A streaming row's fields advance each poll so its signature changes and it +// still renders; client-only display toggles (search, active, density, select, +// lineage expansion) render through their own paths and are intentionally light +// here. Serialization failure returns null → never skip. (#5455 WS2.4) +let _lastSessionListRenderSig = null; +function _sessionListRenderSignature(){ + try{ + const search=($('sessionSearch')&&$('sessionSearch').value)||''; + return JSON.stringify([ + _allSessions, + _allProjects, + _activeSessionIdForSidebar(), + search, + _sessionSourceFilter, + !!_sessionSelectMode, + (window._sidebarDensity==='detailed'?'d':'c'), + !!_showAllProfiles, + _otherProfileCount,_archivedWebuiCount,_archivedCliCount, + _serverWebuiSessionCount,_serverCliSessionCount, + ]); + }catch(_){ return null; } +} function _applySessionListPayload(sessData, projData){ // Server's other_profile_count tells us how many sessions exist outside the // active profile so the "Show N from other profiles" toggle can render @@ -4428,6 +4453,22 @@ function _applySessionListPayload(sessData, projData){ // authoritative render replaces the profile-switch skeleton — while unrelated renders that // fire before this point stay blocked by the guard in renderSessionListFromCache(). _sessionListSkeletonActive = false; + // No-op fast path: if this payload renders identically to what is already on + // screen (the common case for idle polls) and no entrance animation is + // pending, skip the full DOM rebuild. Only applies here in the fetch/apply + // path; the 60s relative-time refresh and every other render trigger call + // renderSessionListFromCache directly and are unaffected. Guarded by the same + // conditions renderSessionListFromCache bails on, so a bailed render never + // caches a signature that would suppress the next real repaint. (#5455 WS2.4) + const _canRenderNow = !_renamingSid && !_sessionActionMenu; + const _renderSig = _sessionListRenderSignature(); + if(_canRenderNow && !_sessionListRefreshAnimationPending && _renderSig && _renderSig===_lastSessionListRenderSig){ + // Preserve the per-refresh INFLIGHT cleanup that renderSessionListFromCache + // would otherwise perform, then skip only the DOM rebuild. + if(typeof _purgeStaleInflightEntries==='function') _purgeStaleInflightEntries(); + return; + } + if(_canRenderNow) _lastSessionListRenderSig = _renderSig; renderSessionListFromCache(); // no-ops if rename is in progress } From 6a0f5937cf505b7d947b181b9ba79dcb12ee8223 Mon Sep 17 00:00:00 2001 From: TARS Date: Fri, 3 Jul 2026 19:55:37 +0200 Subject: [PATCH 2/7] test: stub sidebar render-skip globals --- tests/test_issue4766_sidebar_source_pushdown.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index abbfb972..c1031174 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -472,13 +472,19 @@ global._sessionListLoadError = null; global._sessionListHasLoadedOnce = false; global._sessionListFirstRenderAnimated = true; global._sessionListSkeletonActive = true; +global._sessionListRefreshAnimationPending = false; +global._lastSessionListRenderSig = null; global._activeProject = null; global.NO_PROJECT_FILTER = '__none__'; global._showAllProfiles = false; global._sessionSourceFilter = 'webui'; +global._renamingSid = null; +global._sessionActionMenu = null; global.S = {{ activeProfile: 'default' }}; global._reconcileActiveSessionIdleStateFromList = rows => rows; global._mergeOptimisticFirstTurnSessions = rows => rows; +global._sessionListRenderSignature = () => ''; +global._purgeStaleInflightEntries = () => {{}}; global._syncSessionAttentionSoundState = () => {{}}; global._pruneLineageReportCacheToVisibleSessions = () => {{}}; global._markPollingCompletionUnreadTransitions = () => {{}}; From 5973d2b0e0f032eec5c7741cd4614b6355db9a8b Mon Sep 17 00:00:00 2001 From: TARS Date: Fri, 3 Jul 2026 20:10:44 +0200 Subject: [PATCH 3/7] fix: narrow sidebar render-skip signature --- static/sessions.js | 15 ++++++++++++++- tests/test_webui_external_refresh_frontend.py | 13 +++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/static/sessions.js b/static/sessions.js index 01015ef0..9f653f1b 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -4357,8 +4357,21 @@ let _lastSessionListRenderSig = null; function _sessionListRenderSignature(){ try{ const search=($('sessionSearch')&&$('sessionSearch').value)||''; + const sessionKeys = [ + 'session_id','id','display_name','title','created_at','started_at', + 'updated_at','last_message_at','last_activity','message_count', + 'actual_message_count','is_streaming','streaming','status','pinned', + 'archived','project_id','source','session_source','generation', + '_lineage_root_id','_lineage_tip_id','_compression_segment_count', + ]; + const sessionsSlim = Array.isArray(_allSessions) ? _allSessions.map(s => { + if(!s || typeof s !== 'object') return s; + const out={}; + sessionKeys.forEach(k=>{ if(Object.prototype.hasOwnProperty.call(s,k)) out[k]=s[k]; }); + return out; + }) : _allSessions; return JSON.stringify([ - _allSessions, + sessionsSlim, _allProjects, _activeSessionIdForSidebar(), search, diff --git a/tests/test_webui_external_refresh_frontend.py b/tests/test_webui_external_refresh_frontend.py index 4d4ff68e..17022f6e 100644 --- a/tests/test_webui_external_refresh_frontend.py +++ b/tests/test_webui_external_refresh_frontend.py @@ -122,6 +122,19 @@ def test_session_event_profile_filter_tolerates_default_root_aliases(): assert "S.activeProfileIsDefault = !!data.is_default;" in PANELS_JS +def test_session_list_render_signature_ignores_non_display_session_fields(): + start = SESSIONS_JS.find("function _sessionListRenderSignature()") + assert start != -1 + block = SESSIONS_JS[start:start + 1500] + assert "const sessionKeys = [" in block + assert "const sessionsSlim = Array.isArray(_allSessions)" in block + assert "sessionKeys.forEach" in block + assert "_allSessions," not in block + assert "message_count" in block + assert "last_message_at" in block + assert "_compression_segment_count" in block + + def test_pwa_pull_to_refresh_refreshes_session_list_not_page_when_available(): assert "window.refreshSessionList('pull', {force:true, refreshActive:true})" in UI_JS assert "Promise.resolve(window.refreshSessionList('pull', {force:true, refreshActive:true})).catch(()=>{}).finally(_ptrReset)" in UI_JS From 3999418b0e46a2ca2369abf2d42e77bfc770f4f0 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 3 Jul 2026 22:58:21 +0000 Subject: [PATCH 4/7] gate-fix #5467: serialize full rows in render-skip signature (close false-skips) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- static/sessions.js | 34 +++++-------- tests/test_webui_external_refresh_frontend.py | 48 +++++++++++++++---- 2 files changed, 52 insertions(+), 30 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index bf82d935..4d80fbbe 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -4346,32 +4346,24 @@ function _syncSessionAttentionSoundState(sessions){ next.forEach((sig,sid)=>_sessionAttentionSoundState.set(sid,sig)); } -// Signature of everything the sidebar render reads from the applied payload + -// the coarse display state. Used to skip the full DOM rebuild when a poll -// returns data identical to what is already on screen (the common idle case). -// A streaming row's fields advance each poll so its signature changes and it -// still renders; client-only display toggles (search, active, density, select, -// lineage expansion) render through their own paths and are intentionally light -// here. Serialization failure returns null → never skip. (#5455 WS2.4) +// Signature of everything the sidebar render reads. Used to skip the full DOM +// rebuild when a poll returns data identical to what is already on screen (the +// common idle case). We serialize the FULL applied row objects (not a curated +// field subset) plus the reference/nesting rows and the coarse display state, so +// ANY server- or client-visible field the render helpers read (streaming/pending +// state, attention dots, source/read-only/worktree/lineage/child/model/profile +// meta, etc.) is covered — a narrow allowlist silently false-skips the moment a +// new rendered field is added (Codex #5467 gate: it omitted pending/running, +// attention, and the source/lineage cluster). A streaming/pending row's fields +// advance each poll so its signature changes and it still renders. Serialization +// failure returns null → never skip (fail-open). (#5455 WS2.4) let _lastSessionListRenderSig = null; function _sessionListRenderSignature(){ try{ const search=($('sessionSearch')&&$('sessionSearch').value)||''; - const sessionKeys = [ - 'session_id','id','display_name','title','created_at','started_at', - 'updated_at','last_message_at','last_activity','message_count', - 'actual_message_count','is_streaming','streaming','status','pinned', - 'archived','project_id','source','session_source','generation', - '_lineage_root_id','_lineage_tip_id','_compression_segment_count', - ]; - const sessionsSlim = Array.isArray(_allSessions) ? _allSessions.map(s => { - if(!s || typeof s !== 'object') return s; - const out={}; - sessionKeys.forEach(k=>{ if(Object.prototype.hasOwnProperty.call(s,k)) out[k]=s[k]; }); - return out; - }) : _allSessions; return JSON.stringify([ - sessionsSlim, + _allSessions, + _sidebarReferenceSessions, _allProjects, _activeSessionIdForSidebar(), search, diff --git a/tests/test_webui_external_refresh_frontend.py b/tests/test_webui_external_refresh_frontend.py index d37f253c..03816568 100644 --- a/tests/test_webui_external_refresh_frontend.py +++ b/tests/test_webui_external_refresh_frontend.py @@ -133,17 +133,47 @@ def test_session_event_profile_filter_tolerates_default_root_aliases(): assert "S.activeProfileIsDefault = !!data.is_default;" in PANELS_JS -def test_session_list_render_signature_ignores_non_display_session_fields(): +def test_session_list_render_signature_serializes_full_rows_not_a_narrow_allowlist(): + """The render-skip signature must serialize the FULL applied rows (+ reference + rows), not a curated field subset — a narrow allowlist silently false-skips + when a rendered field it omits changes (Codex #5467: it dropped pending/running + streaming state, attention dots, and the source/lineage cluster, so an + approval/clarify transition or a row starting to stream could keep a stale + sidebar). Serializing the whole row objects covers every field the render + helpers read, present and future. + """ start = SESSIONS_JS.find("function _sessionListRenderSignature()") assert start != -1 - block = SESSIONS_JS[start:start + 1500] - assert "const sessionKeys = [" in block - assert "const sessionsSlim = Array.isArray(_allSessions)" in block - assert "sessionKeys.forEach" in block - assert "_allSessions," not in block - assert "message_count" in block - assert "last_message_at" in block - assert "_compression_segment_count" in block + block = SESSIONS_JS[start:start + 900] + # Full-object serialization, not a hand-picked allowlist. + assert "const sessionKeys = [" not in block, \ + "signature must not use a narrow field allowlist (it false-skips on omitted fields)" + assert "sessionKeys.forEach" not in block + assert "_allSessions," in block, "signature must serialize the full applied rows" + assert "_sidebarReferenceSessions," in block, \ + "signature must include the hidden reference/nesting rows the sidebar renders" + # Fail-open on serialization failure (never skip on null). + assert "catch(_){ return null; }" in block + + +def test_session_list_render_signature_changes_on_pending_running_and_attention(): + """Regression for the Codex #5467 CORE/SILENT false-skips: because the + signature serializes whole rows, a change to pending/running state or the + attention dot changes the signature (so the sidebar repaints, not skips). + This is a structural guarantee of full-row serialization — assert the + signature does not strip those fields back out. + """ + start = SESSIONS_JS.find("function _sessionListRenderSignature()") + block = SESSIONS_JS[start:start + 900] + # None of the previously-omitted, render-consumed fields may be filtered out. + for stripped in ( + "delete out.active_stream_id", + "delete out.attention", + "delete out.has_pending_user_message", + ): + assert stripped not in block, f"signature must not strip {stripped!r}" + # The whole-object arrays are passed straight to JSON.stringify. + assert "JSON.stringify([" in block def test_pwa_pull_to_refresh_refreshes_session_list_not_page_when_available(): From b438976507c33564f77b22e89ad9515e38bcedc4 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 3 Jul 2026 23:04:41 +0000 Subject: [PATCH 5/7] gate-fix #5467 r2: force render when recovering from skeleton/error-banner DOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.) --- static/sessions.js | 12 +++++++++++- tests/test_webui_external_refresh_frontend.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/static/sessions.js b/static/sessions.js index 4d80fbbe..4775da99 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -4436,6 +4436,11 @@ function _applySessionListPayload(sessData, projData){ _syncSessionAttentionSoundState(_allSessions); _pruneLineageReportCacheToVisibleSessions(_allSessions); _allProjects = projData.projects||[]; + // Capture the recovering-from-error state BEFORE clearing it: the error banner + // DOM was rendered outside the signature path, so if this payload heals with + // rows identical to the last render, the identical-signature skip below would + // leave the stale "Could not load conversations" banner on screen. (Codex #5467) + const _hadSessionListLoadError = !!_sessionListLoadError; _sessionListLoadError = null; _sessionListHasLoadedOnce = true; _markPollingCompletionUnreadTransitions(_allSessions); @@ -4457,6 +4462,7 @@ function _applySessionListPayload(sessData, projData){ // holds the CURRENT profile's rows. Clear the skeleton flag right before painting so this // authoritative render replaces the profile-switch skeleton — while unrelated renders that // fire before this point stay blocked by the guard in renderSessionListFromCache(). + const _hadSessionListSkeleton = _sessionListSkeletonActive; _sessionListSkeletonActive = false; // No-op fast path: if this payload renders identically to what is already on // screen (the common case for idle polls) and no entrance animation is @@ -4465,9 +4471,13 @@ function _applySessionListPayload(sessData, projData){ // renderSessionListFromCache directly and are unaffected. Guarded by the same // conditions renderSessionListFromCache bails on, so a bailed render never // caches a signature that would suppress the next real repaint. (#5455 WS2.4) + // NEVER skip when recovering from a skeleton or error-banner DOM state: those + // are rendered outside the signature path, so an identical-signature match + // would leave the skeleton/error on screen instead of the real list. (Codex #5467) const _canRenderNow = !_renamingSid && !_sessionActionMenu; + const _mustForceRender = _hadSessionListSkeleton || _hadSessionListLoadError; const _renderSig = _sessionListRenderSignature(); - if(_canRenderNow && !_sessionListRefreshAnimationPending && _renderSig && _renderSig===_lastSessionListRenderSig){ + if(_canRenderNow && !_mustForceRender && !_sessionListRefreshAnimationPending && _renderSig && _renderSig===_lastSessionListRenderSig){ // Preserve the per-refresh INFLIGHT cleanup that renderSessionListFromCache // would otherwise perform, then skip only the DOM rebuild. if(typeof _purgeStaleInflightEntries==='function') _purgeStaleInflightEntries(); diff --git a/tests/test_webui_external_refresh_frontend.py b/tests/test_webui_external_refresh_frontend.py index 03816568..62a4140e 100644 --- a/tests/test_webui_external_refresh_frontend.py +++ b/tests/test_webui_external_refresh_frontend.py @@ -176,6 +176,25 @@ def test_session_list_render_signature_changes_on_pending_running_and_attention( assert "JSON.stringify([" in block +def test_session_list_render_signature_does_not_skip_recovering_from_skeleton_or_error(): + """The render-skip must never fire when recovering from a skeleton or the + 'Could not load conversations' banner — both are rendered OUTSIDE the + signature path, so an identical-signature match (empty/same-shaped profile, + or a transient fetch failure healing with identical rows) would leave the + skeleton/error DOM on screen instead of the real list. (Codex #5467 re-gate) + """ + start = SESSIONS_JS.find("function _applySessionListPayload(") + assert start != -1 + body = SESSIONS_JS[start:start + 6000] + assert "const _hadSessionListSkeleton = _sessionListSkeletonActive;" in body + assert "const _hadSessionListLoadError = !!_sessionListLoadError;" in body + assert "const _mustForceRender = _hadSessionListSkeleton || _hadSessionListLoadError;" in body + assert "!_mustForceRender" in body, "the force flag must gate the identical-signature skip" + # captured BEFORE the respective clears + assert body.index("const _hadSessionListSkeleton") < body.index("_sessionListSkeletonActive = false;") + assert body.index("const _hadSessionListLoadError") < body.index("_sessionListLoadError = null;") + + def test_pwa_pull_to_refresh_refreshes_session_list_not_page_when_available(): assert "window.refreshSessionList('pull', {force:true, refreshActive:true})" in UI_JS assert "Promise.resolve(window.refreshSessionList('pull', {force:true, refreshActive:true})).catch(()=>{}).finally(_ptrReset)" in UI_JS From a53c183dd5b8ba4f19a651c2f756296dd1d0c793 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 3 Jul 2026 23:05:03 +0000 Subject: [PATCH 6/7] fix(test): widen _applySessionListPayload slice to reach _mustForceRender assertion --- tests/test_webui_external_refresh_frontend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_webui_external_refresh_frontend.py b/tests/test_webui_external_refresh_frontend.py index 62a4140e..e005e666 100644 --- a/tests/test_webui_external_refresh_frontend.py +++ b/tests/test_webui_external_refresh_frontend.py @@ -185,7 +185,7 @@ def test_session_list_render_signature_does_not_skip_recovering_from_skeleton_or """ start = SESSIONS_JS.find("function _applySessionListPayload(") assert start != -1 - body = SESSIONS_JS[start:start + 6000] + body = SESSIONS_JS[start:start + 12000] assert "const _hadSessionListSkeleton = _sessionListSkeletonActive;" in body assert "const _hadSessionListLoadError = !!_sessionListLoadError;" in body assert "const _mustForceRender = _hadSessionListSkeleton || _hadSessionListLoadError;" in body From f66944d006cb01a6dd039f075ad04a85243b08a9 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 3 Jul 2026 23:23:20 +0000 Subject: [PATCH 7/7] docs(changelog): #5467 sidebar render-skip on idle polls (#5455) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ac74888..6cb88298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixed +- **The sidebar skips its redundant rebuild on idle polls without ever going stale.** The session list re-applied `/api/sessions` and rebuilt the whole sidebar DOM on every 30-second poll even when nothing had changed; it now compares a signature of the full applied rows (plus the hidden nesting rows, projects, filters, density, selection, and counts) and skips the rebuild only when everything the sidebar renders is byte-identical. The signature covers every rendered field — streaming/pending state, attention dots, source/read-only/worktree/lineage/model/profile metadata — so a real change never gets skipped, and recovering from a loading skeleton or a "couldn't load conversations" banner always forces a real repaint. Part of the response to "WebUI feels laggy." Thanks @ai-ag2026. (#5467, #5455) - **Desktop: the transcript no longer jumps back up while the assistant is streaming a reply.** On desktop (which doesn't use the browser's overflow-anchoring), the scroll-position restore keyed off a content-derived anchor that a streaming chunk kept invalidating, so it silently fell back to an absolute scroll position that didn't account for the content growing above the viewport — yanking you upward mid-read. Restore now recovers via the stable per-message index when the content key goes stale, so your reading position holds steady as the answer grows. A genuinely removed message still concedes cleanly (no forced jump to the wrong row), and the separate mobile overflow-anchor fix is untouched. Thanks @allenliang2022. (#5469) - **The live streaming view stays smooth on long answers instead of getting progressively heavier.** With Transparent Stream / compact worklog, the live response area re-rendered the entire growing answer on every streamed frame (and re-serialized the whole turn for its restore snapshot, and re-parsed the full text on every token), so a long reply got quadratically more expensive as it grew — visible as rising CPU and jank late in a response. The live prose row now renders incrementally (the same streaming markdown parser the main chat body already uses, fed only the new text), the restore snapshot is throttled, and the redundant per-token parse is skipped once the answer row exists. Settled messages still render through the canonical full-markdown path (unchanged final output), and every fallback path (parser unavailable, mid-stream edit, session switch) reverts to it cleanly. Part of the response to "WebUI feels laggy." Thanks @ai-ag2026. (#5466, #5455) - **The sidebar no longer keeps polling and rebuilding itself in a hidden background tab.** The 30-second session-list poll and the 60-second relative-time refresh ran unconditionally, so a backgrounded window kept fetching `/api/sessions` and rebuilding a sidebar it couldn't show. Both now pause while the tab is hidden and refresh immediately when you return to it (each poll has its own `visibilitychange` handler), so nothing goes stale — you just stop paying for updates you can't see. (One small timing change: while a tab is hidden *and* a session is streaming, the in-app attention chime / unread badge now fires when you return to the tab rather than mid-hidden; OS-level notifications are unaffected, and browsers already throttled the old hidden-tab poll anyway.) Part of the response to "WebUI feels laggy." Thanks @ai-ag2026. (#5465, #5455)