diff --git a/static/sessions.js b/static/sessions.js index 73560c49..bf82d935 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -4346,6 +4346,44 @@ 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)||''; + 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, + _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 +4466,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 } 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 = () => {{}}; diff --git a/tests/test_webui_external_refresh_frontend.py b/tests/test_webui_external_refresh_frontend.py index 395c5737..d37f253c 100644 --- a/tests/test_webui_external_refresh_frontend.py +++ b/tests/test_webui_external_refresh_frontend.py @@ -133,6 +133,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