fix(notifications): make hidden-tracker stream-owned + clear on terminal paths (#4416 Codex gate)

Codex SHIP-WITH-FIXES: non-done terminal paths (apperror/cancel/stream-error
finalize) didn't clear _STREAM_WAS_HIDDEN, so a stale wasHidden=true could be
inherited by a later same-sid reconnect -> spurious notification. Make each
entry stream-owned ({streamId, wasHidden}) so a stale entry from a prior stream
is never trusted (reconnect keeps state only when streamId matches; done reads
via the owned entry), and add _clearStreamHidden(activeSid, streamId) on the
apperror/cancel/finalize-fallback terminal paths (belt-and-suspenders).
This commit is contained in:
nesquena-hermes
2026-06-19 23:54:23 +00:00
parent d473f6c5a6
commit ca19574b75
2 changed files with 40 additions and 13 deletions
+33 -11
View File
@@ -1426,19 +1426,31 @@ const LIVE_STREAMS={};
// #4416: track whether the tab was hidden at ANY point during a live stream, so
// the response-complete notification fires for a backgrounded tab even when
// Chromium throttles the background-tab SSE and delivers the `done` event LATE
// (after the user returns, when document.hidden already reads false). Keyed by
// session id; set at stream attach, OR'd true whenever the tab goes hidden,
// read + cleared at done. One idempotent visibilitychange listener (never
// leaks) flips the bit on all active entries.
// (after the user returns, when document.hidden already reads false). Each entry
// is STREAM-OWNED ({streamId, wasHidden}) so a stale entry left by a non-`done`
// terminal path (apperror/cancel/stream-error/reconnect-no-active) can never be
// mis-attributed to a later stream for the same session id — a reconnect only
// keeps the prior state when the streamId matches. One idempotent
// visibilitychange listener (never leaks) flips wasHidden on all active entries.
const _STREAM_WAS_HIDDEN={};
let _streamHiddenTrackerBound=false;
function _bindStreamHiddenTracker(){
if(_streamHiddenTrackerBound||typeof document==='undefined'||typeof document.addEventListener!=='function') return;
_streamHiddenTrackerBound=true;
document.addEventListener('visibilitychange',()=>{
if(document.hidden){ for(const k in _STREAM_WAS_HIDDEN) _STREAM_WAS_HIDDEN[k]=true; }
if(document.hidden){ for(const k in _STREAM_WAS_HIDDEN){ const e=_STREAM_WAS_HIDDEN[k]; if(e) e.wasHidden=true; } }
});
}
function _clearStreamHidden(sid, streamId){
// Clear only when we own the current stream's entry (or unconditionally when
// streamId is omitted). Prevents a terminal path for an old stream from wiping
// a newer stream's tracker.
if(!sid) return;
const e=_STREAM_WAS_HIDDEN[sid];
if(!e) return;
if(streamId&&e.streamId&&e.streamId!==streamId) return;
delete _STREAM_WAS_HIDDEN[sid];
}
function closeLiveStream(sessionId, streamId, source){
const live=LIVE_STREAMS[sessionId];
@@ -1506,11 +1518,17 @@ function closeOtherLiveStreams(activeSid){
function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!activeSid||!streamId) return;
const reconnecting=!!options.reconnecting;
// #4416: start (or, on reconnect, keep) tracking whether the tab was hidden
// during this stream so the done-notification fires for a backgrounded tab.
// #4416: start (or, on reconnect for the SAME stream, keep) tracking whether
// the tab was hidden during this stream so the done-notification fires for a
// backgrounded tab. A reconnect with a different streamId re-seeds (the old
// entry belonged to a prior stream).
_bindStreamHiddenTracker();
if(!reconnecting||!(activeSid in _STREAM_WAS_HIDDEN)){
_STREAM_WAS_HIDDEN[activeSid]=(typeof document!=='undefined'&&!!document.hidden);
{
const _prev=_STREAM_WAS_HIDDEN[activeSid];
const _keep=reconnecting&&_prev&&_prev.streamId===streamId;
if(!_keep){
_STREAM_WAS_HIDDEN[activeSid]={streamId,wasHidden:(typeof document!=='undefined'&&!!document.hidden)};
}
}
if(!INFLIGHT[activeSid]) INFLIGHT[activeSid]={messages:[...S.messages],uploaded:[...uploaded],toolCalls:[]};
else {
@@ -1779,6 +1797,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
_clearOwnerInflightState();
_clearStreamHidden(activeSid, streamId); // #4416: terminal path, drop hidden tracker
_flushReasoningToAnchor();
_scheduleAnchorRegistryCleanup();
_clearApprovalForOwner();
@@ -3560,8 +3579,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// delivers late — after the user returns and document.hidden is false).
// If the user watched the whole stream, _wasEverHidden stays false and
// the notification is suppressed (matches Slack/Discord/Gmail/Claude).
const _wasEverHidden=!!_STREAM_WAS_HIDDEN[activeSid];
delete _STREAM_WAS_HIDDEN[activeSid];
const _hiddenEntry=_STREAM_WAS_HIDDEN[activeSid];
const _wasEverHidden=!!(_hiddenEntry&&_hiddenEntry.wasHidden);
_clearStreamHidden(activeSid, streamId);
sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished',{forceHidden:_wasEverHidden,sid:activeSid});
};
if(_shouldUseStreamFade()&&assistantBody){
@@ -3727,6 +3747,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// This is distinct from the SSE network 'error' event below.
try{if(source&&source.readyState!==2)source.close();}catch(_){ }
_clearOwnerInflightState();
_clearStreamHidden(activeSid, streamId); // #4416: terminal path, drop hidden tracker
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
let d={};
@@ -3899,6 +3920,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
try{if(source&&source.readyState!==2)source.close();}catch(_){ }
_clearOwnerInflightState();
_clearStreamHidden(activeSid, streamId); // #4416: terminal path, drop hidden tracker
_clearApprovalForOwner();
_clearClarifyForOwner('cancelled');
let _cancelData={};
+7 -2
View File
@@ -40,8 +40,13 @@ def test_completion_notification_fires_when_tab_was_hidden_during_stream():
# The per-stream hidden tracker exists and is wired at attach + done.
assert "_STREAM_WAS_HIDDEN" in MESSAGES_JS
assert "function _bindStreamHiddenTracker" in MESSAGES_JS
assert "const _wasEverHidden=!!_STREAM_WAS_HIDDEN[activeSid];" in MESSAGES_JS
assert "delete _STREAM_WAS_HIDDEN[activeSid];" in MESSAGES_JS
# Entries are stream-owned ({streamId, wasHidden}) so a stale entry from a
# non-`done` terminal path can't be mis-attributed to a later same-sid stream.
assert "const _wasEverHidden=!!(_hiddenEntry&&_hiddenEntry.wasHidden);" in MESSAGES_JS
assert "function _clearStreamHidden" in MESSAGES_JS
# Cleared on the non-done terminal paths too (belt-and-suspenders alongside
# the streamId-ownership guard).
assert MESSAGES_JS.count("_clearStreamHidden(activeSid, streamId)") >= 4
# sendBrowserNotification honors forceHidden but still respects the
# notifications-enabled setting (forceHidden is NOT the test-button force).
assert "const forceHidden=!!(options&&options.forceHidden);" in MESSAGES_JS