Merge pull request #5489 from nesquena/release/stage-5467

Release v0.51.842 — sidebar render-skip on idle polls (#5467)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-07-03 16:48:46 -07:00
commit 7a700b96db
4 files changed
+125

No files matched your search

+1
View File
@@ -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)
- **A failed send no longer wipes the message you just typed.** When a message couldn't be started because of a provider or background error (auth failure, rate limit, connection refused, etc.), the composer had already been cleared, so the error message appeared with an empty box and you had to retype the whole thing from scratch. Now, when the send is rejected before the turn actually starts, your original text is put back in the composer (and any attached files are re-staged) so you can fix the issue and re-send with one key press. It restores exactly what you typed — not the expanded form of a slash command like `/moa` — never overwrites a new message you started typing while the send was in flight, and won't touch the composer if you've since switched to a different conversation (your draft is still saved to the original one). (#5472, #5488)
- **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)
+56
View File
@@ -4346,6 +4346,36 @@ function _syncSessionAttentionSoundState(sessions){
next.forEach((sig,sid)=>_sessionAttentionSoundState.set(sid,sig));
}
// 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)||'';
return JSON.stringify([
_allSessions,
_sidebarReferenceSessions,
_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
@@ -4406,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);
@@ -4427,7 +4462,28 @@ 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
// 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)
// 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 && !_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();
return;
}
if(_canRenderNow) _lastSessionListRenderSig = _renderSig;
renderSessionListFromCache(); // no-ops if rename is in progress
}
@@ -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 = () => {{}};
@@ -133,6 +133,68 @@ 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_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 + 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_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 + 12000]
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