Files
paseo/packages/app/src/hooks/use-client-activity.ts
T

201 lines
6.5 KiB
TypeScript

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<Date>(new Date());
const appVisibleRef = useRef(AppState.currentState === "active");
const appVisibilityChangedAtRef = useRef<Date>(new Date());
const backgroundedAtMsRef = useRef<number | null>(
AppState.currentState === "active" ? null : Date.now(),
);
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
const lastImmediateHeartbeatAtRef = useRef<number>(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]);
}