diff --git a/web/src/App.tsx b/web/src/App.tsx index d880949..8f732e2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -113,15 +113,23 @@ function Shell() { // a transient lost connection has already recovered. useEffect(() => { const recover = () => { - if (document.visibilityState !== "visible") return; void linkStore.getState().probe(); catalogStore.getState().reconnect(); }; - document.addEventListener("visibilitychange", recover); + // visibilitychange fires reliably on tab/app switch; guard it so we don't + // probe while the page is being hidden. + const onVisibility = () => { + if (document.visibilityState !== "visible") return; + recover(); + }; + // pageshow (bfcache restore) and online (network back) indicate the page + // is active — fire unconditionally since visibilityState may lag behind on + // some mobile browsers. + document.addEventListener("visibilitychange", onVisibility); window.addEventListener("pageshow", recover); window.addEventListener("online", recover); return () => { - document.removeEventListener("visibilitychange", recover); + document.removeEventListener("visibilitychange", onVisibility); window.removeEventListener("pageshow", recover); window.removeEventListener("online", recover); }; diff --git a/web/src/components/structured/useStructuredSession.ts b/web/src/components/structured/useStructuredSession.ts index bba2ced..91bbfc2 100644 --- a/web/src/components/structured/useStructuredSession.ts +++ b/web/src/components/structured/useStructuredSession.ts @@ -197,11 +197,12 @@ export function useStructuredSession( }; // Mobile browsers suspend timers and silently kill WebSockets in the - // background. Kick a fresh connect when the page becomes visible again. + // background. The browser often still reports readyState === OPEN because + // TCP keepalive hasn't noticed the dead connection yet. Force-replace + // unconditionally — connect() closes the old socket first, and the server + // replays the full transcript on every reconnect so nothing is lost. const onVisibility = () => { if (document.visibilityState !== "visible" || disposed) return; - if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return; - cancelTimer(); setStatus("reconnecting"); connect(); }; diff --git a/web/src/components/terminal/TerminalPane.tsx b/web/src/components/terminal/TerminalPane.tsx index da03014..636ea5c 100644 --- a/web/src/components/terminal/TerminalPane.tsx +++ b/web/src/components/terminal/TerminalPane.tsx @@ -303,17 +303,22 @@ export const TerminalPane = forwardRef( ta?.addEventListener("blur", onTaBlur); // Mobile browsers (Android especially) suspend timers and silently kill - // WebSockets when the app goes to background. The backoff timer set by - // onclose is frozen too, so recovery stalls until visibility returns. Kick - // a fresh connect immediately when the page becomes visible again. + // WebSockets when the app goes to background. The browser often still + // reports readyState === OPEN because TCP keepalive hasn't noticed the + // dead connection yet. Force-replace the socket unconditionally — + // a genuinely alive server socket gets a clean close, and the reconnect + // replays the scrollback so nothing is lost. const onVisibility = () => { if (document.visibilityState !== "visible" || disposed) return; - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) return; - // Cancel any pending backoff — we're reconnecting right now. if (reconnectTimer !== null) { window.clearTimeout(reconnectTimer); reconnectTimer = null; } + // Detach the old socket's handlers so its close event doesn't race + // with the fresh connection (same pattern as catalogStore.reconnect). + ws.onclose = null; + ws.onerror = null; + ws.close(); reconnectMs = INITIAL_RECONNECT_MS; onStatusChange?.("reconnecting"); connect();