From b9a9d44da651d5fe1f836b85a105d06d50821617 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Sun, 22 Mar 2026 21:50:40 -0400 Subject: [PATCH] feat: add AppState-aware WebSocket reconnect on mobile foreground When the app returns from background, check if the WebSocket is still open. If not, close the stale socket and reconnect with reset backoff. Proactively close the WS when backgrounding to save battery and avoid OS killing it mid-frame. Add "reconnecting" connection status with amber pulsing badge so users see the app is re-establishing connection. Fixes #33 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mobile/components/ConnectionBadge.tsx | 3 +- artifacts/mobile/context/TimmyContext.tsx | 51 ++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/artifacts/mobile/components/ConnectionBadge.tsx b/artifacts/mobile/components/ConnectionBadge.tsx index 7cec708..e13bee3 100644 --- a/artifacts/mobile/components/ConnectionBadge.tsx +++ b/artifacts/mobile/components/ConnectionBadge.tsx @@ -11,6 +11,7 @@ const STATUS_CONFIG: Record connecting: { color: "#F59E0B", label: "Connecting" }, connected: { color: "#10B981", label: "Live" }, disconnected: { color: "#6B7280", label: "Offline" }, + reconnecting: { color: "#F59E0B", label: "Reconnecting" }, error: { color: "#EF4444", label: "Error" }, }; @@ -18,7 +19,7 @@ export function ConnectionBadge({ status }: { status: ConnectionStatus }) { const pulseAnim = useRef(new Animated.Value(1)).current; useEffect(() => { - if (status === "connecting") { + if (status === "connecting" || status === "reconnecting") { const pulse = Animated.loop( Animated.sequence([ Animated.timing(pulseAnim, { toValue: 0.3, duration: 600, useNativeDriver: true }), diff --git a/artifacts/mobile/context/TimmyContext.tsx b/artifacts/mobile/context/TimmyContext.tsx index 557b81a..18a5a20 100644 --- a/artifacts/mobile/context/TimmyContext.tsx +++ b/artifacts/mobile/context/TimmyContext.tsx @@ -7,6 +7,7 @@ import React, { useRef, useState, } from "react"; +import { AppState, Platform } from "react-native"; export type TimmyMood = "idle" | "thinking" | "working" | "speaking"; @@ -21,7 +22,7 @@ export type WsEvent = { count?: number; }; -export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "error"; +export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "reconnecting" | "error"; type TimmyContextValue = { timmyMood: TimmyMood; @@ -215,6 +216,54 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) { }; }, [connectWs]); + // AppState-aware WebSocket reconnect on foreground + useEffect(() => { + if (Platform.OS === "web") return; + + const appStateRef = { current: AppState.currentState }; + + const subscription = AppState.addEventListener("change", (nextAppState) => { + const wasBackground = + appStateRef.current === "background" || + appStateRef.current === "inactive"; + const isNowActive = nextAppState === "active"; + + if (wasBackground && isNowActive) { + // App returned to foreground — check if WS is still alive + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + // Cancel any pending retry so we don't create duplicates + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + retryCountRef.current = 0; + setConnectionStatus("reconnecting"); + connectWs(); + } + } else if (nextAppState === "background") { + // Proactively close the WS to avoid OS killing it mid-frame + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + if (wsRef.current) { + wsRef.current.onclose = null; + wsRef.current.onerror = null; + wsRef.current.close(); + wsRef.current = null; + } + setConnectionStatus("disconnected"); + } + + appStateRef.current = nextAppState; + }); + + return () => { + subscription.remove(); + }; + }, [connectWs]); + const send = useCallback((msg: object) => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify(msg)); -- 2.43.0