diff --git a/CHANGELOG.md b/CHANGELOG.md index abbf02963..7d1231cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.384] — 2026-06-13 — Release MW (no false streaming / activity-timer reset on session switch, #3900) + +### Fixed + +- **An idle session no longer shows streaming chrome (Stop/spinner/thinking) right after a sidebar switch, and switching back to a live stream no longer resets its activity timer (#3900).** `loadSession` now clears `S.busy` / `S.activeStreamId` as soon as the session metadata confirms there is no `active_stream_id` — before the async message-load gap — so the previous session's busy flag can't bleed onto an idle chat. On switch-away it snapshots the live-turn DOM before replacing the message pane (seeding an `INFLIGHT` bucket if needed) and restores that HTML on the active-stream return path instead of rebuilding the worklog shell from scratch, so the elapsed timer and live trace survive the round-trip. (#3900) + ## [v0.51.383] — 2026-06-13 — Release MV (desktop tab title keeps active session name on bot-name refresh, #4086) ### Fixed @@ -265,6 +271,7 @@ - **Worklog details settings now align with the live-to-final model.** The old "Activity expanded by default" setting is renamed to **Worklog details** (default folded), the legacy "Compact tool activity" preference is deprecated, and the Worklog renderer stays enabled for older installs that had saved `simplified_tool_calling=false`. (#3400, #3820) + ## [v0.51.346] — 2026-06-09 — Release LJ (PWA notification controls) ### Added diff --git a/static/sessions.js b/static/sessions.js index 51e723fca..6b78c4799 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -878,6 +878,22 @@ async function loadSession(sid){ // close streams for the session the user actually landed on (#1060 guard, // extended to cover the new pre-switch await). if (_loadingSessionId !== sid) return; + // Snapshot the live turn before msgInner is replaced. Preserves the activity + // timer, partial response, and tool cards so switching back does not rebuild + // the stream UI from scratch. + if( + (S.busy||S.activeStreamId||(INFLIGHT&&INFLIGHT[currentSid]))&& + typeof snapshotLiveTurnHtmlForSession==='function' + ){ + if(!INFLIGHT[currentSid]){ + INFLIGHT[currentSid]={ + messages:Array.isArray(S.messages)?[...S.messages]:[], + uploaded:[], + toolCalls:Array.isArray(S.toolCalls)?[...S.toolCalls]:[], + }; + } + snapshotLiveTurnHtmlForSession(currentSid); + } } if (currentSid !== sid || forceReload) { // #3306: When force-reloading the currently-active session (e.g. external @@ -1040,14 +1056,21 @@ async function loadSession(sid){ if(typeof startSessionStream==='function') startSessionStream(S.session.session_id); const activeStreamId=S.session.active_stream_id||null; - // If the server says the session is idle, discard any browser-side inflight - // cache left behind by a crashed/restarted stream. Otherwise the UI can keep - // showing a permanent thinking/running state even though active_streams=0. - if(!activeStreamId&&INFLIGHT[sid]){ - delete INFLIGHT[sid]; - if(typeof clearInflightState==='function') clearInflightState(sid); + // If the server says the session is idle, reset browser-side streaming flags + // NOW — before the async _ensureMessagesLoaded gap below. Without this, + // S.busy can remain true from a still-running stream in the PREVIOUS session + // while S.session.session_id has already advanced to the new one. + // _isSessionLocallyStreaming() checks (isActive && S.busy), so during the + // async window the new session would appear locally-streaming (sidebar spinner, + // Stop button, thinking state on an idle chat). Also clears stale INFLIGHT + // entries left behind by a crashed/restarted stream. + if(!activeStreamId){ S.activeStreamId=null; S.busy=false; + if(INFLIGHT[sid]){ + delete INFLIGHT[sid]; + if(typeof clearInflightState==='function') clearInflightState(sid); + } } function _mergePendingSessionMessage(session,messages){ @@ -1262,10 +1285,15 @@ async function loadSession(sid){ updateSendBtn(); setStatus(''); setComposerStatus(''); - // syncTopbar();renderMessages();appendThinking();loadDir('.'); syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined); - if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell(); - else appendThinking(); + let restoredLiveTurn=false; + if(typeof restoreLiveTurnHtmlForSession==='function'){ + restoredLiveTurn=restoreLiveTurnHtmlForSession(sid); + } + if(!restoredLiveTurn){ + if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell(); + else appendThinking(); + } loadDir('.'); updateQueueBadge(sid); startApprovalPolling(sid); diff --git a/tests/test_session_switch_busy_race.py b/tests/test_session_switch_busy_race.py new file mode 100644 index 000000000..71170f0ff --- /dev/null +++ b/tests/test_session_switch_busy_race.py @@ -0,0 +1,87 @@ +"""Regression coverage for session-switch busy-state race and live-turn restore. + +Switching from a streaming session to an idle one must clear S.busy before the +async _ensureMessagesLoaded gap. Otherwise _isSessionLocallyStreaming() treats +the newly opened session as locally streaming while messages are still loading. + +Switching back to a streaming session must restore the snapshotted live turn +instead of rebuilding thinking/worklog chrome from scratch. +""" + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +SESSIONS_SRC = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") +UI_SRC = (REPO / "static" / "ui.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_loadSession_clears_busy_before_async_message_load_when_server_idle(): + body = _function_body(SESSIONS_SRC, "async function loadSession(") + + idle_reset = body.find("if(!activeStreamId){") + assert idle_reset != -1, "loadSession must gate idle cleanup on missing active_stream_id" + idle_block = body[idle_reset : idle_reset + 500] + assert "S.busy=false" in idle_block, "idle switch must clear S.busy immediately" + assert "S.activeStreamId=null" in idle_block, "idle switch must clear S.activeStreamId immediately" + + ensure_load = body.find("await _ensureMessagesLoaded(sid)") + assert ensure_load != -1, "loadSession must still lazy-load messages for idle sessions" + assert idle_reset < ensure_load, ( + "S.busy must be cleared before _ensureMessagesLoaded so session-list polling " + "during the async gap does not mark the new session as locally streaming" + ) + + +def test_loadSession_snapshots_live_turn_before_wiping_message_pane(): + body = _function_body(SESSIONS_SRC, "async function loadSession(") + + snap_pos = body.find("snapshotLiveTurnHtmlForSession(currentSid)") + # Anchor on the actual loading-placeholder marker (unique), not the + # whitespace-sensitive innerHTML literal which also matches the + # "Session not available" error handler. (Maintainer review.) + wipe_pos = body.find("Loading conversation...") + assert snap_pos != -1, "loadSession must snapshot the outgoing live turn before switching" + assert wipe_pos != -1, "loadSession must still show the loading placeholder on switch" + assert snap_pos < wipe_pos, "snapshot must run before msgInner is replaced with the loading placeholder" + + +def test_loadSession_restores_live_turn_on_active_stream_return_path(): + body = _function_body(SESSIONS_SRC, "async function loadSession(") + + # The restore that actually fires on switch-back is the Phase 2a path: after + # loadInflightState() rehydrates INFLIGHT for an active stream, the streaming + # branch calls restoreLiveTurnHtmlForSession(sid). (The old Phase-2b idle-branch + # call was unreachable — INFLIGHT is always seeded by then — so assert the live + # Phase 2a path. Maintainer review.) + phase2a = body.find("Phase 2a") + assert phase2a != -1, "loadSession must keep the Phase 2a streaming-restore branch" + inflight_load = body.find("loadInflightState(sid", phase2a) + assert inflight_load != -1, "Phase 2a must rehydrate INFLIGHT from persisted state for an active stream" + restore = body.find("restoreLiveTurnHtmlForSession(sid)", inflight_load) + assert restore != -1, ( + "the active-stream return path must restore the snapshotted live-turn HTML " + "after rehydrating INFLIGHT (Phase 2a), instead of rebuilding the worklog shell" + ) + + +def test_activity_timer_reads_pending_started_at(): + body = _function_body(UI_SRC, "function _activityElapsedStartedAt(") + assert "pending_started_at" in body + assert "data-turn-started-at" in body or "turnStartedAt" in body