Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions apps/code/src/renderer/utils/notifications.test.ts
Comment thread
richardsolomou marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
52 changes: 30 additions & 22 deletions apps/code/src/renderer/utils/notifications.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
}
Comment thread
richardsolomou marked this conversation as resolved.

function sendDesktopNotification(
title: string,
body: string,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
}
Loading