fix(server,app,desktop): route in-app notifications by presence cascade
Replaces the per-client stale/visibility policy with one shared
presence cascade so a backgrounded Firefox tab can no longer steal
in-app notifications from an active Electron desktop window.
Server (`agent-attention-policy.ts`): exports `PRESENCE_THRESHOLD_MS`
(3 min) and `computeNotificationPlan({ allStates, agentId, reason,
nowMs })` returning `{ inAppRecipientIndex, shouldPush }`. Cascade:
focus suppression requires presence; otherwise the most-recent
present client wins (lower-index tiebreak); push fires only when no
client is present, and never on `error`. `broadcastAgentAttention`
calls the policy once per event.
Electron (`daemon-manager.ts`, `idle.ts`): registers
`desktop_get_system_idle_time` returning `powerMonitor
.getSystemIdleTime() * 1000`. Renderer wrapper
`getDesktopSystemIdleTimeMs()` is best-effort: rejected IPC, null,
NaN, Infinity, or negative values return null without throwing.
Hook (`use-client-activity.ts`): Electron-only effect polls every 5s
and updates `lastActivityAtRef` only forward, so OS-wide activity
keeps a backgrounded Electron window present while in-window events
still update immediately.
`ClientHeartbeatMessageSchema` is unchanged; old/new clients and
daemons remain wire-compatible.
Tests: 52 focused tests across policy unit, dispatch, e2e, hook,
desktop wrapper, and permissions. Phase-3 dispatch test +
Phase-5 stale-poll test verified by mutation (breaking the focus
check / the `>` guard makes the exact regression fail).
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
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";
|
||||
import { getIsElectron, isWeb, isNative } from "@/constants/platform";
|
||||
import { getDesktopSystemIdleTimeMs } from "@/desktop/electron/idle";
|
||||
|
||||
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;
|
||||
@@ -130,6 +132,31 @@ export function useClientActivity({
|
||||
};
|
||||
}, [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) {
|
||||
|
||||
Reference in New Issue
Block a user