import { useEffect, useRef, useCallback } from "react"; import { AppState } from "react-native"; import type { DaemonClient } from "@server/client/daemon-client"; import { getIsElectron, isWeb, isNative } from "@/constants/platform"; import { getDesktopSystemIdleTimeMs } from "@/desktop/electron/idle"; import { useStableEvent } from "@/hooks/use-stable-event"; const HEARTBEAT_INTERVAL_MS = 15_000; const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; const DESKTOP_IDLE_POLL_INTERVAL_MS = 5_000; interface ClientActivityOptions { client: DaemonClient; focusedAgentId: string | null; onAppResumed?: (awayMs: number) => void; } /** * Handles client activity reporting: * - Heartbeat sending every 15 seconds * - App visibility tracking * - Records lastActivityAt only on real user activity (not on heartbeat) */ export function useClientActivity({ client, focusedAgentId, onAppResumed, }: ClientActivityOptions): void { const lastActivityAtRef = useRef(new Date()); const appVisibleRef = useRef(AppState.currentState === "active"); const appVisibilityChangedAtRef = useRef(new Date()); const backgroundedAtMsRef = useRef( AppState.currentState === "active" ? null : Date.now(), ); const heartbeatIntervalRef = useRef | null>(null); const prevFocusedAgentIdRef = useRef(focusedAgentId); const lastImmediateHeartbeatAtRef = useRef(0); const deviceType = isWeb ? "web" : "mobile"; const recordUserActivity = useCallback(() => { lastActivityAtRef.current = new Date(); }, []); const sendHeartbeat = useStableEvent(() => { if (!client.isConnected) return; client.sendHeartbeat({ deviceType, focusedAgentId, lastActivityAt: lastActivityAtRef.current.toISOString(), appVisible: appVisibleRef.current, appVisibilityChangedAt: appVisibilityChangedAtRef.current.toISOString(), }); }); const setAppVisible = useCallback( (nextVisible: boolean) => { const previousVisible = appVisibleRef.current; if (previousVisible === nextVisible) { return; } appVisibleRef.current = nextVisible; appVisibilityChangedAtRef.current = new Date(); if (!nextVisible) { backgroundedAtMsRef.current = Date.now(); return; } const backgroundedAt = backgroundedAtMsRef.current; backgroundedAtMsRef.current = null; if (backgroundedAt !== null) { onAppResumed?.(Math.max(0, Date.now() - backgroundedAt)); } recordUserActivity(); }, [onAppResumed, recordUserActivity], ); const maybeSendImmediateHeartbeat = useCallback(() => { if (!client.isConnected) return; const now = Date.now(); if (now - lastImmediateHeartbeatAtRef.current < ACTIVITY_HEARTBEAT_THROTTLE_MS) { return; } lastImmediateHeartbeatAtRef.current = now; sendHeartbeat(); }, [client, sendHeartbeat]); // Track app visibility useEffect(() => { const subscription = AppState.addEventListener("change", (nextState) => { setAppVisible(nextState === "active"); // Send immediately on visibility changes so the server can adapt streaming behavior. sendHeartbeat(); }); return () => subscription.remove(); }, [sendHeartbeat, setAppVisible]); // Track user activity on web for accurate staleness. useEffect(() => { if (isNative) return; if (typeof document === "undefined") return; const handleUserActivity = () => { recordUserActivity(); maybeSendImmediateHeartbeat(); }; const handleVisibilityChange = () => { const visible = document.visibilityState === "visible"; setAppVisible(visible); if (visible) { maybeSendImmediateHeartbeat(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); window.addEventListener("focus", handleUserActivity); window.addEventListener("pointerdown", handleUserActivity, { passive: true }); window.addEventListener("keydown", handleUserActivity); window.addEventListener("wheel", handleUserActivity, { passive: true }); window.addEventListener("touchstart", handleUserActivity, { passive: true }); return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); window.removeEventListener("focus", handleUserActivity); window.removeEventListener("pointerdown", handleUserActivity); window.removeEventListener("keydown", handleUserActivity); window.removeEventListener("wheel", handleUserActivity); window.removeEventListener("touchstart", handleUserActivity); }; }, [maybeSendImmediateHeartbeat, recordUserActivity, setAppVisible]); // Track OS-wide activity in Electron so backgrounded desktop windows still report presence. useEffect(() => { if (!getIsElectron()) return; let disposed = false; const pollSystemIdleTime = async () => { const systemIdleMs = await getDesktopSystemIdleTimeMs(); if (disposed || systemIdleMs === null) return; const systemLastActivityAtMs = Date.now() - systemIdleMs; if (systemLastActivityAtMs > lastActivityAtRef.current.getTime()) { lastActivityAtRef.current = new Date(systemLastActivityAtMs); } }; const interval = setInterval(() => { void pollSystemIdleTime(); }, DESKTOP_IDLE_POLL_INTERVAL_MS); return () => { disposed = true; clearInterval(interval); }; }, [client]); // Send heartbeat on focused agent change useEffect(() => { if (prevFocusedAgentIdRef.current !== focusedAgentId) { prevFocusedAgentIdRef.current = focusedAgentId; recordUserActivity(); sendHeartbeat(); } }, [focusedAgentId, recordUserActivity, sendHeartbeat]); // Periodic heartbeat useEffect(() => { const startHeartbeat = () => { if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); } sendHeartbeat(); heartbeatIntervalRef.current = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS); }; const stopHeartbeat = () => { if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); heartbeatIntervalRef.current = null; } }; const unsubscribe = client.subscribeConnectionStatus((state) => { if (state.status === "connected") { startHeartbeat(); } else { stopHeartbeat(); } }); return () => { unsubscribe(); stopHeartbeat(); }; }, [client, sendHeartbeat]); }