diff --git a/packages/app/src/hooks/use-client-activity.test.tsx b/packages/app/src/hooks/use-client-activity.test.tsx index 61f86d188..b89eab1fc 100644 --- a/packages/app/src/hooks/use-client-activity.test.tsx +++ b/packages/app/src/hooks/use-client-activity.test.tsx @@ -49,7 +49,10 @@ vi.mock("@server/client/daemon-client", () => ({})); function createTestClient() { return { isConnected: true, - subscribeConnectionStatus: vi.fn(() => vi.fn()), + subscribeConnectionStatus: vi.fn((listener: (state: { status: "connected" }) => void) => { + listener({ status: "connected" }); + return vi.fn(); + }), sendHeartbeat: vi.fn<(payload: HeartbeatPayload) => void>(), }; } @@ -68,13 +71,15 @@ function heartbeatTimeMs(client: ReturnType): number { async function renderActivityHook({ client = createTestClient(), + focusedAgentId = "agent-1", }: { client?: ReturnType; + focusedAgentId?: string | null; } = {}) { - function Probe() { + function Probe({ focusedAgentId }: { focusedAgentId: string | null }) { useClientActivity({ client: client as unknown as Parameters[0]["client"], - focusedAgentId: "agent-1", + focusedAgentId, }); return null; } @@ -84,10 +89,16 @@ async function renderActivityHook({ const root = createRoot(container); await act(async () => { - root.render(); + root.render(); }); - return { client, root }; + const rerender = async (nextFocusedAgentId: string | null) => { + await act(async () => { + root.render(); + }); + }; + + return { client, rerender, root }; } async function advance(ms: number) { @@ -138,6 +149,24 @@ describe("useClientActivity", () => { expect(getDesktopSystemIdleTimeMs).not.toHaveBeenCalled(); }); + it("sends one immediate heartbeat when the focused agent changes", async () => { + const rendered = await renderActivityHook(); + root = rendered.root; + + expect(rendered.client.sendHeartbeat).toHaveBeenCalledTimes(1); + rendered.client.sendHeartbeat.mockClear(); + + vi.setSystemTime(new Date("2026-04-19T10:00:05.000Z")); + await rendered.rerender("agent-2"); + + expect(rendered.client.subscribeConnectionStatus).toHaveBeenCalledTimes(1); + expect(rendered.client.sendHeartbeat).toHaveBeenCalledTimes(1); + expect(latestHeartbeat(rendered.client)).toMatchObject({ + focusedAgentId: "agent-2", + lastActivityAt: "2026-04-19T10:00:05.000Z", + }); + }); + it("drives lastActivityAt forward from Electron idle polling", async () => { platformState.isElectron = true; getDesktopSystemIdleTimeMs.mockResolvedValue(0); diff --git a/packages/app/src/hooks/use-client-activity.ts b/packages/app/src/hooks/use-client-activity.ts index a244df33b..b659a6164 100644 --- a/packages/app/src/hooks/use-client-activity.ts +++ b/packages/app/src/hooks/use-client-activity.ts @@ -3,6 +3,7 @@ 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; @@ -41,7 +42,7 @@ export function useClientActivity({ lastActivityAtRef.current = new Date(); }, []); - const sendHeartbeat = useCallback(() => { + const sendHeartbeat = useStableEvent(() => { if (!client.isConnected) return; client.sendHeartbeat({ deviceType, @@ -50,7 +51,7 @@ export function useClientActivity({ appVisible: appVisibleRef.current, appVisibilityChangedAt: appVisibilityChangedAtRef.current.toISOString(), }); - }, [client, deviceType, focusedAgentId]); + }); const setAppVisible = useCallback( (nextVisible: boolean) => { @@ -191,10 +192,6 @@ export function useClientActivity({ } }); - if (client.isConnected) { - startHeartbeat(); - } - return () => { unsubscribe(); stopHeartbeat(); diff --git a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts index ec42106c4..718483cd4 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts @@ -32,7 +32,6 @@ const ELIGIBLE_WORKTREE_SNAPSHOT: WorkspaceGitRuntimeSnapshot = { featuresEnabled: false, pullRequest: null, error: null, - refreshedAt: null, }, }; diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index d4ede9deb..6e18aa09b 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -296,7 +296,6 @@ function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { featuresEnabled: false, pullRequest: null, error: null, - refreshedAt: null, }, }; } diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 5f11238f8..71c1d8d32 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -237,7 +237,6 @@ function createWorkspaceGitSnapshot( featuresEnabled: false, pullRequest: null, error: null, - refreshedAt: null, ...overrides?.github, }, }; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b84ed7f2e..8b1751dd3 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5837,7 +5837,6 @@ export class Session { featuresEnabled: snapshot.github.featuresEnabled, pullRequest: snapshot.github.pullRequest, error: snapshot.github.error, - refreshedAt: snapshot.github.refreshedAt, }; } diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 4aaac5dd5..28362bebf 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -38,7 +38,6 @@ function createWorkspaceRuntimeSnapshot( featuresEnabled: true, pullRequest: null, error: null, - refreshedAt: "2026-04-12T00:00:00.000Z", }, }; @@ -477,7 +476,6 @@ describe("workspace git watch targets", () => { headRefName: "workspace-git-service", isMerged: true, }, - refreshedAt: "2026-04-12T00:05:00.000Z", }, }), ); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 51aca6a88..3a74e2d3e 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -134,7 +134,6 @@ function createNoopWorkspaceGitService() { featuresEnabled: false, pullRequest: null, error: null, - refreshedAt: null, }, }, unsubscribe: () => {}, @@ -159,7 +158,6 @@ function createNoopWorkspaceGitService() { featuresEnabled: false, pullRequest: null, error: null, - refreshedAt: null, }, }), resolveRepoRemoteUrl: async () => null, @@ -210,7 +208,6 @@ function createWorkspaceRuntimeSnapshot( isMerged: false, }, error: null, - refreshedAt: "2026-04-12T00:00:00.000Z", }, }; @@ -2592,7 +2589,6 @@ describe("workspace aggregation", () => { headRefName: "runtime-branch", isMerged: false, }, - refreshedAt: "2026-04-12T00:05:00.000Z", }, }); const workspaceGitService = createNoopWorkspaceGitService(); @@ -2674,7 +2670,6 @@ describe("workspace aggregation", () => { isMerged: false, }, error: null, - refreshedAt: "2026-04-12T00:05:00.000Z", }, }), ]); @@ -2696,7 +2691,6 @@ describe("workspace aggregation", () => { headRefName: "feature/runtime-payloads", isMerged: true, }, - refreshedAt: "2026-04-12T00:10:00.000Z", }, }); const workspaceGitService = createNoopWorkspaceGitService(); @@ -2770,7 +2764,6 @@ describe("workspace aggregation", () => { title: "Updated runtime payloads", isMerged: true, }), - refreshedAt: "2026-04-12T00:10:00.000Z", }), }), }, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 0d5b3c2fc..f2efd12fd 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -89,7 +89,6 @@ function createFallbackWorkspaceGitService(): WorkspaceGitServiceImpl { featuresEnabled: false, pullRequest: null, error: null, - refreshedAt: null, }, }, unsubscribe: () => {}, @@ -114,7 +113,6 @@ function createFallbackWorkspaceGitService(): WorkspaceGitServiceImpl { featuresEnabled: false, pullRequest: null, error: null, - refreshedAt: null, }, }), refresh: async () => {}, diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 6060e74a9..85cd27f46 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -137,7 +137,6 @@ function createSnapshot( isMerged: false, }, error: null, - refreshedAt: "2026-04-12T00:00:00.000Z", }, }; @@ -412,9 +411,6 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { currentBranch: "feature", diffStat: { additions: 4, deletions: 2 }, }, - github: { - refreshedAt: "2026-04-12T00:00:16.000Z", - }, }); await expect(Promise.all([first, second])).resolves.toEqual([freshSnapshot, freshSnapshot]); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 4ed4d0c9e..c6c46a2c7 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -51,7 +51,6 @@ function createSnapshot( isMerged: false, }, error: null, - refreshedAt: "2026-04-12T00:00:00.000Z", }, }; @@ -267,7 +266,6 @@ describe("WorkspaceGitServiceImpl", () => { headRefName: "workspace-git-service", isMerged: false, }, - refreshedAt: "2026-04-12T02:03:04.000Z", }, }), ); @@ -276,6 +274,26 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); + test("non-forced GitHub refresh does not emit when pull request state is unchanged", async () => { + let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const service = createService({ + getPullRequestStatus, + now: () => new Date(nowMs), + }); + const listener = vi.fn(); + const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); + + nowMs += 3_000; + await service.refresh("/tmp/repo"); + + expect(getPullRequestStatus).toHaveBeenCalledTimes(2); + expect(listener).not.toHaveBeenCalled(); + + subscription.unsubscribe(); + service.dispose(); + }); + test("cold getSnapshot calls share one workspace target setup and cache the snapshot", async () => { const checkoutStatusDeferred = createDeferred(); const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise); @@ -361,13 +379,7 @@ describe("WorkspaceGitServiceImpl", () => { nowMs += 3_000; await service.refresh("/tmp/repo"); - await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual( - createSnapshot("/tmp/repo", { - github: { - refreshedAt: "2026-04-12T00:00:03.000Z", - }, - }), - ); + await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo")); expect(getPullRequestStatus).toHaveBeenCalledTimes(2); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); @@ -460,7 +472,6 @@ describe("WorkspaceGitServiceImpl", () => { headRefName: "feature", isMerged: true, }, - refreshedAt: "2026-04-12T00:05:00.000Z", }, }), ); @@ -534,7 +545,6 @@ describe("WorkspaceGitServiceImpl", () => { github: { featuresEnabled: false, pullRequest: null, - refreshedAt: null, }, }), ); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index c42c41c5e..32166db81 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -79,7 +79,6 @@ export type WorkspaceGitRuntimeSnapshot = { reviewDecision?: "approved" | "changes_requested" | "pending" | null; } | null; error: { message: string } | null; - refreshedAt: string | null; }; }; @@ -1499,7 +1498,6 @@ async function loadGitHubSnapshot(options: { featuresEnabled: false, pullRequest: null, error: null, - refreshedAt: null, }; } @@ -1510,7 +1508,6 @@ async function loadGitHubSnapshot(options: { featuresEnabled: false, pullRequest: null, error: null, - refreshedAt: null, }; } @@ -1523,7 +1520,6 @@ async function loadGitHubSnapshot(options: { featuresEnabled: true, pullRequest: result.status, error: null, - refreshedAt: options.now.toISOString(), }; } catch (error) { return { @@ -1532,7 +1528,6 @@ async function loadGitHubSnapshot(options: { error: { message: error instanceof Error ? error.message : String(error), }, - refreshedAt: options.now.toISOString(), }; } } @@ -1607,7 +1602,6 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { featuresEnabled: false, pullRequest: null, error: null, - refreshedAt: null, }, }; } diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts index c77be207e..8d3f925da 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -32,7 +32,6 @@ function createNoopWorkspaceGitService(): WorkspaceGitService { featuresEnabled: false, pullRequest: null, error: null, - refreshedAt: null, }, }, unsubscribe: () => {}, @@ -57,7 +56,6 @@ function createNoopWorkspaceGitService(): WorkspaceGitService { featuresEnabled: false, pullRequest: null, error: null, - refreshedAt: null, }, }), resolveRepoRemoteUrl: async () => null,