diff --git a/CHANGELOG.md b/CHANGELOG.md
index dac725c..27e33cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Hermes Web UI -- Changelog
+## [v0.50.59] — 2026-04-16
+
+### Fixed
+- **False "Connection lost" message after settled stream** — the UI no longer injects a fake `**Error:** Connection lost` assistant message when an SSE connection drops after the stream already completed normally. The fix tracks terminal stream states (`done`, `stream_end`, `cancel`, `apperror`) and, on a disconnect, fetches `/api/session` to confirm the session is settled before silently restoring it instead of calling the error path. Real failures still go through the error path as before. (Fixes #561, PR #562 by @halmisen)
+
## [v0.50.58] — 2026-04-16
### Fixed
diff --git a/static/index.html b/static/index.html
index 4236e08..9a1f7aa 100644
--- a/static/index.html
+++ b/static/index.html
@@ -553,7 +553,7 @@
System
Instance version and access controls.
- v0.50.58
+ v0.50.59
diff --git a/static/messages.js b/static/messages.js
index 4696119..fcdf71f 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -213,6 +213,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// ── Shared SSE handler wiring (used for initial connection and reconnect) ──
let _reconnectAttempted=false;
+ let _terminalStateReached=false;
// rAF-throttled rendering: buffer tokens, render at most once per frame
let _renderPending=false;
@@ -412,6 +413,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
});
source.addEventListener('done',e=>{
+ _terminalStateReached=true;
const d=JSON.parse(e.data);
delete INFLIGHT[activeSid];
clearInflight();clearInflightState(activeSid);
@@ -454,6 +456,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
});
source.addEventListener('stream_end',e=>{
+ _terminalStateReached=true;
try{
const d=JSON.parse(e.data||'{}');
if((d.session_id||activeSid)!==activeSid) return;
@@ -473,6 +476,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
});
source.addEventListener('apperror',e=>{
+ _terminalStateReached=true;
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
// This is distinct from the SSE network 'error' event below.
source.close();
@@ -514,8 +518,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}catch(_){}
});
- source.addEventListener('error',e=>{
+ source.addEventListener('error',async e=>{
source.close();
+ if(_terminalStateReached){
+ _closeSource();
+ return;
+ }
// Attempt one reconnect if the stream is still active server-side
if(!_reconnectAttempted && streamId){
_reconnectAttempted=true;
@@ -529,14 +537,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
return;
}
}catch(_){}
+ if(await _restoreSettledSession()) return;
_handleStreamError();
},1500);
return;
}
+ if(await _restoreSettledSession()) return;
_handleStreamError();
});
source.addEventListener('cancel',e=>{
+ _terminalStateReached=true;
source.close();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
@@ -553,6 +564,29 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
});
}
+ async function _restoreSettledSession(){
+ try{
+ const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
+ const session=data&&data.session;
+ if(!session) return false;
+ if(session.active_stream_id||session.pending_user_message) return false;
+ delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
+ _closeSource();
+ if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
+ if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true);
+ if(S.session&&S.session.session_id===activeSid){
+ S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
+ clearLiveToolCards();if(!assistantText)removeThinking();
+ S.session=session;S.messages=session.messages||[];
+ syncTopbar();renderMessages();
+ }
+ renderSessionList();setBusy(false);setComposerStatus('');
+ return true;
+ }catch(_){
+ return false;
+ }
+ }
+
function _handleStreamError(){
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
_closeSource();