Files
paseo/packages/app/src/hooks/use-client-activity.ts
T
Mohamed Boudra 0f7fa55a0f Centralize platform gating and fix iPad/tablet support
Add `packages/app/src/constants/platform.ts` with canonical gates
(isWeb, isNative, getIsElectron) and refactor all ~100 ad-hoc
Platform.OS checks across 69 files to use shared imports.

Fix sidebar hover crash on native (onPointerEnter → onHoverIn),
fix tooltip to use breakpoint instead of platform detection, make
sidebar action buttons always visible on native where hover doesn't
work, and document the platform gating decision matrix in CLAUDE.md.
2026-04-13 13:24:00 +07:00

177 lines
5.6 KiB
TypeScript

import { useEffect, useRef, useCallback } from "react";
import { AppState } from "react-native";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
const HEARTBEAT_INTERVAL_MS = 15_000;
const ACTIVITY_HEARTBEAT_THROTTLE_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 = useCallback(() => {
if (!client.isConnected) return;
client.sendHeartbeat({
deviceType,
focusedAgentId,
lastActivityAt: lastActivityAtRef.current.toISOString(),
appVisible: appVisibleRef.current,
appVisibilityChangedAt: appVisibilityChangedAtRef.current.toISOString(),
});
}, [client, deviceType, focusedAgentId]);
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]);
// 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();
}
});
if (client.isConnected) {
startHeartbeat();
}
return () => {
unsubscribe();
stopHeartbeat();
};
}, [client, sendHeartbeat]);
}