Merge PR #5467 from ai-ag2026: skip full session-list rebuild when payload unchanged (#5455)

This commit is contained in:
nesquena-hermes 2026-07-03 22:47:40 +00:00
commit 10e5e941d3
3 changed files with 73 additions and 0 deletions

View file

@ -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
}

View file

@ -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 = () => {{}};

View file

@ -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