authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-13 19:29:36+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 13:29:36-03:00
log42b6bcc6f59bd0369377746b0f2ef370c275b624
tree485814a29cb5f2bcc5cb0b57f2817928d7d07bcb
parentca2cc1339bbd1904b932c3a92d23a2c0ffe1d267
signature Signed by PGP key B5690EEEBB952194

feat(web): add opt-in in-app thread notifications (#11570)


8 files changed, 367 insertions(+), 20 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
......@@ -19,6 +19,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts";
1919const clientSettings: ClientSettings = {
2020 ...DEFAULT_CLIENT_SETTINGS,
2121 notificationMode: "notifications-and-sound",
22 inAppNotificationsEnabled: true,
2223 appearanceContrast: 100,
2324 browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
2425 browserDefaultZoomFactor: 1.25,
apps/web/src/components/ThreadNotificationCoordinator.test.tsx created+249
......@@ -0,0 +1,249 @@
1import type { ClientSettings } from "@t3tools/contracts/settings";
2import * as Option from "effect/Option";
3import { act } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7const state = vi.hoisted(() => ({
8 mode: "off" as ClientSettings["notificationMode"],
9 inApp: true,
10 active: { environmentId: "env-1", threadId: "other-thread" },
11 focused: true,
12 visible: "visible",
13 live: true,
14 completedAt: null as string | null,
15 archivedAt: null as string | null,
16 input: false,
17 approval: false,
18 sessionError: false,
19 turnError: false,
20 add: vi.fn(
21 (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) =>
22 "toast-1",
23 ),
24 close: vi.fn(),
25 navigate: vi.fn(),
26 sound: vi.fn(),
27 notification: vi.fn(function () {}),
28}));
29
30vi.mock("@effect/atom-react", () => ({
31 useAtomValue: () => ({
32 status: state.live ? "live" : "disconnected",
33 snapshot: Option.some({
34 threads: [
35 {
36 id: "thread-1",
37 title: "Fix the login form",
38 archivedAt: state.archivedAt,
39 hasPendingUserInput: state.input,
40 hasPendingApprovals: state.approval,
41 session: state.sessionError ? { status: "error" } : null,
42 latestTurn: {
43 turnId: "turn-1",
44 state: state.turnError ? "error" : state.completedAt ? "completed" : "running",
45 completedAt: state.completedAt,
46 },
47 },
48 ],
49 }),
50 }),
51}));
52vi.mock("@tanstack/react-router", () => ({
53 useNavigate: () => state.navigate,
54 useParams: () => state.active,
55}));
56vi.mock("../hooks/useSettings", () => ({
57 useClientSettings: (
58 select: (
59 settings: Pick<ClientSettings, "notificationMode" | "inAppNotificationsEnabled">,
60 ) => unknown,
61 ) => select({ notificationMode: state.mode, inAppNotificationsEnabled: state.inApp }),
62 getClientSettings: () => ({ notificationMode: state.mode }),
63}));
64vi.mock("../state/environments", () => ({
65 useEnvironments: () => ({ environments: [{ environmentId: "env-1" }] }),
66}));
67vi.mock("../state/shell", () => ({
68 environmentShell: { stateValueAtom: vi.fn() },
69}));
70vi.mock("../threadNotifications", async (importOriginal) => ({
71 ...(await importOriginal<typeof import("../threadNotifications")>()),
72 playNotificationSound: state.sound,
73}));
74vi.mock("./ui/toast", () => ({
75 toastManager: { add: state.add, close: state.close },
76}));
77
78import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator";
79
80let renderer: ReactTestRenderer | undefined;
81
82async function render() {
83 await act(() => {
84 if (renderer) renderer.update(<ThreadNotificationCoordinator />);
85 else renderer = create(<ThreadNotificationCoordinator />);
86 });
87}
88
89async function complete() {
90 state.completedAt = "2026-09-13T10:00:00.000Z";
91 await render();
92}
93
94beforeEach(() => {
95 vi.clearAllMocks();
96 Object.assign(state, {
97 mode: "off",
98 inApp: true,
99 active: { environmentId: "env-1", threadId: "other-thread" },
100 focused: true,
101 visible: "visible",
102 live: true,
103 completedAt: null,
104 archivedAt: null,
105 input: false,
106 approval: false,
107 sessionError: false,
108 turnError: false,
109 });
110 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
111 vi.stubGlobal("document", {
112 get visibilityState() {
113 return state.visible;
114 },
115 hasFocus: () => state.focused,
116 addEventListener: vi.fn(),
117 removeEventListener: vi.fn(),
118 });
119 vi.stubGlobal("Notification", Object.assign(state.notification, { permission: "granted" }));
120});
121
122afterEach(async () => {
123 await act(() => renderer?.unmount());
124 renderer = undefined;
125 vi.unstubAllGlobals();
126});
127
128describe("thread notifications", () => {
129 it("alerts once with system alerts off and opens the completed thread", async () => {
130 await render();
131 await complete();
132 await render();
133 expect(state.add).toHaveBeenCalledTimes(1);
134 const toast = state.add.mock.calls[0]?.[0];
135 expect(toast?.title).toBe("Thread completed");
136 expect(toast?.description).toBe("Fix the login form");
137 toast?.actionProps.onClick();
138 expect(state.close).toHaveBeenCalledWith("toast-1");
139 expect(state.navigate).toHaveBeenCalledWith({
140 to: "/$environmentId/$threadId",
141 params: { environmentId: "env-1", threadId: "thread-1" },
142 });
143 expect(state.notification).not.toHaveBeenCalled();
144 });
145
146 it.each(["active", "blurred", "hidden", "archived", "disabled"])(
147 "does not show a completion toast for %s threads",
148 async (condition) => {
149 await render();
150 if (condition === "active") state.active.threadId = "thread-1";
151 if (condition === "blurred") state.focused = false;
152 if (condition === "hidden") state.visible = "hidden";
153 if (condition === "archived") state.archivedAt = "2026-09-13T09:00:00.000Z";
154 if (condition === "disabled") state.inApp = false;
155 await complete();
156 expect(state.add).not.toHaveBeenCalled();
157 },
158 );
159
160 it.each([
161 ["input", "Input needed"],
162 ["approval", "Approval needed"],
163 ["sessionError", "Thread failed"],
164 ["turnError", "Thread failed"],
165 ] as const)("uses the same %s event for in-app and desktop alerts", async (event, title) => {
166 state.mode = "notifications-and-sound";
167 await render();
168 state[event] = true;
169 await render();
170 await render();
171 expect(state.add).toHaveBeenCalledTimes(1);
172 expect(state.add).toHaveBeenLastCalledWith(expect.objectContaining({ title }));
173 expect(state.sound).toHaveBeenCalledWith("input", expect.any(Function));
174 expect(state.notification).not.toHaveBeenCalled();
175
176 state[event] = false;
177 await render();
178 state.focused = false;
179 state[event] = true;
180 await render();
181 await render();
182 expect(state.add).toHaveBeenCalledTimes(1);
183 expect(state.notification).toHaveBeenCalledTimes(1);
184 expect(state.notification).toHaveBeenCalledWith(title, {
185 body: "Fix the login form",
186 tag: "env-1:thread-1",
187 silent: true,
188 });
189 });
190
191 it("keeps desktop alerts when in-app notifications are disabled", async () => {
192 state.inApp = false;
193 state.mode = "notifications";
194 await render();
195 await complete();
196 expect(state.add).not.toHaveBeenCalled();
197 expect(state.notification).toHaveBeenCalledTimes(1);
198 state.inApp = true;
199 await render();
200 expect(state.add).not.toHaveBeenCalled();
201 });
202
203 it("does not replay a completion when opting in from all alerts off", async () => {
204 state.inApp = false;
205 await render();
206 await complete();
207 state.inApp = true;
208 await render();
209 expect(state.add).not.toHaveBeenCalled();
210 });
211
212 it("compares the environment as well as the thread", async () => {
213 state.active = { environmentId: "env-2", threadId: "thread-1" };
214 await render();
215 await complete();
216 expect(state.add).toHaveBeenCalledTimes(1);
217 });
218
219 it("does not replay completed threads on first load or reconnect", async () => {
220 await complete();
221 state.live = false;
222 await render();
223 state.live = true;
224 await render();
225 expect(state.add).not.toHaveBeenCalled();
226 });
227
228 it("keeps sound but replaces the system popup when showing a toast", async () => {
229 state.mode = "notifications-and-sound";
230 await render();
231 await complete();
232 expect(state.sound).toHaveBeenCalledWith("completion", expect.any(Function));
233 expect(state.add).toHaveBeenCalledTimes(1);
234 expect(state.notification).not.toHaveBeenCalled();
235 });
236
237 it("keeps system alerts when the app is in the background", async () => {
238 state.mode = "notifications";
239 state.focused = false;
240 await render();
241 await complete();
242 expect(state.add).not.toHaveBeenCalled();
243 expect(state.notification).toHaveBeenCalledWith("Thread completed", {
244 body: "Fix the login form",
245 tag: "env-1:thread-1",
246 silent: true,
247 });
248 });
249});
apps/web/src/components/ThreadNotificationCoordinator.tsx+69-19
......@@ -1,5 +1,5 @@
11import { useAtomValue } from "@effect/atom-react";
2import { useNavigate } from "@tanstack/react-router";
2import { useNavigate, useParams } from "@tanstack/react-router";
33import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
44import * as Option from "effect/Option";
55import { useEffect, useRef } from "react";
......@@ -14,10 +14,14 @@ import {
1414 unlockNotificationAudio,
1515} from "../threadNotifications";
1616import { resolveSidebarThreadStatus } from "./Sidebar.logic";
17import { toastManager } from "./ui/toast";
1718
1819export function ThreadNotificationCoordinator() {
1920 const { environments } = useEnvironments();
2021 const mode = useClientSettings((settings) => settings.notificationMode);
22 const inAppNotificationsEnabled = useClientSettings(
23 (settings) => settings.inAppNotificationsEnabled,
24 );
2125
2226 useEffect(() => {
2327 if (!hasNotificationSound(mode)) return;
......@@ -29,7 +33,7 @@ export function ThreadNotificationCoordinator() {
2933 };
3034 }, [mode]);
3135
32 if (mode === "off") return null;
36 if (mode === "off" && !inAppNotificationsEnabled) return null;
3337
3438 return environments.map((environment) => (
3539 <EnvironmentNotifications
......@@ -42,20 +46,29 @@ export function ThreadNotificationCoordinator() {
4246function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) {
4347 const shell = useAtomValue(environmentShell.stateValueAtom(environmentId));
4448 const mode = useClientSettings((settings) => settings.notificationMode);
49 const inAppNotificationsEnabled = useClientSettings(
50 (settings) => settings.inAppNotificationsEnabled,
51 );
4552 const navigate = useNavigate();
46 const previous = useRef(new Map<ThreadId, { input: string | null; completion: number | null }>());
53 const { environmentId: activeEnvironmentId, threadId: activeThreadId } = useParams({
54 strict: false,
55 });
56 const previous = useRef(
57 new Map<ThreadId, { attention: string | null; completion: number | null }>(),
58 );
4759
4860 useEffect(() => {
4961 if (shell.status !== "live" || Option.isNone(shell.snapshot)) {
5062 previous.current.clear();
5163 return;
5264 }
53 const next = new Map<ThreadId, { input: string | null; completion: number | null }>();
65 const next = new Map<ThreadId, { attention: string | null; completion: number | null }>();
5466 for (const thread of shell.snapshot.value.threads) {
55 const status = resolveSidebarThreadStatus(thread);
67 let status = resolveSidebarThreadStatus(thread);
68 if (status === "ready" && thread.latestTurn?.state === "error") status = "failed";
5669 const prior = previous.current.get(thread.id);
57 const input =
58 status === "input" || status === "approval"
70 const attention =
71 status === "input" || status === "approval" || status === "failed"
5972 ? `${thread.latestTurn?.turnId ?? ""}:${status}`
6073 : null;
6174 const completedAt = Date.parse(thread.latestTurn?.completedAt ?? "");
......@@ -65,20 +78,52 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen
6578 Number.isFinite(completedAt)
6679 ? completedAt
6780 : (prior?.completion ?? null);
68 next.set(thread.id, { input, completion });
69 if (!prior || mode === "off" || thread.archivedAt !== null) continue;
81 next.set(thread.id, { attention, completion });
82 if (!prior || thread.archivedAt !== null) continue;
7083 const kind =
71 input && input !== prior.input
84 attention && attention !== prior.attention
7285 ? "input"
7386 : completion !== null && (prior.completion === null || completion > prior.completion)
7487 ? "completion"
7588 : null;
7689 if (!kind) continue;
90 const title =
91 kind === "completion"
92 ? "Thread completed"
93 : status === "approval"
94 ? "Approval needed"
95 : status === "failed"
96 ? "Thread failed"
97 : "Input needed";
7798 if (hasNotificationSound(mode)) {
7899 void playNotificationSound(kind, () =>
79100 hasNotificationSound(getClientSettings().notificationMode),
80101 );
81102 }
103 if (
104 inAppNotificationsEnabled &&
105 document.visibilityState === "visible" &&
106 document.hasFocus() &&
107 (activeEnvironmentId !== environmentId || activeThreadId !== thread.id)
108 ) {
109 const toastId = toastManager.add({
110 type: kind === "completion" ? "success" : status === "failed" ? "error" : "warning",
111 title,
112 description: thread.title,
113 data: { hideCopyButton: true },
114 actionProps: {
115 children: "Open thread",
116 onClick: () => {
117 toastManager.close(toastId);
118 void navigate({
119 to: "/$environmentId/$threadId",
120 params: { environmentId, threadId: thread.id },
121 });
122 },
123 },
124 });
125 continue;
126 }
82127 if (
83128 !hasDesktopNotifications(mode) ||
84129 typeof Notification === "undefined" ||
......@@ -86,14 +131,11 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen
86131 )
87132 continue;
88133 try {
89 const notification = new Notification(
90 kind === "completion"
91 ? "Thread completed"
92 : status === "approval"
93 ? "Approval needed"
94 : "Input needed",
95 { body: thread.title, tag: `${environmentId}:${thread.id}`, silent: true },
96 );
134 const notification = new Notification(title, {
135 body: thread.title,
136 tag: `${environmentId}:${thread.id}`,
137 silent: true,
138 });
97139 notification.addEventListener("click", () => {
98140 notification.close();
99141 window.focus();
......@@ -107,7 +149,15 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen
107149 }
108150 }
109151 previous.current = next;
110 }, [environmentId, mode, navigate, shell]);
152 }, [
153 activeEnvironmentId,
154 activeThreadId,
155 environmentId,
156 inAppNotificationsEnabled,
157 mode,
158 navigate,
159 shell,
160 ]);
111161
112162 return null;
113163}
apps/web/src/components/settings/NotificationSettings.tsx+1-1
......@@ -22,7 +22,7 @@ export function NotificationSettings() {
2222 {...searchableSetting("thread-notifications")}
2323 description={
2424 permissionMessage ??
25 "Alert when a thread finishes or needs input. Applies to this device while T3 Code is open."
25 "System alerts when a thread finishes, fails, or needs input or approval. Applies to this device while T3 Code is open."
2626 }
2727 control={
2828 <Select
apps/web/src/components/settings/SettingsPanels.tsx+16
......@@ -525,6 +525,9 @@ export function useSettingsRestore(onRestored?: () => void) {
525525 ...(settings.notificationMode !== DEFAULT_UNIFIED_SETTINGS.notificationMode
526526 ? ["Thread notifications"]
527527 : []),
528 ...(settings.inAppNotificationsEnabled !== DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled
529 ? ["In-app notifications"]
530 : []),
528531 ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount
529532 ? ["Visible threads"]
530533 : []),
......@@ -646,6 +649,7 @@ export function useSettingsRestore(onRestored?: () => void) {
646649 settings.showSkillsInSlashMenu,
647650 settings.timestampFormat,
648651 settings.notificationMode,
652 settings.inAppNotificationsEnabled,
649653 settings.wordWrap,
650654 followSystem,
651655 theme,
......@@ -721,6 +725,7 @@ export function useSettingsRestore(onRestored?: () => void) {
721725 diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
722726 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
723727 notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
728 inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled,
724729 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
725730 diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed,
726731 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
......@@ -2277,6 +2282,17 @@ export function GeneralSettingsPanel() {
22772282
22782283 <SettingsSection id="behavior" title="Behavior">
22792284 <NotificationSettings />
2285 <SettingsRow
2286 {...searchableSetting("in-app-notifications")}
2287 description="Show a toast when another thread finishes, fails, or needs input or approval while this app has focus."
2288 control={
2289 <Switch
2290 checked={settings.inAppNotificationsEnabled}
2291 onCheckedChange={(checked) => updateSettings({ inAppNotificationsEnabled: checked })}
2292 aria-label="In-app notifications"
2293 />
2294 }
2295 />
22802296 <SettingsRow
22812297 {...searchableSetting("time-format")}
22822298 description="System default follows your browser or OS clock preference."
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -247,6 +247,12 @@ export const SETTINGS_SEARCH_ITEMS = [
247247 to: "/settings/general",
248248 searchTerms: ["notification sound alert completion input approval desktop"],
249249 },
250 {
251 id: "in-app-notifications",
252 title: "In-app notifications",
253 to: "/settings/general",
254 searchTerms: ["notification toast popup completion input approval failure"],
255 },
250256 {
251257 id: "time-format",
252258 title: "Time format",
packages/contracts/src/settings.test.ts+23
......@@ -167,9 +167,32 @@ describe("ClaudeSettings auto-compaction", () => {
167167describe("ClientSettings notifications", () => {
168168 it("requires opt-in when existing settings omit notification preferences", () => {
169169 expect(decodeClientSettings({}).notificationMode).toBe("off");
170 expect(decodeClientSettings({}).inAppNotificationsEnabled).toBe(false);
171 expect(decodeClientSettingsPatch({})).not.toHaveProperty("inAppNotificationsEnabled");
170172 expect(decodeClientSettingsPatch({})).not.toHaveProperty("notificationMode");
171173 });
172174
175 it.each([true, false])(
176 "round-trips in-app notifications set to %s",
177 (inAppNotificationsEnabled) => {
178 const settings = decodeClientSettings({ inAppNotificationsEnabled });
179 expect(encodeClientSettings(settings).inAppNotificationsEnabled).toBe(
180 inAppNotificationsEnabled,
181 );
182 expect(
183 decodeClientSettingsPatch({ inAppNotificationsEnabled }).inAppNotificationsEnabled,
184 ).toBe(inAppNotificationsEnabled);
185 },
186 );
187
188 it.each(["true", 1, null])(
189 "rejects an invalid in-app notification preference %s",
190 (inAppNotificationsEnabled) => {
191 expect(() => decodeClientSettings({ inAppNotificationsEnabled })).toThrow();
192 expect(() => decodeClientSettingsPatch({ inAppNotificationsEnabled })).toThrow();
193 },
194 );
195
173196 it.each(["off", "notifications", "sound", "notifications-and-sound"])(
174197 "round-trips the %s mode",
175198 (notificationMode) => {
packages/contracts/src/settings.ts+2
......@@ -291,6 +291,7 @@ export const ClientSettingsSchema = Schema.Struct({
291291 notificationMode: NotificationMode.pipe(
292292 Schema.withDecodingDefault(Effect.succeed("off" as const)),
293293 ),
294 inAppNotificationsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
294295 diffColorScheme: DiffColorScheme.pipe(
295296 Schema.withDecodingDefault(Effect.succeed("red-green" as const)),
296297 ),
......@@ -1429,6 +1430,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
14291430
14301431export const ClientSettingsPatch = Schema.Struct({
14311432 notificationMode: Schema.optionalKey(NotificationMode),
1433 inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean),
14321434 diffColorScheme: Schema.optionalKey(DiffColorScheme),
14331435 loadBalancingEnabled: Schema.optionalKey(Schema.Boolean),
14341436 loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),