From a4f1a5d3d939001acaf17d2762ebbf594339a410 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sun, 24 May 2026 12:55:43 +0100 Subject: [PATCH 1/6] feat(code): pause task-list polling when window is hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task list polls every 30s for new tasks but never paused when the window lost focus, so the desktop kept fetching while the user was away. On return, the user was relying on `refetchOnWindowFocus` to surface tasks created elsewhere (e.g. cloud tasks created from mobile), and that didn't always feel reliable. Gate the `refetchInterval` on `useRendererWindowFocusStore` for the three task-list queries (`useTasks`, `useTaskSummaries`, `useSlackTasks`) so polling only runs when the document is visible and the window has OS focus. Bump the interval to 3 minutes — once focus is restored the existing `refetchOnWindowFocus: true` in `queryClient.ts` triggers an immediate fetch, so the in-foreground cadence doesn't need to be 30s. Generated-By: PostHog Code Task-Id: 4070b208-17d8-4eba-8b02-d8b6bbda6bbd --- .../src/renderer/features/tasks/hooks/useTasks.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index 9643f8ccf..953c6964d 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -6,6 +6,7 @@ import { useMeQuery } from "@hooks/useMeQuery"; import type { Schemas } from "@renderer/api/generated"; import { useFocusStore } from "@renderer/stores/focusStore"; import { useNavigationStore } from "@renderer/stores/navigationStore"; +import { useRendererWindowFocusStore } from "@renderer/stores/rendererWindowFocusStore"; import { trpcClient } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; import { keepPreviousData, useQueryClient } from "@tanstack/react-query"; @@ -14,7 +15,7 @@ import { useCallback } from "react"; const log = logger.scope("tasks"); -const TASK_LIST_POLL_INTERVAL_MS = 30_000; +const TASK_LIST_POLL_INTERVAL_MS = 3 * 60_000; const taskKeys = { all: ["tasks"] as const, @@ -42,6 +43,7 @@ export function useTasks( const { data: currentUser } = useMeQuery(); const createdBy = filters?.showAllUsers ? undefined : currentUser?.id; const internal = filters?.showInternal ? true : undefined; + const windowFocused = useRendererWindowFocusStore((s) => s.focused); return useAuthenticatedQuery( taskKeys.list({ repository: filters?.repository, createdBy, internal }), @@ -53,7 +55,7 @@ export function useTasks( }) as unknown as Promise, { enabled: (options?.enabled ?? true) && !!currentUser?.id, - refetchInterval: TASK_LIST_POLL_INTERVAL_MS, + refetchInterval: windowFocused ? TASK_LIST_POLL_INTERVAL_MS : false, }, ); } @@ -62,12 +64,13 @@ export function useTaskSummaries( ids: string[], options?: { enabled?: boolean }, ) { + const windowFocused = useRendererWindowFocusStore((s) => s.focused); return useAuthenticatedQuery( taskKeys.summaries(ids), (client) => client.getTaskSummaries(ids), { enabled: (options?.enabled ?? true) && ids.length > 0, - refetchInterval: TASK_LIST_POLL_INTERVAL_MS, + refetchInterval: windowFocused ? TASK_LIST_POLL_INTERVAL_MS : false, placeholderData: keepPreviousData, }, ); @@ -82,6 +85,7 @@ export function useSlackTasks(options?: { showInternal?: boolean; }) { const internal = options?.showInternal ? true : undefined; + const windowFocused = useRendererWindowFocusStore((s) => s.focused); return useAuthenticatedQuery( taskKeys.list({ originProduct: "slack", internal }), (client) => @@ -91,7 +95,7 @@ export function useSlackTasks(options?: { }) as unknown as Promise, { enabled: options?.enabled ?? true, - refetchInterval: TASK_LIST_POLL_INTERVAL_MS, + refetchInterval: windowFocused ? TASK_LIST_POLL_INTERVAL_MS : false, }, ); } From 20c94296964f02e86eccdfbee8da76fa9317453b Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sun, 24 May 2026 13:04:27 +0100 Subject: [PATCH 2/6] refactor(code): ramp task-list polling from 30s to 3min after focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the original commit: 1. **Ramp polling, then back off.** A flat 3-minute interval was too slow right after the user returned to the desktop — tasks created elsewhere could take up to 3 minutes to appear. The list now polls at 30s for the first half-minute of focus, then 60s, then 120s, then settles at 180s. Each tier's threshold is set so it fires roughly once before promoting, which gives a fast catch-up without sustained API noise. 2. **Force refetch on return.** The global staleTime is 5 minutes, which meant `refetchOnWindowFocus: true` skipped the on-focus refetch when the user came back within that window — the exact "I just got back from a walk" case that motivated this whole change. Set `refetchOnWindowFocus: "always"` on the task-list queries so the refetch happens regardless of staleness. The focus store now also records `focusedAt` so the backoff knows when to reset. The transition is detected by comparing the previous focused state in the sync listener. Generated-By: PostHog Code Task-Id: 4070b208-17d8-4eba-8b02-d8b6bbda6bbd --- .../renderer/features/tasks/hooks/useTasks.ts | 40 +++++++++++++++---- .../stores/rendererWindowFocusStore.ts | 27 +++++++++++-- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index 953c6964d..60e0e9429 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -15,7 +15,27 @@ import { useCallback } from "react"; const log = logger.scope("tasks"); -const TASK_LIST_POLL_INTERVAL_MS = 3 * 60_000; +// Poll fast right after focus, then back off to 3 min. Tier thresholds are +// the cumulative elapsed time at which each tier finishes one tick, so each +// tier fires roughly once before promoting to the next. +const TASK_LIST_POLL_MAX_MS = 3 * 60_000; + +function taskListPollInterval(elapsedSinceFocusMs: number): number { + if (elapsedSinceFocusMs < 30_000) return 30_000; + if (elapsedSinceFocusMs < 90_000) return 60_000; + if (elapsedSinceFocusMs < 210_000) return 120_000; + return TASK_LIST_POLL_MAX_MS; +} + +function useTaskListRefetchInterval(): () => number | false { + const focused = useRendererWindowFocusStore((s) => s.focused); + return useCallback(() => { + if (!focused) return false; + const focusedAt = useRendererWindowFocusStore.getState().focusedAt; + if (focusedAt == null) return false; + return taskListPollInterval(Date.now() - focusedAt); + }, [focused]); +} const taskKeys = { all: ["tasks"] as const, @@ -43,7 +63,7 @@ export function useTasks( const { data: currentUser } = useMeQuery(); const createdBy = filters?.showAllUsers ? undefined : currentUser?.id; const internal = filters?.showInternal ? true : undefined; - const windowFocused = useRendererWindowFocusStore((s) => s.focused); + const refetchInterval = useTaskListRefetchInterval(); return useAuthenticatedQuery( taskKeys.list({ repository: filters?.repository, createdBy, internal }), @@ -55,7 +75,11 @@ export function useTasks( }) as unknown as Promise, { enabled: (options?.enabled ?? true) && !!currentUser?.id, - refetchInterval: windowFocused ? TASK_LIST_POLL_INTERVAL_MS : false, + refetchInterval, + // The global staleTime is 5 min, so the default `true` would skip the + // on-focus refetch when returning within that window — exactly the case + // we care about (laptop opened after a short walk). Force the refetch. + refetchOnWindowFocus: "always", }, ); } @@ -64,13 +88,14 @@ export function useTaskSummaries( ids: string[], options?: { enabled?: boolean }, ) { - const windowFocused = useRendererWindowFocusStore((s) => s.focused); + const refetchInterval = useTaskListRefetchInterval(); return useAuthenticatedQuery( taskKeys.summaries(ids), (client) => client.getTaskSummaries(ids), { enabled: (options?.enabled ?? true) && ids.length > 0, - refetchInterval: windowFocused ? TASK_LIST_POLL_INTERVAL_MS : false, + refetchInterval, + refetchOnWindowFocus: "always", placeholderData: keepPreviousData, }, ); @@ -85,7 +110,7 @@ export function useSlackTasks(options?: { showInternal?: boolean; }) { const internal = options?.showInternal ? true : undefined; - const windowFocused = useRendererWindowFocusStore((s) => s.focused); + const refetchInterval = useTaskListRefetchInterval(); return useAuthenticatedQuery( taskKeys.list({ originProduct: "slack", internal }), (client) => @@ -95,7 +120,8 @@ export function useSlackTasks(options?: { }) as unknown as Promise, { enabled: options?.enabled ?? true, - refetchInterval: windowFocused ? TASK_LIST_POLL_INTERVAL_MS : false, + refetchInterval, + refetchOnWindowFocus: "always", }, ); } diff --git a/apps/code/src/renderer/stores/rendererWindowFocusStore.ts b/apps/code/src/renderer/stores/rendererWindowFocusStore.ts index e9b85c4ea..64857e622 100644 --- a/apps/code/src/renderer/stores/rendererWindowFocusStore.ts +++ b/apps/code/src/renderer/stores/rendererWindowFocusStore.ts @@ -11,9 +11,22 @@ function computeWindowFocused(): boolean { return document.visibilityState === "visible" && document.hasFocus(); } -export const useRendererWindowFocusStore = create<{ focused: boolean }>(() => ({ - focused: typeof document !== "undefined" ? computeWindowFocused() : false, -})); +interface RendererWindowFocusState { + focused: boolean; + // Timestamp (ms) of the most recent unfocused→focused transition. Pollers + // use this to ramp polling back up after the user returns, then back off. + focusedAt: number | null; +} + +const initialFocused = + typeof document !== "undefined" ? computeWindowFocused() : false; + +export const useRendererWindowFocusStore = create( + () => ({ + focused: initialFocused, + focusedAt: initialFocused ? Date.now() : null, + }), +); let listenersAttached = false; @@ -24,7 +37,13 @@ function ensureWindowFocusListeners(): void { listenersAttached = true; const sync = (): void => { - useRendererWindowFocusStore.setState({ focused: computeWindowFocused() }); + const focused = computeWindowFocused(); + const prev = useRendererWindowFocusStore.getState().focused; + if (focused === prev) return; + useRendererWindowFocusStore.setState({ + focused, + focusedAt: focused ? Date.now() : null, + }); }; window.addEventListener("focus", sync); From 6e58ded77a0847aa625317986b71babbbce65582 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sun, 24 May 2026 13:10:47 +0100 Subject: [PATCH 3/6] test(code): cover task-list poll backoff and focus-store transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock in the two behaviours the previous commits depend on: - `taskListPollInterval` — verify the tier boundaries (30s → 60s → 120s → 180s cap). Extracted the pure function into `features/tasks/utils/` so the test can import it without dragging in the trpc renderer client (which needs an Electron preload at import time). - `rendererWindowFocusStore` — verify the focus transition logic: `focusedAt` clears on blur, gets a fresh timestamp on focus, and is NOT reset by redundant focus events while already focused (which Electron can emit when a child frame regains focus). All 7 new tests pass. Generated-By: PostHog Code Task-Id: 4070b208-17d8-4eba-8b02-d8b6bbda6bbd --- .../renderer/features/tasks/hooks/useTasks.ts | 13 +---- .../tasks/utils/taskListPollInterval.test.ts | 26 +++++++++ .../tasks/utils/taskListPollInterval.ts | 11 ++++ .../stores/rendererWindowFocusStore.test.ts | 55 +++++++++++++++++++ 4 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 apps/code/src/renderer/features/tasks/utils/taskListPollInterval.test.ts create mode 100644 apps/code/src/renderer/features/tasks/utils/taskListPollInterval.ts create mode 100644 apps/code/src/renderer/stores/rendererWindowFocusStore.test.ts diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index 60e0e9429..d62bf65de 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -1,4 +1,5 @@ import { pinnedTasksApi } from "@features/sidebar/hooks/usePinnedTasks"; +import { taskListPollInterval } from "@features/tasks/utils/taskListPollInterval"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; @@ -15,18 +16,6 @@ import { useCallback } from "react"; const log = logger.scope("tasks"); -// Poll fast right after focus, then back off to 3 min. Tier thresholds are -// the cumulative elapsed time at which each tier finishes one tick, so each -// tier fires roughly once before promoting to the next. -const TASK_LIST_POLL_MAX_MS = 3 * 60_000; - -function taskListPollInterval(elapsedSinceFocusMs: number): number { - if (elapsedSinceFocusMs < 30_000) return 30_000; - if (elapsedSinceFocusMs < 90_000) return 60_000; - if (elapsedSinceFocusMs < 210_000) return 120_000; - return TASK_LIST_POLL_MAX_MS; -} - function useTaskListRefetchInterval(): () => number | false { const focused = useRendererWindowFocusStore((s) => s.focused); return useCallback(() => { diff --git a/apps/code/src/renderer/features/tasks/utils/taskListPollInterval.test.ts b/apps/code/src/renderer/features/tasks/utils/taskListPollInterval.test.ts new file mode 100644 index 000000000..a6525de1c --- /dev/null +++ b/apps/code/src/renderer/features/tasks/utils/taskListPollInterval.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { taskListPollInterval } from "./taskListPollInterval"; + +describe("taskListPollInterval", () => { + it("polls every 30s during the first 30s after focus", () => { + expect(taskListPollInterval(0)).toBe(30_000); + expect(taskListPollInterval(15_000)).toBe(30_000); + expect(taskListPollInterval(29_999)).toBe(30_000); + }); + + it("backs off to 60s once the 30s tier completes one tick", () => { + expect(taskListPollInterval(30_000)).toBe(60_000); + expect(taskListPollInterval(89_999)).toBe(60_000); + }); + + it("backs off to 120s once the 60s tier completes one tick", () => { + expect(taskListPollInterval(90_000)).toBe(120_000); + expect(taskListPollInterval(209_999)).toBe(120_000); + }); + + it("settles at 180s after the 120s tier completes one tick", () => { + expect(taskListPollInterval(210_000)).toBe(180_000); + expect(taskListPollInterval(10 * 60_000)).toBe(180_000); + expect(taskListPollInterval(Number.MAX_SAFE_INTEGER)).toBe(180_000); + }); +}); diff --git a/apps/code/src/renderer/features/tasks/utils/taskListPollInterval.ts b/apps/code/src/renderer/features/tasks/utils/taskListPollInterval.ts new file mode 100644 index 000000000..6daae736a --- /dev/null +++ b/apps/code/src/renderer/features/tasks/utils/taskListPollInterval.ts @@ -0,0 +1,11 @@ +// Poll fast right after focus, then back off to 3 min. Tier thresholds are +// the cumulative elapsed time at which each tier finishes one tick, so each +// tier fires roughly once before promoting to the next. +export const TASK_LIST_POLL_MAX_MS = 3 * 60_000; + +export function taskListPollInterval(elapsedSinceFocusMs: number): number { + if (elapsedSinceFocusMs < 30_000) return 30_000; + if (elapsedSinceFocusMs < 90_000) return 60_000; + if (elapsedSinceFocusMs < 210_000) return 120_000; + return TASK_LIST_POLL_MAX_MS; +} diff --git a/apps/code/src/renderer/stores/rendererWindowFocusStore.test.ts b/apps/code/src/renderer/stores/rendererWindowFocusStore.test.ts new file mode 100644 index 000000000..fa70e91c0 --- /dev/null +++ b/apps/code/src/renderer/stores/rendererWindowFocusStore.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useRendererWindowFocusStore } from "./rendererWindowFocusStore"; + +// The store reads document.hasFocus() and document.visibilityState through +// its computeWindowFocused() helper. We drive transitions by stubbing both +// before dispatching the focus/blur events the listeners are bound to. + +function setDocumentFocused(focused: boolean): void { + vi.spyOn(document, "hasFocus").mockReturnValue(focused); + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => (focused ? "visible" : "hidden"), + }); +} + +describe("rendererWindowFocusStore", () => { + beforeEach(() => { + setDocumentFocused(true); + useRendererWindowFocusStore.setState({ focused: true, focusedAt: 1_000 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("clears focusedAt when the window loses focus", () => { + setDocumentFocused(false); + window.dispatchEvent(new Event("blur")); + + const state = useRendererWindowFocusStore.getState(); + expect(state.focused).toBe(false); + expect(state.focusedAt).toBeNull(); + }); + + it("records a fresh focusedAt when focus returns", () => { + useRendererWindowFocusStore.setState({ focused: false, focusedAt: null }); + setDocumentFocused(true); + vi.spyOn(Date, "now").mockReturnValue(2_500); + + window.dispatchEvent(new Event("focus")); + + const state = useRendererWindowFocusStore.getState(); + expect(state.focused).toBe(true); + expect(state.focusedAt).toBe(2_500); + }); + + it("does not reset focusedAt when a focus event fires while already focused", () => { + // While focused, OS / Electron can fire a redundant `focus` event (e.g. + // when a child frame regains focus). The backoff ramp must not restart. + setDocumentFocused(true); + window.dispatchEvent(new Event("focus")); + + expect(useRendererWindowFocusStore.getState().focusedAt).toBe(1_000); + }); +}); From f9802c6f23f398b303aeca78185f8fd35bb4acd5 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sun, 24 May 2026 13:29:34 +0100 Subject: [PATCH 4/6] =?UTF-8?q?refactor(code):=20drop=20poll=20backoff=20?= =?UTF-8?q?=E2=80=94=20flat=203min=20+=20forced=20on-return=20refetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applying XP simple-design rules to the previous commits: The 4-tier backoff (`taskListPollInterval`) existed because I added it, not because the user's bug required it. The actual bug — cloud tasks not appearing after returning to the desktop — is solved entirely by `refetchOnWindowFocus: "always"` overriding the 5-min staleTime that was silently swallowing the on-focus refetch. Once the backoff goes, its supporting cast goes too: - `taskListPollInterval` (4-tier ramp with magic thresholds) — deleted. - `focusedAt` timestamp on `rendererWindowFocusStore` — only existed to feed the ramp; reverted to the original boolean-only store. - Both new test files — they were guarding code that no longer exists. What remains: each task-list query polls every 3 min when focused, not at all when hidden, and force-refetches on focus return regardless of staleness. That's the whole feature. No new tests in this commit either — the change is a one-line ternary plus a declarative TanStack Query option. The behavior worth protecting (refetchOnWindowFocus: "always" overriding staleTime) is a library contract, not our logic. Tests would be testing TanStack Query, not us. Generated-By: PostHog Code Task-Id: 4070b208-17d8-4eba-8b02-d8b6bbda6bbd --- .../renderer/features/tasks/hooks/useTasks.ts | 12 ++-- .../tasks/utils/taskListPollInterval.test.ts | 26 --------- .../tasks/utils/taskListPollInterval.ts | 11 ---- .../stores/rendererWindowFocusStore.test.ts | 55 ------------------- .../stores/rendererWindowFocusStore.ts | 27 ++------- 5 files changed, 8 insertions(+), 123 deletions(-) delete mode 100644 apps/code/src/renderer/features/tasks/utils/taskListPollInterval.test.ts delete mode 100644 apps/code/src/renderer/features/tasks/utils/taskListPollInterval.ts delete mode 100644 apps/code/src/renderer/stores/rendererWindowFocusStore.test.ts diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index d62bf65de..ed3f50a87 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -1,5 +1,4 @@ import { pinnedTasksApi } from "@features/sidebar/hooks/usePinnedTasks"; -import { taskListPollInterval } from "@features/tasks/utils/taskListPollInterval"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; @@ -16,14 +15,11 @@ import { useCallback } from "react"; const log = logger.scope("tasks"); -function useTaskListRefetchInterval(): () => number | false { +const TASK_LIST_POLL_INTERVAL_MS = 3 * 60_000; + +function useTaskListRefetchInterval(): number | false { const focused = useRendererWindowFocusStore((s) => s.focused); - return useCallback(() => { - if (!focused) return false; - const focusedAt = useRendererWindowFocusStore.getState().focusedAt; - if (focusedAt == null) return false; - return taskListPollInterval(Date.now() - focusedAt); - }, [focused]); + return focused ? TASK_LIST_POLL_INTERVAL_MS : false; } const taskKeys = { diff --git a/apps/code/src/renderer/features/tasks/utils/taskListPollInterval.test.ts b/apps/code/src/renderer/features/tasks/utils/taskListPollInterval.test.ts deleted file mode 100644 index a6525de1c..000000000 --- a/apps/code/src/renderer/features/tasks/utils/taskListPollInterval.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { taskListPollInterval } from "./taskListPollInterval"; - -describe("taskListPollInterval", () => { - it("polls every 30s during the first 30s after focus", () => { - expect(taskListPollInterval(0)).toBe(30_000); - expect(taskListPollInterval(15_000)).toBe(30_000); - expect(taskListPollInterval(29_999)).toBe(30_000); - }); - - it("backs off to 60s once the 30s tier completes one tick", () => { - expect(taskListPollInterval(30_000)).toBe(60_000); - expect(taskListPollInterval(89_999)).toBe(60_000); - }); - - it("backs off to 120s once the 60s tier completes one tick", () => { - expect(taskListPollInterval(90_000)).toBe(120_000); - expect(taskListPollInterval(209_999)).toBe(120_000); - }); - - it("settles at 180s after the 120s tier completes one tick", () => { - expect(taskListPollInterval(210_000)).toBe(180_000); - expect(taskListPollInterval(10 * 60_000)).toBe(180_000); - expect(taskListPollInterval(Number.MAX_SAFE_INTEGER)).toBe(180_000); - }); -}); diff --git a/apps/code/src/renderer/features/tasks/utils/taskListPollInterval.ts b/apps/code/src/renderer/features/tasks/utils/taskListPollInterval.ts deleted file mode 100644 index 6daae736a..000000000 --- a/apps/code/src/renderer/features/tasks/utils/taskListPollInterval.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Poll fast right after focus, then back off to 3 min. Tier thresholds are -// the cumulative elapsed time at which each tier finishes one tick, so each -// tier fires roughly once before promoting to the next. -export const TASK_LIST_POLL_MAX_MS = 3 * 60_000; - -export function taskListPollInterval(elapsedSinceFocusMs: number): number { - if (elapsedSinceFocusMs < 30_000) return 30_000; - if (elapsedSinceFocusMs < 90_000) return 60_000; - if (elapsedSinceFocusMs < 210_000) return 120_000; - return TASK_LIST_POLL_MAX_MS; -} diff --git a/apps/code/src/renderer/stores/rendererWindowFocusStore.test.ts b/apps/code/src/renderer/stores/rendererWindowFocusStore.test.ts deleted file mode 100644 index fa70e91c0..000000000 --- a/apps/code/src/renderer/stores/rendererWindowFocusStore.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useRendererWindowFocusStore } from "./rendererWindowFocusStore"; - -// The store reads document.hasFocus() and document.visibilityState through -// its computeWindowFocused() helper. We drive transitions by stubbing both -// before dispatching the focus/blur events the listeners are bound to. - -function setDocumentFocused(focused: boolean): void { - vi.spyOn(document, "hasFocus").mockReturnValue(focused); - Object.defineProperty(document, "visibilityState", { - configurable: true, - get: () => (focused ? "visible" : "hidden"), - }); -} - -describe("rendererWindowFocusStore", () => { - beforeEach(() => { - setDocumentFocused(true); - useRendererWindowFocusStore.setState({ focused: true, focusedAt: 1_000 }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("clears focusedAt when the window loses focus", () => { - setDocumentFocused(false); - window.dispatchEvent(new Event("blur")); - - const state = useRendererWindowFocusStore.getState(); - expect(state.focused).toBe(false); - expect(state.focusedAt).toBeNull(); - }); - - it("records a fresh focusedAt when focus returns", () => { - useRendererWindowFocusStore.setState({ focused: false, focusedAt: null }); - setDocumentFocused(true); - vi.spyOn(Date, "now").mockReturnValue(2_500); - - window.dispatchEvent(new Event("focus")); - - const state = useRendererWindowFocusStore.getState(); - expect(state.focused).toBe(true); - expect(state.focusedAt).toBe(2_500); - }); - - it("does not reset focusedAt when a focus event fires while already focused", () => { - // While focused, OS / Electron can fire a redundant `focus` event (e.g. - // when a child frame regains focus). The backoff ramp must not restart. - setDocumentFocused(true); - window.dispatchEvent(new Event("focus")); - - expect(useRendererWindowFocusStore.getState().focusedAt).toBe(1_000); - }); -}); diff --git a/apps/code/src/renderer/stores/rendererWindowFocusStore.ts b/apps/code/src/renderer/stores/rendererWindowFocusStore.ts index 64857e622..e9b85c4ea 100644 --- a/apps/code/src/renderer/stores/rendererWindowFocusStore.ts +++ b/apps/code/src/renderer/stores/rendererWindowFocusStore.ts @@ -11,22 +11,9 @@ function computeWindowFocused(): boolean { return document.visibilityState === "visible" && document.hasFocus(); } -interface RendererWindowFocusState { - focused: boolean; - // Timestamp (ms) of the most recent unfocused→focused transition. Pollers - // use this to ramp polling back up after the user returns, then back off. - focusedAt: number | null; -} - -const initialFocused = - typeof document !== "undefined" ? computeWindowFocused() : false; - -export const useRendererWindowFocusStore = create( - () => ({ - focused: initialFocused, - focusedAt: initialFocused ? Date.now() : null, - }), -); +export const useRendererWindowFocusStore = create<{ focused: boolean }>(() => ({ + focused: typeof document !== "undefined" ? computeWindowFocused() : false, +})); let listenersAttached = false; @@ -37,13 +24,7 @@ function ensureWindowFocusListeners(): void { listenersAttached = true; const sync = (): void => { - const focused = computeWindowFocused(); - const prev = useRendererWindowFocusStore.getState().focused; - if (focused === prev) return; - useRendererWindowFocusStore.setState({ - focused, - focusedAt: focused ? Date.now() : null, - }); + useRendererWindowFocusStore.setState({ focused: computeWindowFocused() }); }; window.addEventListener("focus", sync); From f2d942508500b1074a9cf0bad99ddb1aee4e59f1 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sun, 24 May 2026 14:05:01 +0100 Subject: [PATCH 5/6] refactor(code): collapse task-list polling options into one helper + test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addressing review feedback on PR #2335: - **DRY** (convergent finding from 3 reviewers): the trio of `refetchInterval`, `refetchOnWindowFocus: "always"`, and the focus store subscription was repeated across `useTasks`, `useTaskSummaries`, and `useSlackTasks`. Collapsed into a single `useTaskListQueryOptions` hook spread into each query. The "why always" comment now lives once, next to the option it explains. - **Naming**: renamed `TASK_LIST_POLL_INTERVAL_MS` to `TASK_LIST_FOCUSED_POLL_INTERVAL_MS` — the value only applies while the window is focused, so the name should say so. - **Test**: added `useTaskListQueryOptions.test.ts` covering the three contracts (paused when not focused, polling at the focused interval, forced `"always"` refetch on focus). The hook is the integration point between the focus store and TanStack Query — exactly the kind of glue that quietly breaks under refactors. - **Doc**: generalised the JSDoc on `rendererWindowFocusStore` — it no longer only powers inbox polling. Deliberately not addressed: - 30s→3min interval bump (MEDIUM): author themselves framed it as "not blocking, worth a beat before merging"; leaving the choice to the human reviewer rather than overriding the interval the user already chose in chat. - 3 simultaneous refetches on focus, focusManager duplication, useFocusStore naming clash: all flagged "not a blocker / outside scope" by the reviewer themselves. Generated-By: PostHog Code Task-Id: 4070b208-17d8-4eba-8b02-d8b6bbda6bbd --- .../hooks/useTaskListQueryOptions.test.ts | 32 +++++++++++++++++++ .../tasks/hooks/useTaskListQueryOptions.ts | 24 ++++++++++++++ .../renderer/features/tasks/hooks/useTasks.ts | 27 ++++------------ .../stores/rendererWindowFocusStore.ts | 3 +- 4 files changed, 65 insertions(+), 21 deletions(-) create mode 100644 apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.test.ts create mode 100644 apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.ts diff --git a/apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.test.ts b/apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.test.ts new file mode 100644 index 000000000..0ddef1680 --- /dev/null +++ b/apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.test.ts @@ -0,0 +1,32 @@ +import { useRendererWindowFocusStore } from "@renderer/stores/rendererWindowFocusStore"; +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + TASK_LIST_FOCUSED_POLL_INTERVAL_MS, + useTaskListQueryOptions, +} from "./useTaskListQueryOptions"; + +describe("useTaskListQueryOptions", () => { + beforeEach(() => { + useRendererWindowFocusStore.setState({ focused: true }); + }); + + it("disables polling when the window is not focused", () => { + useRendererWindowFocusStore.setState({ focused: false }); + const { result } = renderHook(() => useTaskListQueryOptions()); + expect(result.current.refetchInterval).toBe(false); + }); + + it("polls at the focused interval when the window is focused", () => { + useRendererWindowFocusStore.setState({ focused: true }); + const { result } = renderHook(() => useTaskListQueryOptions()); + expect(result.current.refetchInterval).toBe( + TASK_LIST_FOCUSED_POLL_INTERVAL_MS, + ); + }); + + it('forces "always" refetch on focus to bypass the global staleTime', () => { + const { result } = renderHook(() => useTaskListQueryOptions()); + expect(result.current.refetchOnWindowFocus).toBe("always"); + }); +}); diff --git a/apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.ts b/apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.ts new file mode 100644 index 000000000..e3d085e9d --- /dev/null +++ b/apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.ts @@ -0,0 +1,24 @@ +import { useRendererWindowFocusStore } from "@renderer/stores/rendererWindowFocusStore"; + +export const TASK_LIST_FOCUSED_POLL_INTERVAL_MS = 3 * 60_000; + +/** + * Shared polling options for the three task-list queries (tasks, + * summaries, slack tasks). Centralises two policy decisions: + * + * 1. Polling pauses when the document is hidden / window unfocused. + * 2. On return to focus, force a refetch regardless of staleness — the + * global `staleTime` is 5 min, so `refetchOnWindowFocus: true` would + * silently skip the refetch in the exact window we care about + * (laptop opened after a short walk). + */ +export function useTaskListQueryOptions(): { + refetchInterval: number | false; + refetchOnWindowFocus: "always"; +} { + const focused = useRendererWindowFocusStore((s) => s.focused); + return { + refetchInterval: focused ? TASK_LIST_FOCUSED_POLL_INTERVAL_MS : false, + refetchOnWindowFocus: "always", + }; +} diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index ed3f50a87..79955864f 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -1,4 +1,5 @@ import { pinnedTasksApi } from "@features/sidebar/hooks/usePinnedTasks"; +import { useTaskListQueryOptions } from "@features/tasks/hooks/useTaskListQueryOptions"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; @@ -6,7 +7,6 @@ import { useMeQuery } from "@hooks/useMeQuery"; import type { Schemas } from "@renderer/api/generated"; import { useFocusStore } from "@renderer/stores/focusStore"; import { useNavigationStore } from "@renderer/stores/navigationStore"; -import { useRendererWindowFocusStore } from "@renderer/stores/rendererWindowFocusStore"; import { trpcClient } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; import { keepPreviousData, useQueryClient } from "@tanstack/react-query"; @@ -15,13 +15,6 @@ import { useCallback } from "react"; const log = logger.scope("tasks"); -const TASK_LIST_POLL_INTERVAL_MS = 3 * 60_000; - -function useTaskListRefetchInterval(): number | false { - const focused = useRendererWindowFocusStore((s) => s.focused); - return focused ? TASK_LIST_POLL_INTERVAL_MS : false; -} - const taskKeys = { all: ["tasks"] as const, lists: () => [...taskKeys.all, "list"] as const, @@ -48,7 +41,7 @@ export function useTasks( const { data: currentUser } = useMeQuery(); const createdBy = filters?.showAllUsers ? undefined : currentUser?.id; const internal = filters?.showInternal ? true : undefined; - const refetchInterval = useTaskListRefetchInterval(); + const pollingOptions = useTaskListQueryOptions(); return useAuthenticatedQuery( taskKeys.list({ repository: filters?.repository, createdBy, internal }), @@ -60,11 +53,7 @@ export function useTasks( }) as unknown as Promise, { enabled: (options?.enabled ?? true) && !!currentUser?.id, - refetchInterval, - // The global staleTime is 5 min, so the default `true` would skip the - // on-focus refetch when returning within that window — exactly the case - // we care about (laptop opened after a short walk). Force the refetch. - refetchOnWindowFocus: "always", + ...pollingOptions, }, ); } @@ -73,14 +62,13 @@ export function useTaskSummaries( ids: string[], options?: { enabled?: boolean }, ) { - const refetchInterval = useTaskListRefetchInterval(); + const pollingOptions = useTaskListQueryOptions(); return useAuthenticatedQuery( taskKeys.summaries(ids), (client) => client.getTaskSummaries(ids), { enabled: (options?.enabled ?? true) && ids.length > 0, - refetchInterval, - refetchOnWindowFocus: "always", + ...pollingOptions, placeholderData: keepPreviousData, }, ); @@ -95,7 +83,7 @@ export function useSlackTasks(options?: { showInternal?: boolean; }) { const internal = options?.showInternal ? true : undefined; - const refetchInterval = useTaskListRefetchInterval(); + const pollingOptions = useTaskListQueryOptions(); return useAuthenticatedQuery( taskKeys.list({ originProduct: "slack", internal }), (client) => @@ -105,8 +93,7 @@ export function useSlackTasks(options?: { }) as unknown as Promise, { enabled: options?.enabled ?? true, - refetchInterval, - refetchOnWindowFocus: "always", + ...pollingOptions, }, ); } diff --git a/apps/code/src/renderer/stores/rendererWindowFocusStore.ts b/apps/code/src/renderer/stores/rendererWindowFocusStore.ts index e9b85c4ea..22135b927 100644 --- a/apps/code/src/renderer/stores/rendererWindowFocusStore.ts +++ b/apps/code/src/renderer/stores/rendererWindowFocusStore.ts @@ -2,7 +2,8 @@ import { create } from "zustand"; /** * True when the renderer document is visible and the window has OS focus. - * Used to pause inbox polling when the Electron window is in the background. + * Used to pause polling-style queries (inbox, task list, etc.) when the + * Electron window is in the background. */ function computeWindowFocused(): boolean { if (typeof document === "undefined") { From ac4556250a3e8bf57f9fb95043e215049fb3c5ce Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sun, 24 May 2026 14:10:06 +0100 Subject: [PATCH 6/6] =?UTF-8?q?refactor(code):=20drop=20task-list=20pollin?= =?UTF-8?q?g=20=E2=80=94=20premature=20optimisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polling cadence (and the focus-gated 3-min interval) was carrying no weight: `refetchOnWindowFocus: "always"` overriding the global 5-min staleTime is what actually surfaces tasks created elsewhere when the user returns to the desktop. The polling on top was an optimisation for a problem we hadn't observed. - `useTaskListQueryOptions` (the hook) had a single piece of state — the focus subscription used to derive `refetchInterval`. With polling gone there is no state to subscribe to, so demote to a plain `TASK_LIST_QUERY_OPTIONS` const and rename the file to match. Three call sites spread the const directly. - Delete `useTaskListQueryOptions.test.ts`: two of its three assertions covered the removed polling behaviour; the third (`refetchOnWindowFocus: "always"`) is a TanStack Query library contract and not ours to test. - `TASK_LIST_FOCUSED_POLL_INTERVAL_MS` removed alongside. Generated-By: PostHog Code Task-Id: 5b3f974d-074d-4bd8-aaff-974a34db5614 --- .../tasks/hooks/taskListQueryOptions.ts | 10 ++++++ .../hooks/useTaskListQueryOptions.test.ts | 32 ------------------- .../tasks/hooks/useTaskListQueryOptions.ts | 24 -------------- .../renderer/features/tasks/hooks/useTasks.ts | 11 +++---- 4 files changed, 14 insertions(+), 63 deletions(-) create mode 100644 apps/code/src/renderer/features/tasks/hooks/taskListQueryOptions.ts delete mode 100644 apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.test.ts delete mode 100644 apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.ts diff --git a/apps/code/src/renderer/features/tasks/hooks/taskListQueryOptions.ts b/apps/code/src/renderer/features/tasks/hooks/taskListQueryOptions.ts new file mode 100644 index 000000000..796c39034 --- /dev/null +++ b/apps/code/src/renderer/features/tasks/hooks/taskListQueryOptions.ts @@ -0,0 +1,10 @@ +/** + * Shared options for the three task-list queries (tasks, summaries, + * slack tasks). Force a refetch on return-to-focus regardless of + * staleness — the global `staleTime` is 5 min, so + * `refetchOnWindowFocus: true` would silently skip the refetch in the + * exact window we care about (laptop opened after a short walk). + */ +export const TASK_LIST_QUERY_OPTIONS = { + refetchOnWindowFocus: "always" as const, +}; diff --git a/apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.test.ts b/apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.test.ts deleted file mode 100644 index 0ddef1680..000000000 --- a/apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useRendererWindowFocusStore } from "@renderer/stores/rendererWindowFocusStore"; -import { renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it } from "vitest"; -import { - TASK_LIST_FOCUSED_POLL_INTERVAL_MS, - useTaskListQueryOptions, -} from "./useTaskListQueryOptions"; - -describe("useTaskListQueryOptions", () => { - beforeEach(() => { - useRendererWindowFocusStore.setState({ focused: true }); - }); - - it("disables polling when the window is not focused", () => { - useRendererWindowFocusStore.setState({ focused: false }); - const { result } = renderHook(() => useTaskListQueryOptions()); - expect(result.current.refetchInterval).toBe(false); - }); - - it("polls at the focused interval when the window is focused", () => { - useRendererWindowFocusStore.setState({ focused: true }); - const { result } = renderHook(() => useTaskListQueryOptions()); - expect(result.current.refetchInterval).toBe( - TASK_LIST_FOCUSED_POLL_INTERVAL_MS, - ); - }); - - it('forces "always" refetch on focus to bypass the global staleTime', () => { - const { result } = renderHook(() => useTaskListQueryOptions()); - expect(result.current.refetchOnWindowFocus).toBe("always"); - }); -}); diff --git a/apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.ts b/apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.ts deleted file mode 100644 index e3d085e9d..000000000 --- a/apps/code/src/renderer/features/tasks/hooks/useTaskListQueryOptions.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useRendererWindowFocusStore } from "@renderer/stores/rendererWindowFocusStore"; - -export const TASK_LIST_FOCUSED_POLL_INTERVAL_MS = 3 * 60_000; - -/** - * Shared polling options for the three task-list queries (tasks, - * summaries, slack tasks). Centralises two policy decisions: - * - * 1. Polling pauses when the document is hidden / window unfocused. - * 2. On return to focus, force a refetch regardless of staleness — the - * global `staleTime` is 5 min, so `refetchOnWindowFocus: true` would - * silently skip the refetch in the exact window we care about - * (laptop opened after a short walk). - */ -export function useTaskListQueryOptions(): { - refetchInterval: number | false; - refetchOnWindowFocus: "always"; -} { - const focused = useRendererWindowFocusStore((s) => s.focused); - return { - refetchInterval: focused ? TASK_LIST_FOCUSED_POLL_INTERVAL_MS : false, - refetchOnWindowFocus: "always", - }; -} diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index 79955864f..04142501a 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -1,5 +1,5 @@ import { pinnedTasksApi } from "@features/sidebar/hooks/usePinnedTasks"; -import { useTaskListQueryOptions } from "@features/tasks/hooks/useTaskListQueryOptions"; +import { TASK_LIST_QUERY_OPTIONS } from "@features/tasks/hooks/taskListQueryOptions"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; @@ -41,7 +41,6 @@ export function useTasks( const { data: currentUser } = useMeQuery(); const createdBy = filters?.showAllUsers ? undefined : currentUser?.id; const internal = filters?.showInternal ? true : undefined; - const pollingOptions = useTaskListQueryOptions(); return useAuthenticatedQuery( taskKeys.list({ repository: filters?.repository, createdBy, internal }), @@ -53,7 +52,7 @@ export function useTasks( }) as unknown as Promise, { enabled: (options?.enabled ?? true) && !!currentUser?.id, - ...pollingOptions, + ...TASK_LIST_QUERY_OPTIONS, }, ); } @@ -62,13 +61,12 @@ export function useTaskSummaries( ids: string[], options?: { enabled?: boolean }, ) { - const pollingOptions = useTaskListQueryOptions(); return useAuthenticatedQuery( taskKeys.summaries(ids), (client) => client.getTaskSummaries(ids), { enabled: (options?.enabled ?? true) && ids.length > 0, - ...pollingOptions, + ...TASK_LIST_QUERY_OPTIONS, placeholderData: keepPreviousData, }, ); @@ -83,7 +81,6 @@ export function useSlackTasks(options?: { showInternal?: boolean; }) { const internal = options?.showInternal ? true : undefined; - const pollingOptions = useTaskListQueryOptions(); return useAuthenticatedQuery( taskKeys.list({ originProduct: "slack", internal }), (client) => @@ -93,7 +90,7 @@ export function useSlackTasks(options?: { }) as unknown as Promise, { enabled: options?.enabled ?? true, - ...pollingOptions, + ...TASK_LIST_QUERY_OPTIONS, }, ); }