diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c36a39..4335494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Hermes Web UI -- Changelog +## [v0.50.93] — 2026-04-19 + +### Fixed +- **Gateway message sync no longer corrupts the active session on slow networks** — the `sessions_changed` SSE handler now captures the active session ID before the async `import_cli` fetch and validates it in `.then()`, preventing session-switch races from overwriting the wrong conversation. Added `is_cli_session` guard so the handler only fires for CLI-originated sessions. The backend import path now also verifies that existing messages are a strict prefix of the fresh CLI messages before overwriting, preventing silent data loss on hybrid WebUI+CLI sessions. (PR #676 by @yunyunyunyun-yun) + ## [v0.50.91] — 2026-04-19 ### Added diff --git a/api/routes.py b/api/routes.py index f6cb397..f46b314 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2869,9 +2869,17 @@ def _handle_session_import_cli(handler, body): sid = str(body["session_id"]) - # Check if already imported — idempotent + # Check if already imported — refresh messages from CLI store if new ones arrived existing = Session.load(sid) if existing: + fresh_msgs = get_cli_session_messages(sid) + if fresh_msgs and len(fresh_msgs) > len(existing.messages): + # Prefix-equality guard: only extend if existing messages are a prefix of + # the fresh CLI messages. Prevents silently dropping WebUI-added messages + # on hybrid sessions (user sent messages via WebUI while CLI continued). + if existing.messages == fresh_msgs[:len(existing.messages)]: + existing.messages = fresh_msgs + existing.save(touch_updated_at=False) return j( handler, { diff --git a/static/sessions.js b/static/sessions.js index 9d7c86d..1881a99 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -331,6 +331,30 @@ function startGatewaySSE(){ const data = JSON.parse(ev.data); if(data.sessions){ renderSessionList(); // re-fetch and re-render + // If the active session received new gateway messages, refresh the conversation view. + // S.busy check prevents stomping on an in-progress WebUI response. + // is_cli_session check ensures we only poll import_cli for CLI-originated sessions. + if(S.session && !S.busy && S.session.is_cli_session){ + const changedIds = new Set((data.sessions||[]).map(s=>s.session_id)); + if(changedIds.has(S.session.session_id)){ + // Capture active session ID before async fetch — race guard. + // If the user switches sessions while the fetch is in-flight, discard the result. + const activeSid = S.session.session_id; + api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:activeSid})}) + .then(res=>{ + if(!S.session || S.session.session_id !== activeSid) return; + if(res && res.session && Array.isArray(res.session.messages)){ + const prev = S.messages.length; + S.messages = res.session.messages.filter(m=>m&&m.role); + if(S.messages.length !== prev){ + renderMessages(); + if(typeof highlightCode==='function') highlightCode(); + } + } + }) + .catch(()=>{ /* ignore — next poll will retry */ }); + } + } } }catch(e){ /* ignore parse errors */ } });