diff --git a/CHANGELOG.md b/CHANGELOG.md index 006b1d72..3686209e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - **PR #2445** by @Michaelyklam (fixes #2443) — `/api/models` now fingerprints model-catalog inputs as part of its persisted cache metadata, so server-side catalog additions and Codex local catalog changes invalidate `models_cache.json` immediately instead of waiting for the 24-hour TTL or manual cache deletion. - **PR #2450** by @Michaelyklam (fixes #2447) — Cap the optional streaming word-fade drain after the final `done` SSE event so very large or bursty completed responses are rendered from the canonical session promptly instead of keeping the chat in a live/working state until Stop is pressed. - **PR #2452** by @Michaelyklam (fixes #2451) — Manual WebUI cron triggers now deliver the same final response or failure notice as scheduled cron runs, while still saving output files and recording delivery errors separately from job execution failures. +- 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) diff --git a/static/sessions.js b/static/sessions.js index 0e15f267..8aa247e1 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 @@ -1893,9 +1918,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) @@ -1906,6 +1928,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" + )