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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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