Skip to content

Commit b8524c4

Browse files
feat(notifications): notify when focused on a different task (#2210)
1 parent 6e616e0 commit b8524c4

2 files changed

Lines changed: 205 additions & 22 deletions

File tree

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { useSettingsStore } from "@features/settings/stores/settingsStore";
2+
import { useNavigationStore } from "@stores/navigationStore";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
const { sendMutate, showDockBadgeMutate, bounceDockMutate, playSound } =
6+
vi.hoisted(() => ({
7+
sendMutate: vi.fn().mockResolvedValue(undefined),
8+
showDockBadgeMutate: vi.fn().mockResolvedValue(undefined),
9+
bounceDockMutate: vi.fn().mockResolvedValue(undefined),
10+
playSound: vi.fn(),
11+
}));
12+
13+
vi.mock("@renderer/trpc/client", () => ({
14+
trpcClient: {
15+
notification: {
16+
send: { mutate: sendMutate },
17+
showDockBadge: { mutate: showDockBadgeMutate },
18+
bounceDock: { mutate: bounceDockMutate },
19+
},
20+
secureStore: {
21+
getItem: { query: vi.fn().mockResolvedValue(null) },
22+
setItem: { query: vi.fn().mockResolvedValue(undefined) },
23+
removeItem: { query: vi.fn().mockResolvedValue(undefined) },
24+
},
25+
},
26+
}));
27+
28+
vi.mock("@utils/logger", () => ({
29+
logger: { scope: () => ({ info: vi.fn(), error: vi.fn(), debug: vi.fn() }) },
30+
}));
31+
32+
vi.mock("@utils/analytics", () => ({ track: vi.fn() }));
33+
34+
vi.mock("@utils/sounds", () => ({
35+
playCompletionSound: playSound,
36+
}));
37+
38+
import { notifyPermissionRequest, notifyPromptComplete } from "./notifications";
39+
40+
const TASK_ID = "task-123";
41+
const OTHER_TASK_ID = "task-999";
42+
43+
type View = { type: string; data?: { id: string }; taskId?: string };
44+
45+
function setView(view: View) {
46+
useNavigationStore.setState({
47+
// biome-ignore lint/suspicious/noExplicitAny: test-only narrow cast
48+
view: view as any,
49+
});
50+
}
51+
52+
function setFocus(focused: boolean) {
53+
vi.spyOn(document, "hasFocus").mockReturnValue(focused);
54+
}
55+
56+
describe("notifications", () => {
57+
beforeEach(() => {
58+
sendMutate.mockClear();
59+
showDockBadgeMutate.mockClear();
60+
bounceDockMutate.mockClear();
61+
playSound.mockClear();
62+
useSettingsStore.setState({
63+
desktopNotifications: true,
64+
dockBadgeNotifications: true,
65+
dockBounceNotifications: true,
66+
completionSound: "meep",
67+
completionVolume: 80,
68+
});
69+
setView({ type: "task-input" });
70+
});
71+
72+
describe("shouldNotifyForTask gating (via notifyPermissionRequest)", () => {
73+
const cases: ReadonlyArray<{
74+
name: string;
75+
focused: boolean;
76+
view: View;
77+
taskId?: string;
78+
shouldNotify: boolean;
79+
}> = [
80+
{
81+
name: "window unfocused → notifies",
82+
focused: false,
83+
view: { type: "task-detail", data: { id: TASK_ID }, taskId: TASK_ID },
84+
taskId: TASK_ID,
85+
shouldNotify: true,
86+
},
87+
{
88+
name: "focused on the same task → does not notify",
89+
focused: true,
90+
view: { type: "task-detail", data: { id: TASK_ID }, taskId: TASK_ID },
91+
taskId: TASK_ID,
92+
shouldNotify: false,
93+
},
94+
{
95+
name: "focused on a different task → notifies",
96+
focused: true,
97+
view: {
98+
type: "task-detail",
99+
data: { id: OTHER_TASK_ID },
100+
taskId: OTHER_TASK_ID,
101+
},
102+
taskId: TASK_ID,
103+
shouldNotify: true,
104+
},
105+
{
106+
name: "focused but view is not task-detail → notifies",
107+
focused: true,
108+
view: { type: "inbox" },
109+
taskId: TASK_ID,
110+
shouldNotify: true,
111+
},
112+
{
113+
name: "focused with no taskId supplied → does not notify",
114+
focused: true,
115+
view: { type: "inbox" },
116+
taskId: undefined,
117+
shouldNotify: false,
118+
},
119+
{
120+
name: "focused, view.data missing, falls back to view.taskId → does not notify",
121+
focused: true,
122+
view: { type: "task-detail", taskId: TASK_ID },
123+
taskId: TASK_ID,
124+
shouldNotify: false,
125+
},
126+
];
127+
128+
it.each(cases)("$name", ({ focused, view, taskId, shouldNotify }) => {
129+
setFocus(focused);
130+
setView(view);
131+
132+
notifyPermissionRequest("My task", taskId);
133+
134+
expect(sendMutate).toHaveBeenCalledTimes(shouldNotify ? 1 : 0);
135+
expect(playSound).toHaveBeenCalledTimes(shouldNotify ? 1 : 0);
136+
});
137+
});
138+
139+
describe("notifyPromptComplete", () => {
140+
it.each([
141+
{ stopReason: "tool_use", shouldNotify: false },
142+
{ stopReason: "max_tokens", shouldNotify: false },
143+
{ stopReason: "end_turn", shouldNotify: true },
144+
])(
145+
"stop reason '$stopReason' → notifies=$shouldNotify",
146+
({ stopReason, shouldNotify }) => {
147+
setFocus(false);
148+
notifyPromptComplete("My task", stopReason, TASK_ID);
149+
expect(sendMutate).toHaveBeenCalledTimes(shouldNotify ? 1 : 0);
150+
},
151+
);
152+
153+
it.each([
154+
{
155+
name: "focused on same task → does not notify",
156+
view: { type: "task-detail", data: { id: TASK_ID }, taskId: TASK_ID },
157+
shouldNotify: false,
158+
},
159+
{
160+
name: "focused on different task → notifies",
161+
view: {
162+
type: "task-detail",
163+
data: { id: OTHER_TASK_ID },
164+
taskId: OTHER_TASK_ID,
165+
},
166+
shouldNotify: true,
167+
},
168+
])("$name", ({ view, shouldNotify }) => {
169+
setFocus(true);
170+
setView(view);
171+
notifyPromptComplete("My task", "end_turn", TASK_ID);
172+
expect(sendMutate).toHaveBeenCalledTimes(shouldNotify ? 1 : 0);
173+
});
174+
});
175+
});

apps/code/src/renderer/utils/notifications.ts

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useSettingsStore } from "@features/settings/stores/settingsStore";
22
import { trpcClient } from "@renderer/trpc/client";
3+
import { useNavigationStore } from "@stores/navigationStore";
34
import { logger } from "@utils/logger";
45
import { playCompletionSound } from "@utils/sounds";
56

