diff --git a/static/messages.js b/static/messages.js index 8f57d13a..e060486c 100644 --- a/static/messages.js +++ b/static/messages.js @@ -592,6 +592,54 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // ── Shared SSE handler wiring (used for initial connection and reconnect) ── let _reconnectAttempted=false; let _terminalStateReached=false; + let _deferredStreamRecoveryBound=false; + + function _pageHiddenForStreamError(){ + return (typeof document!=='undefined'&&document.visibilityState==='hidden')|| + (typeof document!=='undefined'&&document.wasDiscarded===true); + } + + function _reattachOrRestoreAfterDeferredStreamError(){ + if(_terminalStateReached||_streamFinalized) return; + (async()=>{ + try{ + if(streamId){ + const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`); + if(st.active){ + setComposerStatus('Reconnected'); + _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{withCredentials:true})); + return; + } + } + }catch(_){ + if(_deferStreamErrorIfOffline()||_pageHiddenForStreamError()) return; + } + if(await _restoreSettledSession()) return; + if(_deferStreamErrorIfOffline()||_pageHiddenForStreamError()) return; + _handleStreamError(); + })(); + } + + function _deferStreamErrorIfPageHidden(){ + if(!_pageHiddenForStreamError()) return false; + setComposerStatus('Connection paused. Reconnecting when this tab returns…'); + if(S.session&&S.session.session_id===activeSid&&streamId) S.activeStreamId=streamId; + if(!_deferredStreamRecoveryBound){ + _deferredStreamRecoveryBound=true; + const resume=()=>{ + if(_pageHiddenForStreamError()) return; + window.removeEventListener('focus',resume); + window.removeEventListener('pageshow',resume); + document.removeEventListener('visibilitychange',resume); + _deferredStreamRecoveryBound=false; + _reattachOrRestoreAfterDeferredStreamError(); + }; + document.addEventListener('visibilitychange',resume); + window.addEventListener('focus',resume); + window.addEventListener('pageshow',resume); + } + return true; + } // Bug A fix (#631): track whether the stream has been finalized so any rAF // scheduled by a trailing 'token'/'reasoning' event that arrives in the same @@ -1633,6 +1681,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ source.addEventListener('error',async e=>{ source.close(); if(_deferStreamErrorIfOffline()) return; + if(_deferStreamErrorIfPageHidden()) return; if(_terminalStateReached || _streamFinalized){ _closeSource(); return; @@ -1654,12 +1703,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } if(await _restoreSettledSession()) return; if(_deferStreamErrorIfOffline()) return; + if(_deferStreamErrorIfPageHidden()) return; _handleStreamError(); },1500); return; } if(await _restoreSettledSession()) return; if(_deferStreamErrorIfOffline()) return; + if(_deferStreamErrorIfPageHidden()) return; _handleStreamError(); }); diff --git a/tests/test_offline_banner.py b/tests/test_offline_banner.py index 4942d8fe..45177975 100644 --- a/tests/test_offline_banner.py +++ b/tests/test_offline_banner.py @@ -70,3 +70,24 @@ def test_sse_network_error_defers_to_offline_banner_instead_of_inline_error(): assert "if(_deferStreamErrorIfOffline()) return;" in MESSAGES_JS error_handler = MESSAGES_JS.split("source.addEventListener('error',async e=>{", 1)[1].split("source.addEventListener('cancel'", 1)[0] assert error_handler.find("_deferStreamErrorIfOffline()") < error_handler.rfind("_handleStreamError()") + + +def test_sse_error_defers_while_page_hidden_until_tab_returns(): + assert "function _deferStreamErrorIfPageHidden()" in MESSAGES_JS + assert "document.visibilityState==='hidden'" in MESSAGES_JS + assert "document.wasDiscarded===true" in MESSAGES_JS + assert "Connection paused. Reconnecting when this tab returns…" in MESSAGES_JS + assert "document.addEventListener('visibilitychange',resume)" in MESSAGES_JS + assert "window.addEventListener('pageshow',resume)" in MESSAGES_JS + error_handler = MESSAGES_JS.split("source.addEventListener('error',async e=>{", 1)[1].split("source.addEventListener('cancel'", 1)[0] + assert "if(_deferStreamErrorIfPageHidden()) return;" in error_handler + assert error_handler.find("_deferStreamErrorIfPageHidden()") < error_handler.rfind("_handleStreamError()") + + +def test_deferred_hidden_stream_error_reattaches_or_restores_before_inline_error(): + recovery_block = MESSAGES_JS.split("function _reattachOrRestoreAfterDeferredStreamError(){", 1)[1].split("function _deferStreamErrorIfPageHidden()", 1)[0] + assert "api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`)" in recovery_block + assert "if(st.active)" in recovery_block + assert "_wireSSE(new EventSource" in recovery_block + assert "if(await _restoreSettledSession()) return;" in recovery_block + assert recovery_block.find("if(await _restoreSettledSession()) return;") < recovery_block.rfind("_handleStreamError()")