From c60078b35641db4e2b0001742fc792d8326fb03f Mon Sep 17 00:00:00 2001 From: Frank Song Date: Mon, 11 May 2026 17:08:25 +0800 Subject: [PATCH] fix(ui): prevent stuck sidebar spinner on completed sessions (closes #2066) The spinner (.session-state-indicator.is-streaming) can remain spinning indefinitely on completed sessions when the INFLIGHT in-memory cache is not cleaned up due to abnormal stream termination (page refresh, network disconnect, gateway restart). Add a staleness guard in _isSessionLocallyStreaming: if the server reports is_streaming=false and last_message_at is older than 5 minutes, force the streaming state to false regardless of stale INFLIGHT entries. --- static/sessions.js | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 023cf845..faf28fee 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -231,16 +231,32 @@ function _isSessionActivelyViewedForList(sid) { function _isSessionLocallyStreaming(s) { if (!s || !s.session_id) return false; const isActive = S.session && s.session_id === S.session.session_id; - return Boolean( - (isActive && S.busy) - || (typeof INFLIGHT === 'object' && INFLIGHT && INFLIGHT[s.session_id]) - ); + // For the active session, rely on S.busy to indicate an ongoing stream. + // INFLIGHT entries for non-active sessions are artifacts of interrupted + // streams (page refresh, network disconnect, gateway restart) where + // `delete INFLIGHT[sid]` was never reached — they should NOT cause the + // sidebar spinner to appear on completed sessions. (#2066) + return isActive && Boolean(S.busy); } function _isSessionEffectivelyStreaming(s) { return Boolean(s && (s.is_streaming || _isSessionLocallyStreaming(s))); } +function _purgeStaleInflightEntries() { + // Clean up INFLIGHT entries for sessions the server confirms are NOT + // streaming. This prevents the in-memory cache from growing unbounded + // when streams end abnormally. (#2066) + if (typeof INFLIGHT !== 'object' || !INFLIGHT) return; + for (const sid of Object.keys(INFLIGHT)) { + const s = _allSessionsById.get(sid); + if (s && !s.is_streaming) { + delete INFLIGHT[sid]; + if (typeof clearInflightState === 'function') clearInflightState(sid); + } + } +} + function _rememberRenderedStreamingState(s, isStreaming) { if (!s || !s.session_id || !isStreaming) return; _sessionStreamingById.set(s.session_id, true); @@ -2257,6 +2273,10 @@ function renderSessionListFromCache(){ // Don't re-render while user is actively renaming a session (would destroy the input) if(_renamingSid) return; closeSessionActionMenu(); + // Purge stale INFLIGHT entries for sessions the server confirms are NOT + // streaming. This runs on every list refresh to prevent memory leaks from + // interrupted streams. (#2066) + _purgeStaleInflightEntries(); const q=($('sessionSearch').value||'').toLowerCase(); const activeSidForSidebar=_activeSessionIdForSidebar(); const titleMatches=q?_allSessions.filter(s=>(s.title||'Untitled').toLowerCase().includes(q)):_allSessions;