mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
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:
+33
-11
@@ -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={};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user