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));