diff --git a/CHANGELOG.md b/CHANGELOG.md index 318ac9fcc..1fcb2d927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,13 @@ ## [Unreleased] -## [v0.51.536] — 2026-06-20 — Release SU (transparent-stream tool-call rows persist after settle) +## [v0.51.537] — 2026-06-20 — Release SV (queued card clears on session switch) ### Fixed -- **Transparent-stream tool-call rows no longer vanish on turn settle (#4539).** The settled-node cleanup sweep in `renderMessages` was removing `.tool-card-row` elements regardless of type; it now excludes rows with `data-event-type="tool"` (the transparent-stream rows) so they survive settlement. The burst-key assignment also pre-scans burst IDs into a `Set` before the tool-card loop (O(1) lookup instead of O(n) spread per card), and only uses a burst key when the matching assistant segment is present. Thanks @rodboev. +- **The queued-turn card from one session no longer bleeds into another when switching sessions (#4533).** The card-clear logic was only called for the *active* session's drain; session switch had no explicit clear, so a queued card from session A could persist visually when loading session B. `_clearQueueCardDisplay` is now called during `loadSession` on the session being left. Thanks @rodboev. -## [v0.51.535] — 2026-06-20 — Release ST (open model picker stays stable during stream sync) +## [v0.51.536] — 2026-06-20 — Release SU (transparent-stream tool-call rows persist after settle) ### Fixed diff --git a/static/sessions.js b/static/sessions.js index 8d6a27825..fb5f63d1b 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -915,6 +915,7 @@ async function loadSession(sid){ // draft survives page refresh and syncs across clients. if (currentSid && currentSid !== sid) { if(typeof window._clearPendingSelections==='function') window._clearPendingSelections(); + if(typeof _clearQueueCardDisplay==='function') _clearQueueCardDisplay(currentSid); await _saveComposerDraftNow(currentSid, ($('msg') || {}).value || '', S.pendingFiles ? [...S.pendingFiles] : []); // The awaited draft save above yields the event loop. If another // loadSession() started for a different session while we were waiting diff --git a/static/ui.js b/static/ui.js index 9004c2c05..374b0e52d 100644 --- a/static/ui.js +++ b/static/ui.js @@ -5414,6 +5414,29 @@ function setBusy(v){ // normal user bubble in the chat — the chip is removed at drain time. const _queueRenderKeys={}; // per-session fingerprint to avoid redundant rebuilds const _queueCollapsed={}; // per-session: true when user explicitly collapsed the card +let _queueRenderEpoch=0; +function _clearQueueCardDisplay(sid){ + const card=document.getElementById('queueCard'); + const chips=document.getElementById('queueChips'); + if(sid) delete _queueRenderKeys[sid]; + if(card) card.classList.remove('visible'); + if(chips){ + const _chips=chips; + const _card=card; + const _sid=String(sid||''); + const _epoch=_chips.getAttribute('data-queue-render-epoch')||''; + setTimeout(()=>{ + if((_card&&!_card.classList.contains('visible'))||!_card){ + if((_chips.getAttribute('data-queue-render-sid')||'')===_sid&&(_chips.getAttribute('data-queue-render-epoch')||'')===_epoch){ + _chips.innerHTML=''; + } + } + },360); + } + const _msgsEl=document.getElementById('messages'); + if(_msgsEl) _msgsEl.classList.remove('queue-open'); + _updateQueuePill(sid,0); +} function _renderQueueChips(sid){ const card=document.getElementById('queueCard'); @@ -5425,6 +5448,8 @@ function _renderQueueChips(sid){ // Skip re-render if user is actively editing inside the queue panel if(inner.contains(document.activeElement)&&document.activeElement!==inner) return; _queueRenderKeys[sid]=key; + inner.setAttribute('data-queue-render-sid',sid); + inner.setAttribute('data-queue-render-epoch',String(++_queueRenderEpoch)); inner.innerHTML=''; if(!q.length){ card.classList.remove('visible'); @@ -5663,14 +5688,7 @@ function updateQueueBadge(sessionId){ // Only wipe global DOM if this is the currently active session const isActive=S.session&&sid===S.session.session_id; if(isActive){ - const card=document.getElementById('queueCard'); - const chips=document.getElementById('queueChips'); - if(card) card.classList.remove('visible'); - // Defer clear until after slide-out transition so content doesn't vanish mid-animation - if(chips){const _chips=chips;const _card=card;setTimeout(()=>{if(!_card||!_card.classList.contains('visible'))_chips.innerHTML='';},360);} - const _msgsEl=document.getElementById('messages'); - if(_msgsEl) _msgsEl.classList.remove('queue-open'); - _updateQueuePill(sid,0); + _clearQueueCardDisplay(sid); } } } diff --git a/tests/test_regressions.py b/tests/test_regressions.py index ff1e17fa4..3fc3ffcbb 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -534,6 +534,59 @@ def test_session_scoped_message_queue_frontend_wiring(cleanup_test_sessions): assert "updateQueueBadge(sid);" in sessions_src +def test_queue_card_cross_session_clear_called_before_draft_save(cleanup_test_sessions): + """R15c: switching away from one session to another should clear the old + session's queue card before the async draft-save await, so stale DOM cannot + survive into the destination session. + """ + src = (REPO_ROOT / "static/sessions.js").read_text() + block_pattern = re.compile( + r"if \(currentSid && currentSid !== sid\) \{\s*" + r"if\(typeof window\._clearPendingSelections==='function'\) window\._clearPendingSelections\(\);\s*" + r"if\(typeof _clearQueueCardDisplay==='function'\) _clearQueueCardDisplay\(currentSid\);\s*" + r"await _saveComposerDraftNow\(currentSid", + re.S, + ) + assert block_pattern.search(src), ( + "cross-session loadSession path must clear queue card display via" + " _clearQueueCardDisplay(currentSid) before awaiting _saveComposerDraftNow" + ) + + +def test_queue_card_cross_session_helper_used_only_for_session_change(cleanup_test_sessions): + """R15c: _clearQueueCardDisplay(sid) must only fire on cross-session switches, + not on same-session navigation/force-reload code paths. + """ + src = (REPO_ROOT / "static/sessions.js").read_text() + load_start = src.find("async function loadSession(sid){") + assert load_start >= 0 + load_end = src.find(" // Sync context usage indicator from session data", load_start) + load_body = src[load_start:load_end] + cross_start = load_body.find("if (currentSid && currentSid !== sid) {") + cross_end = load_body.find("if (currentSid !== sid || forceReload) {", cross_start) + assert cross_start >= 0 and cross_end >= 0 + assert "_clearQueueCardDisplay(currentSid);" in load_body[cross_start:cross_end], ( + "queue-card clear helper must be inside the cross-session branch" + ) + same_session_idx = load_body.find("if(currentSid===sid && !forceReload && !_loadingSessionId) return;") + assert same_session_idx >= 0 + assert same_session_idx < cross_start + + +def test_queue_card_clear_helper_tracks_render_epoch(cleanup_test_sessions): + """R15d: the delayed queue clear must only wipe the chips from the render it + was asked to dismiss, not a later render for the same shared queue DOM. + """ + src = (REPO_ROOT / "static/ui.js").read_text() + assert "let _queueRenderEpoch=0;" in src + assert "if(sid) delete _queueRenderKeys[sid];" in src + assert "inner.setAttribute('data-queue-render-sid',sid);" in src + assert "inner.setAttribute('data-queue-render-epoch',String(++_queueRenderEpoch));" in src + assert "const _epoch=_chips.getAttribute('data-queue-render-epoch')||'';" in src + assert "(_chips.getAttribute('data-queue-render-sid')||'')===_sid" in src + assert "(_chips.getAttribute('data-queue-render-epoch')||'')===_epoch" in src + + def test_chat_start_persists_pending_turn_metadata_for_reload_recovery(cleanup_test_sessions): """R15c: chat/start must expose enough pending-turn metadata for a reload to rebuild the in-flight conversation instead of showing a blank session.