From bd9a4924bf5fd9c5561e1d9415dd1dfa7de05749 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 17 May 2026 17:34:43 +0800 Subject: [PATCH] fix: clear stale active session spinner --- CHANGELOG.md | 4 ++ static/sessions.js | 32 +++++++++- .../test_issue2454_active_session_spinner.py | 62 +++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 tests/test_issue2454_active_session_spinner.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdd..7c3792f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Keep the sidebar spinner in sync with server session metadata when the currently open session has finished but the browser still has stale local busy state (#2454). + ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin) ### Added diff --git a/static/sessions.js b/static/sessions.js index 1665ee8f..0e003f02 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -256,6 +256,31 @@ function _isSessionEffectivelyStreaming(s) { return Boolean(s && (s.is_streaming || _isSessionLocallyStreaming(s))); } +function _reconcileActiveSessionIdleStateFromList(serverRows) { + if (!S || !S.session || !S.session.session_id) return false; + if (typeof _sendInProgress !== 'undefined' && _sendInProgress) return false; + if (!Array.isArray(serverRows)) return false; + const sid=S.session.session_id; + const serverRow=serverRows.find(s=>s&&s.session_id===sid); + if (!serverRow) return false; + const serverRowIsIdle=!serverRow.is_streaming&&!serverRow.active_stream_id&&!serverRow.pending_user_message; + if (!serverRowIsIdle) return false; + let changed=false; + if (S.busy) { S.busy=false; changed=true; } + if (S.activeStreamId) { S.activeStreamId=null; changed=true; } + if (INFLIGHT&&INFLIGHT[sid]) { + delete INFLIGHT[sid]; + if (typeof clearInflightState==='function') clearInflightState(sid); + changed=true; + } + if (S.session) { + S.session.active_stream_id=null; + S.session.pending_user_message=null; + } + if (changed&&typeof updateSendBtn==='function') updateSendBtn(); + return changed; +} + function _purgeStaleInflightEntries() { // Clean up INFLIGHT entries for sessions the server confirms are NOT // streaming. This prevents the in-memory cache from growing unbounded @@ -1878,9 +1903,6 @@ function _applySessionListPayload(sessData, projData){ // active profile so the "Show N from other profiles" toggle can render // without a second round-trip. Stashed on the module for renderSessionListFromCache. _otherProfileCount = sessData.other_profile_count || 0; - _allSessions = _mergeOptimisticFirstTurnSessions(sessData.sessions||[]); - _clearLineageReportCache(); - _allProjects = projData.projects||[]; // Capture server clock for clock-skew compensation (issue #1144). // server_time is epoch seconds from the server's time.time(). // _serverTimeDelta = client - server, so (Date.now() - _serverTimeDelta) @@ -1891,6 +1913,10 @@ function _applySessionListPayload(sessData, projData){ if (typeof sessData.server_tz === 'string') { _serverTz = sessData.server_tz; } + _reconcileActiveSessionIdleStateFromList(sessData.sessions||[]); + _allSessions = _mergeOptimisticFirstTurnSessions(sessData.sessions||[]); + _clearLineageReportCache(); + _allProjects = projData.projects||[]; _markPollingCompletionUnreadTransitions(_allSessions); const isStreaming = _allSessions.some(s => Boolean(s && s.is_streaming)); if (isStreaming) { diff --git a/tests/test_issue2454_active_session_spinner.py b/tests/test_issue2454_active_session_spinner.py new file mode 100644 index 00000000..7db2d390 --- /dev/null +++ b/tests/test_issue2454_active_session_spinner.py @@ -0,0 +1,62 @@ +"""Regression coverage for #2454 active-session stale sidebar spinner. + +The backend can already reconcile stale stream state and return `/api/sessions` +rows with `is_streaming: false`, `active_stream_id: null`, and +`pending_user_message: null`. The remaining bug is frontend-local: the current +open session can keep `S.busy = true`, so `_isSessionLocallyStreaming()` still +makes the sidebar row render as streaming even after the server says idle. +""" + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +SESSIONS_SRC = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _function_body(src: str, signature: str) -> str: + start = src.find(signature) + assert start != -1, f"missing {signature}" + brace = src.find("{", start) + assert brace != -1, f"missing opening brace for {signature}" + depth = 0 + for i in range(brace, len(src)): + ch = src[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[brace + 1 : i] + raise AssertionError(f"could not extract function body for {signature}") + + +def test_active_session_idle_reconcile_clears_stale_busy_and_inflight_state(): + body = _function_body(SESSIONS_SRC, "function _reconcileActiveSessionIdleStateFromList(") + + assert "serverRows" in body, "reconcile must inspect raw /api/sessions rows before optimistic merging" + assert "S.session.session_id" in body, "reconcile must target the currently active session" + assert "_sendInProgress" in body, "cleanup must not interrupt a send that has not received stream_id yet" + assert "!serverRow.is_streaming" in body, "server idle metadata must gate the cleanup" + assert "!serverRow.active_stream_id" in body, "active stream id must be absent before cleanup" + assert "!serverRow.pending_user_message" in body, "pending user text must be absent before cleanup" + assert "S.busy=false" in body, "stale local busy state must be cleared" + assert "S.activeStreamId=null" in body, "stale active stream id must be cleared" + assert "delete INFLIGHT[sid]" in body, "stale active-session inflight cache must be purged" + assert "clearInflightState(sid)" in body, "persisted inflight cache must be cleared too" + assert "updateSendBtn()" in body, "composer controls must reflect the idle state after cleanup" + + +def test_session_list_payload_reconciles_active_idle_state_before_optimistic_merge_and_render(): + body = _function_body(SESSIONS_SRC, "function _applySessionListPayload(") + + reconcile_pos = body.find("_reconcileActiveSessionIdleStateFromList(sessData.sessions||[])") + merge_pos = body.find("_allSessions = _mergeOptimisticFirstTurnSessions") + render_pos = body.find("renderSessionListFromCache()") + + assert reconcile_pos != -1, "active-session idle reconciliation must run for refreshed rows" + assert merge_pos != -1, "session rows must still be applied from /api/sessions" + assert render_pos != -1, "payload application must still render from cache" + assert reconcile_pos < merge_pos < render_pos, ( + "local S.busy/INFLIGHT state must be reconciled against raw server rows " + "before optimistic merging can re-label a stale active session as streaming" + )