feat: add AppState-aware WebSocket reconnect on mobile foreground
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s

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) <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-22 21:50:40 -04:00
parent 4c747aa331
commit b9a9d44da6
2 changed files with 52 additions and 2 deletions

View File

@@ -11,6 +11,7 @@ const STATUS_CONFIG: Record<ConnectionStatus, { color: string; label: string }>
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 }),

View File

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