Goal
The live event stream (SSE) should stay connected — or transparently recover — through normal mobile usage (background/foreground, screen lock, Wi-Fi ↔ cellular handoff). Today the user sees the yellow Live connection lost, reconnecting… banner mid-session on mobile, even with the session still active.
Symptom
iOS, mid-session, the banner appears on its own (no user action). Reproduced this session — see attached.
⚠ Live connection lost, reconnecting… new messages may not appear immediately
Messages queued during the drop are delivered after reconnect (the QUEUED · N indicator works correctly), so no data loss observed — but the banner sits there and the user can't tell whether the session is still healthy.
Root cause (current implementation)
packages/web/src/features/sessions/use-session-stream.ts:
const es = new EventSource(api.sessionStreamUrl(sessionId))
es.onopen = () => setStatus('open')
es.onerror = () => setStatus('error') // ← once set, only the next onopen clears it
- Relies entirely on the browser's built-in
EventSource auto-retry.
- Never force-recreates the connection on visibility/online events.
- No timeout-based forced reconnect — if the EventSource sticks in
CONNECTING indefinitely (e.g. mobile suspended JS, proxy black-holed the socket), the banner is stuck and the only fix is reloading.
Server side already keepalives every 30s (packages/server/src/sse.ts — : keepalive\n\n), so the issue isn't idle-timeout from our end — it's almost certainly one of:
- iOS Safari backgrounding — when the page is hidden / app backgrounded, iOS suspends JS and frequently closes the EventSource socket. On foreground the browser may not retry promptly.
- Network handoff — Wi-Fi ↔ cellular drops the TCP connection without a clean signal; spec-defined 3s retry kicks in, but it may race with the network being mid-transition.
- Intermediary HTTP/2 or proxy timeouts — even with 30s server keepalive, some carrier proxies idle-close anyway.
Proposed fix
Make recovery explicit instead of leaving it to the browser:
- On
visibilitychange → visible and on online events, force-close the current EventSource and create a new one (don't trust auto-retry on mobile).
- After ≥ N seconds (e.g. 15s) in
error / connecting without a successful onopen, force close + recreate.
- Surface the
'connecting' vs 'error' distinction in the banner — "reconnecting…" should auto-dismiss within seconds; only a sustained drop (≥ ~10s) is a real warning.
- (Optional)
lastEventId already tracked by EventSource — confirm the server honours Last-Event-ID on reconnect to fill the gap; if it doesn't, the on-open history fetch in use-session-stream still backstops via mergeEvents, but it would mean a brief gap window.
Verification
Refs
- Stream hook:
packages/web/src/features/sessions/use-session-stream.ts
- Banner:
packages/web/src/features/sessions/session-detail/connection-banner.tsx
- Server keepalive:
packages/server/src/sse.ts (already 30 s : keepalive)
- Server route:
packages/server/src/routes/sessions.ts:316-332 (/sessions/:id/stream)
- Related (separate UX issue, possibly same family): the
QUEUED indicator already proves the message-queueing fallback is correct; this issue is purely about restoring the live tail.
Goal
The live event stream (SSE) should stay connected — or transparently recover — through normal mobile usage (background/foreground, screen lock, Wi-Fi ↔ cellular handoff). Today the user sees the yellow
Live connection lost, reconnecting…banner mid-session on mobile, even with the session still active.Symptom
iOS, mid-session, the banner appears on its own (no user action). Reproduced this session — see attached.
Messages queued during the drop are delivered after reconnect (the
QUEUED · Nindicator works correctly), so no data loss observed — but the banner sits there and the user can't tell whether the session is still healthy.Root cause (current implementation)
packages/web/src/features/sessions/use-session-stream.ts:EventSourceauto-retry.CONNECTINGindefinitely (e.g. mobile suspended JS, proxy black-holed the socket), the banner is stuck and the only fix is reloading.Server side already keepalives every 30s (
packages/server/src/sse.ts—: keepalive\n\n), so the issue isn't idle-timeout from our end — it's almost certainly one of:Proposed fix
Make recovery explicit instead of leaving it to the browser:
visibilitychange→visibleand ononlineevents, force-close the current EventSource and create a new one (don't trust auto-retry on mobile).error/connectingwithout a successfulonopen, force close + recreate.'connecting'vs'error'distinction in the banner — "reconnecting…" should auto-dismiss within seconds; only a sustained drop (≥ ~10s) is a real warning.lastEventIdalready tracked by EventSource — confirm the server honoursLast-Event-IDon reconnect to fill the gap; if it doesn't, the on-open history fetch inuse-session-streamstill backstops viamergeEvents, but it would mean a brief gap window.Verification
openand queued messages flow.useSessionStreamcovers the visibility-based reconnect (mockdocument.visibilityStatetoggling, assert a new EventSource is constructed).Refs
packages/web/src/features/sessions/use-session-stream.tspackages/web/src/features/sessions/session-detail/connection-banner.tsxpackages/server/src/sse.ts(already 30 s: keepalive)packages/server/src/routes/sessions.ts:316-332(/sessions/:id/stream)QUEUEDindicator already proves the message-queueing fallback is correct; this issue is purely about restoring the live tail.