diff --git a/apps/code/src/renderer/utils/notifications.test.ts b/apps/code/src/renderer/utils/notifications.test.ts new file mode 100644 index 000000000..98546573f --- /dev/null +++ b/apps/code/src/renderer/utils/notifications.test.ts @@ -0,0 +1,175 @@ +import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { useNavigationStore } from "@stores/navigationStore"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { sendMutate, showDockBadgeMutate, bounceDockMutate, playSound } = + vi.hoisted(() => ({ + sendMutate: vi.fn().mockResolvedValue(undefined), + showDockBadgeMutate: vi.fn().mockResolvedValue(undefined), + bounceDockMutate: vi.fn().mockResolvedValue(undefined), + playSound: vi.fn(), + })); + +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: { + notification: { + send: { mutate: sendMutate }, + showDockBadge: { mutate: showDockBadgeMutate }, + bounceDock: { mutate: bounceDockMutate }, + }, + secureStore: { + getItem: { query: vi.fn().mockResolvedValue(null) }, + setItem: { query: vi.fn().mockResolvedValue(undefined) }, + removeItem: { query: vi.fn().mockResolvedValue(undefined) }, + }, + }, +})); + +vi.mock("@utils/logger", () => ({ + logger: { scope: () => ({ info: vi.fn(), error: vi.fn(), debug: vi.fn() }) }, +})); + +vi.mock("@utils/analytics", () => ({ track: vi.fn() })); + +vi.mock("@utils/sounds", () => ({ + playCompletionSound: playSound, +})); + +import { notifyPermissionRequest, notifyPromptComplete } from "./notifications"; + +const TASK_ID = "task-123"; +const OTHER_TASK_ID = "task-999"; + +type View = { type: string; data?: { id: string }; taskId?: string }; + +function setView(view: View) { + useNavigationStore.setState({ + // biome-ignore lint/suspicious/noExplicitAny: test-only narrow cast + view: view as any, + }); +} + +function setFocus(focused: boolean) { + vi.spyOn(document, "hasFocus").mockReturnValue(focused); +} + +describe("notifications", () => { + beforeEach(() => { + sendMutate.mockClear(); + showDockBadgeMutate.mockClear(); + bounceDockMutate.mockClear(); + playSound.mockClear(); + useSettingsStore.setState({ + desktopNotifications: true, + dockBadgeNotifications: true, + dockBounceNotifications: true, + completionSound: "meep", + completionVolume: 80, + }); + setView({ type: "task-input" }); + }); + + describe("shouldNotifyForTask gating (via notifyPermissionRequest)", () => { + const cases: ReadonlyArray<{ + name: string; + focused: boolean; + view: View; + taskId?: string; + shouldNotify: boolean; + }> = [ + { + name: "window unfocused → notifies", + focused: false, + view: { type: "task-detail", data: { id: TASK_ID }, taskId: TASK_ID }, + taskId: TASK_ID, + shouldNotify: true, + }, + { + name: "focused on the same task → does not notify", + focused: true, + view: { type: "task-detail", data: { id: TASK_ID }, taskId: TASK_ID }, + taskId: TASK_ID, + shouldNotify: false, + }, + { + name: "focused on a different task → notifies", + focused: true, + view: { + type: "task-detail", + data: { id: OTHER_TASK_ID }, + taskId: OTHER_TASK_ID, + }, + taskId: TASK_ID, + shouldNotify: true, + }, + { + name: "focused but view is not task-detail → notifies", + focused: true, + view: { type: "inbox" }, + taskId: TASK_ID, + shouldNotify: true, + }, + { + name: "focused with no taskId supplied → does not notify", + focused: true, + view: { type: "inbox" }, + taskId: undefined, + shouldNotify: false, + }, + { + name: "focused, view.data missing, falls back to view.taskId → does not notify", + focused: true, + view: { type: "task-detail", taskId: TASK_ID }, + taskId: TASK_ID, + shouldNotify: false, + }, + ]; + + it.each(cases)("$name", ({ focused, view, taskId, shouldNotify }) => { + setFocus(focused); + setView(view); + + notifyPermissionRequest("My task", taskId); + + expect(sendMutate).toHaveBeenCalledTimes(shouldNotify ? 1 : 0); + expect(playSound).toHaveBeenCalledTimes(shouldNotify ? 1 : 0); + }); + }); + + describe("notifyPromptComplete", () => { + it.each([ + { stopReason: "tool_use", shouldNotify: false }, + { stopReason: "max_tokens", shouldNotify: false }, + { stopReason: "end_turn", shouldNotify: true }, + ])( + "stop reason '$stopReason' → notifies=$shouldNotify", + ({ stopReason, shouldNotify }) => { + setFocus(false); + notifyPromptComplete("My task", stopReason, TASK_ID); + expect(sendMutate).toHaveBeenCalledTimes(shouldNotify ? 1 : 0); + }, + ); + + it.each([ + { + name: "focused on same task → does not notify", + view: { type: "task-detail", data: { id: TASK_ID }, taskId: TASK_ID }, + shouldNotify: false, + }, + { + name: "focused on different task → notifies", + view: { + type: "task-detail", + data: { id: OTHER_TASK_ID }, + taskId: OTHER_TASK_ID, + }, + shouldNotify: true, + }, + ])("$name", ({ view, shouldNotify }) => { + setFocus(true); + setView(view); + notifyPromptComplete("My task", "end_turn", TASK_ID); + expect(sendMutate).toHaveBeenCalledTimes(shouldNotify ? 1 : 0); + }); + }); +}); diff --git a/apps/code/src/renderer/utils/notifications.ts b/apps/code/src/renderer/utils/notifications.ts index 66bcaa34b..f29b27878 100644 --- a/apps/code/src/renderer/utils/notifications.ts +++ b/apps/code/src/renderer/utils/notifications.ts @@ -1,5 +1,6 @@ import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { trpcClient } from "@renderer/trpc/client"; +import { useNavigationStore } from "@stores/navigationStore"; import { logger } from "@utils/logger"; import { playCompletionSound } from "@utils/sounds"; @@ -12,6 +13,15 @@ function truncateTitle(title: string): string { return `${title.slice(0, MAX_TITLE_LENGTH)}...`; } +function shouldNotifyForTask(taskId?: string): boolean { + if (!document.hasFocus()) return true; + if (!taskId) return false; + const view = useNavigationStore.getState().view; + const viewedTaskId = + view.type === "task-detail" ? (view.data?.id ?? view.taskId) : undefined; + return viewedTaskId !== taskId; +} + function sendDesktopNotification( title: string, body: string, @@ -52,8 +62,7 @@ export function notifyPromptComplete( dockBounceNotifications, } = useSettingsStore.getState(); - const isWindowFocused = document.hasFocus(); - if (isWindowFocused) return; + if (!shouldNotifyForTask(taskId)) return; const willPlayCustomSound = completionSound !== "none"; playCompletionSound(completionSound, completionVolume); @@ -85,25 +94,24 @@ export function notifyPermissionRequest( dockBadgeNotifications, dockBounceNotifications, } = useSettingsStore.getState(); - const isWindowFocused = document.hasFocus(); - - if (!isWindowFocused) { - const willPlayCustomSound = completionSound !== "none"; - playCompletionSound(completionSound, completionVolume); - - if (desktopNotifications) { - sendDesktopNotification( - "PostHog Code", - `"${truncateTitle(taskTitle)}" needs your input`, - willPlayCustomSound, - taskId, - ); - } - if (dockBadgeNotifications) { - showDockBadge(); - } - if (dockBounceNotifications) { - bounceDock(); - } + + if (!shouldNotifyForTask(taskId)) return; + + const willPlayCustomSound = completionSound !== "none"; + playCompletionSound(completionSound, completionVolume); + + if (desktopNotifications) { + sendDesktopNotification( + "PostHog Code", + `"${truncateTitle(taskTitle)}" needs your input`, + willPlayCustomSound, + taskId, + ); + } + if (dockBadgeNotifications) { + showDockBadge(); + } + if (dockBounceNotifications) { + bounceDock(); } }