Merge pull request #2318 into stage-361

fix: defer mobile stream errors while tab is hidden (Michaelyklam, closes #2307)

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Hermes Agent
2026-05-15 19:17:06 +00:00
2 changed files with 72 additions and 0 deletions
+51
View File
@@ -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();
});
+21
View File
@@ -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()")