fix(queue): drain correct session queue after cross-session stream completion (#964)

When a session finishes streaming while the user has switched to a different
session, setBusy(false) was draining S.session.session_id (the currently
*viewed* session) instead of the session that actually finished. Queued
follow-up messages were silently dropped.

Root cause: setBusy() has no context about which session triggered it.
The activeSid closure variable inside attachLiveStream() knew the right
session but was not propagated.

Fix: add _queueDrainSid module global (null by default). Stream done and
error handlers set it to activeSid immediately before calling setBusy(false).
setBusy(false) reads and clears _queueDrainSid, falling back to S.session if
it is unset (the common case where the user hasn't switched away).

Handlers patched: done event, start-call error handler, stream_end/stream_stop
reconnection fallback, and max-retry error exit.

Co-authored with Claude Sonnet 4.6 / Anthropic.
This commit is contained in:
Basit Mustafa
2026-04-24 12:33:56 -07:00
committed by GitHub
parent 619646159c
commit e62338d3a0
3 changed files with 19 additions and 9 deletions
+4 -4
View File
@@ -153,7 +153,7 @@ async function send(){
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);removeThinking();
if(!_clarifySessionId || _clarifySessionId===activeSid) hideClarifyCard(true);
S.messages.push({role:'assistant',content:`**Error:** ${errMsg}`});
renderMessages();setBusy(false);setComposerStatus(`Error: ${errMsg}`);
_queueDrainSid=activeSid;renderMessages();setBusy(false);setComposerStatus(`Error: ${errMsg}`);
return;
}
@@ -726,7 +726,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_markSessionViewed(activeSid, d.session.message_count ?? S.messages.length);
syncTopbar();renderMessages();loadDir('.');
}
renderSessionList();setBusy(false);setStatus('');
_queueDrainSid=activeSid;renderSessionList();setBusy(false);setStatus('');
setComposerStatus('');
playNotificationSound();
sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished');
@@ -900,7 +900,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_markSessionViewed(activeSid, session.message_count ?? S.messages.length);
syncTopbar();renderMessages();
}
renderSessionList();setBusy(false);setComposerStatus('');
_queueDrainSid=activeSid;renderSessionList();setBusy(false);setComposerStatus('');
return true;
}catch(_){
return false;
@@ -951,7 +951,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
clearLiveToolCards();
removeThinking();
setBusy(false);
_queueDrainSid=activeSid;setBusy(false);
setComposerStatus('');
renderMessages();
renderSessionList();
+12 -3
View File
@@ -1,6 +1,13 @@
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default'};
const INFLIGHT={}; // keyed by session_id while request in-flight
const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns
// Tracks which session's queue to drain in setBusy(false).
// Set to activeSid just before setBusy(false) in done/error handlers so the
// queue drains the session that *finished*, not the one currently viewed.
// Single-shot: setBusy() reads and clears this on every call. Concurrent
// back-to-back stream completions would overwrite it, but HTTPServer is
// single-threaded so only one done event fires at a time in practice.
let _queueDrainSid=null;
const $=id=>document.getElementById(id);
function _getSessionQueue(sid, create=false){
if(!sid) return [];
@@ -946,10 +953,12 @@ function setBusy(v){
setComposerStatus('');
// Always hide Cancel button when not busy
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
const sid=S.session&&S.session.session_id;
const sid=_queueDrainSid||(S.session&&S.session.session_id);
_queueDrainSid=null;
updateQueueBadge(sid);
// Drain one queued message for the currently viewed session after UI settles
const next=sid?shiftQueuedSessionMessage(sid):null;
// Drain one queued message for the finished session after UI settles
const _isViewedSid=!S.session||sid===S.session.session_id;
const next=sid&&_isViewedSid?shiftQueuedSessionMessage(sid):null;
if(next){
updateQueueBadge(sid);
setTimeout(()=>{
+3 -2
View File
@@ -501,8 +501,9 @@ def test_session_scoped_message_queue_frontend_wiring(cleanup_test_sessions):
assert "const SESSION_QUEUES" in ui_src
assert "function queueSessionMessage" in ui_src
assert "function shiftQueuedSessionMessage" in ui_src
assert "const sid=S.session&&S.session.session_id;" in ui_src
assert "const next=sid?shiftQueuedSessionMessage(sid):null;" in ui_src
# _queueDrainSid tracks which session's queue to drain even after session switches
assert "_queueDrainSid" in ui_src
assert "shiftQueuedSessionMessage(sid)" in ui_src
assert "queueSessionMessage(S.session.session_id" in messages_src
assert "updateQueueBadge(S.session.session_id);" in messages_src
assert "updateQueueBadge(sid);" in sessions_src