@@ -12,6 +13,15 @@ function truncateTitle(title: string): string {
1213
return `${title.slice(0, MAX_TITLE_LENGTH)}...`;
1314
}
1415

16+
function shouldNotifyForTask(taskId?: string): boolean {
17+
if (!document.hasFocus()) return true;
18+
if (!taskId) return false;
19+
const view = useNavigationStore.getState().view;
20+
const viewedTaskId =
21+
view.type === "task-detail" ? (view.data?.id ?? view.taskId) : undefined;
22+
return viewedTaskId !== taskId;
23+
}
24+
1525
function sendDesktopNotification(
1626
title: string,
1727
body: string,
@@ -52,8 +62,7 @@ export function notifyPromptComplete(
5262
dockBounceNotifications,
5363
} = useSettingsStore.getState();
5464

55-
const isWindowFocused = document.hasFocus();
56-
if (isWindowFocused) return;
65+
if (!shouldNotifyForTask(taskId)) return;
5766

5867
const willPlayCustomSound = completionSound !== "none";
5968
playCompletionSound(completionSound, completionVolume);
@@ -85,25 +94,24 @@ export function notifyPermissionRequest(
8594
dockBadgeNotifications,
8695
dockBounceNotifications,
8796
} = useSettingsStore.getState();
88-
const isWindowFocused = document.hasFocus();
89-
90-
if (!isWindowFocused) {
91-
const willPlayCustomSound = completionSound !== "none";
92-
playCompletionSound(completionSound, completionVolume);
93-
94-
if (desktopNotifications) {
95-
sendDesktopNotification(
96-
"PostHog Code",
97-
`"${truncateTitle(taskTitle)}" needs your input`,
98-
willPlayCustomSound,
99-
taskId,
100-
);
101-
}
102-
if (dockBadgeNotifications) {
103-
showDockBadge();
104-
}
105-
if (dockBounceNotifications) {
106-
bounceDock();
107-
}
97+
98+
if (!shouldNotifyForTask(taskId)) return;
99+
100+
const willPlayCustomSound = completionSound !== "none";
101+
playCompletionSound(completionSound, completionVolume);
102+
103+
if (desktopNotifications) {
104+
sendDesktopNotification(
105+
"PostHog Code",
106+
`"${truncateTitle(taskTitle)}" needs your input`,
107+
willPlayCustomSound,
108+
taskId,
109+
);
110+
}
111+
if (dockBadgeNotifications) {
112+
showDockBadge();
113+
}
114+
if (dockBounceNotifications) {
115+
bounceDock();
108116
}
109117
}

0 commit comments

Comments
 (0)