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:
Mohamed Boudra
2026-04-19 21:48:39 +07:00
parent 128d6668ae
commit 06b3fc2ec6
10 changed files with 759 additions and 362 deletions
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const { invokeDesktopCommandMock } = vi.hoisted(() => ({
invokeDesktopCommandMock: vi.fn<() => Promise<unknown>>(async () => 12_000),
}));
vi.mock("@/desktop/electron/invoke", () => ({
invokeDesktopCommand: invokeDesktopCommandMock,
}));
import { getDesktopSystemIdleTimeMs } from "./idle";
describe("getDesktopSystemIdleTimeMs", () => {
afterEach(() => {
invokeDesktopCommandMock.mockReset();
vi.restoreAllMocks();
});
it("invokes the desktop idle command and returns the millisecond value", async () => {
invokeDesktopCommandMock.mockResolvedValueOnce(4_200);
const idleTimeMs = await getDesktopSystemIdleTimeMs();
expect(invokeDesktopCommandMock).toHaveBeenCalledWith("desktop_get_system_idle_time");
expect(idleTimeMs).toBe(4_200);
});
it("returns null and logs once when IPC rejects", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const error = new Error("ipc failed");
invokeDesktopCommandMock.mockRejectedValueOnce(error);
const idleTimeMs = await getDesktopSystemIdleTimeMs();
expect(idleTimeMs).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Failed to read system idle time", error);
});
it("returns null and logs once when IPC returns null", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
invokeDesktopCommandMock.mockResolvedValueOnce(null);
const idleTimeMs = await getDesktopSystemIdleTimeMs();
expect(idleTimeMs).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", null);
});
it("returns null and logs once when IPC returns NaN", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
invokeDesktopCommandMock.mockResolvedValueOnce(Number.NaN);
const idleTimeMs = await getDesktopSystemIdleTimeMs();
expect(idleTimeMs).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", Number.NaN);
});
it("returns null and logs once when IPC returns a negative value", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
invokeDesktopCommandMock.mockResolvedValueOnce(-1);
const idleTimeMs = await getDesktopSystemIdleTimeMs();
expect(idleTimeMs).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", -1);
});
it("returns 0 when IPC returns zero", async () => {
invokeDesktopCommandMock.mockResolvedValueOnce(0);
const idleTimeMs = await getDesktopSystemIdleTimeMs();
expect(idleTimeMs).toBe(0);
});
});
+21
View File
@@ -0,0 +1,21 @@
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
const DESKTOP_SYSTEM_IDLE_COMMAND = "desktop_get_system_idle_time";
function isValidIdleTimeMs(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
export async function getDesktopSystemIdleTimeMs(): Promise<number | null> {
try {
const idleTimeMs = await invokeDesktopCommand<unknown>(DESKTOP_SYSTEM_IDLE_COMMAND);
if (!isValidIdleTimeMs(idleTimeMs)) {
console.warn("[DesktopIdle] Invalid system idle time", idleTimeMs);
return null;
}
return idleTimeMs;
} catch (error) {
console.warn("[DesktopIdle] Failed to read system idle time", error);
return null;
}
}
@@ -0,0 +1,215 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClientActivity } from "./use-client-activity";
type HeartbeatPayload = {
deviceType: "web" | "mobile";
focusedAgentId: string | null;
lastActivityAt: string;
appVisible: boolean;
appVisibilityChangedAt?: string;
};
const { platformState, getDesktopSystemIdleTimeMs } = vi.hoisted(() => ({
platformState: {
isWeb: true,
isNative: false,
isElectron: false,
},
getDesktopSystemIdleTimeMs: vi.fn<() => Promise<number | null>>(),
}));
vi.mock("@/constants/platform", () => ({
get isWeb() {
return platformState.isWeb;
},
get isNative() {
return platformState.isNative;
},
getIsElectron: () => platformState.isElectron,
}));
vi.mock("@/desktop/electron/idle", () => ({
getDesktopSystemIdleTimeMs,
}));
vi.mock("react-native", () => ({
AppState: {
currentState: "active",
addEventListener: vi.fn(() => ({
remove: vi.fn(),
})),
},
}));
vi.mock("@server/client/daemon-client", () => ({}));
function createTestClient() {
return {
isConnected: true,
subscribeConnectionStatus: vi.fn(() => vi.fn()),
sendHeartbeat: vi.fn<(payload: HeartbeatPayload) => void>(),
};
}
function latestHeartbeat(client: ReturnType<typeof createTestClient>): HeartbeatPayload {
const call = client.sendHeartbeat.mock.calls.at(-1);
if (!call) {
throw new Error("Expected a heartbeat");
}
return call[0];
}
function heartbeatTimeMs(client: ReturnType<typeof createTestClient>): number {
return new Date(latestHeartbeat(client).lastActivityAt).getTime();
}
async function renderActivityHook({
client = createTestClient(),
}: {
client?: ReturnType<typeof createTestClient>;
} = {}) {
function Probe() {
useClientActivity({
client: client as unknown as Parameters<typeof useClientActivity>[0]["client"],
focusedAgentId: "agent-1",
});
return null;
}
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(<Probe />);
});
return { client, root };
}
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
describe("useClientActivity", () => {
let root: Root | null = null;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-19T10:00:00.000Z"));
platformState.isWeb = true;
platformState.isNative = false;
platformState.isElectron = false;
getDesktopSystemIdleTimeMs.mockReset();
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
});
root = null;
}
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("updates lastActivityAt from web pointer activity", async () => {
const rendered = await renderActivityHook();
root = rendered.root;
vi.setSystemTime(new Date("2026-04-19T10:00:05.250Z"));
window.dispatchEvent(new window.Event("pointerdown"));
expect(heartbeatTimeMs(rendered.client)).toBe(Date.now());
expect(getDesktopSystemIdleTimeMs).not.toHaveBeenCalled();
});
it("drives lastActivityAt forward from Electron idle polling", async () => {
platformState.isElectron = true;
getDesktopSystemIdleTimeMs.mockResolvedValue(0);
const rendered = await renderActivityHook();
root = rendered.root;
await advance(5_000);
expect(getDesktopSystemIdleTimeMs).toHaveBeenCalledTimes(1);
await advance(10_000);
expect(heartbeatTimeMs(rendered.client)).toBe(Date.now());
});
it("sets lastActivityAt to Date.now() minus the Electron idle time", async () => {
platformState.isElectron = true;
getDesktopSystemIdleTimeMs.mockResolvedValue(2_000);
const rendered = await renderActivityHook();
root = rendered.root;
await advance(5_000);
await advance(10_000);
expect(heartbeatTimeMs(rendered.client)).toBe(Date.now() - 2_000);
});
it("skips failed Electron idle polls", async () => {
platformState.isElectron = true;
getDesktopSystemIdleTimeMs.mockResolvedValue(null);
const rendered = await renderActivityHook();
root = rendered.root;
const previousLastActivityAt = heartbeatTimeMs(rendered.client);
await advance(5_000);
await advance(10_000);
expect(heartbeatTimeMs(rendered.client)).toBe(previousLastActivityAt);
});
it("never moves lastActivityAt backward from an idle poll", async () => {
platformState.isElectron = true;
getDesktopSystemIdleTimeMs.mockResolvedValue(20_000);
const rendered = await renderActivityHook();
root = rendered.root;
vi.setSystemTime(new Date("2026-04-19T10:00:05.000Z"));
window.dispatchEvent(new window.Event("pointerdown"));
const pointerActivityAt = latestHeartbeat(rendered.client).lastActivityAt;
await advance(5_000);
expect(getDesktopSystemIdleTimeMs).toHaveBeenCalled();
await advance(10_000);
expect(latestHeartbeat(rendered.client).lastActivityAt).toBe(pointerActivityAt);
});
it("keeps an Electron pointerdown newer than a stale idle poll", async () => {
platformState.isElectron = true;
getDesktopSystemIdleTimeMs.mockResolvedValue(20_000);
const rendered = await renderActivityHook();
root = rendered.root;
await advance(4_500);
window.dispatchEvent(new window.Event("pointerdown"));
const pointerActivityAt = latestHeartbeat(rendered.client).lastActivityAt;
await advance(500);
expect(getDesktopSystemIdleTimeMs).toHaveBeenCalled();
await advance(10_000);
expect(latestHeartbeat(rendered.client).lastActivityAt).toBe(pointerActivityAt);
});
});
+28 -1
View File
@@ -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) {