authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-12 22:36:24-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 22:36:24-03:00
log0e0ddaeedf30698bec131caf040a8e8d7b2e3f37
tree957de2fc8b0c96e0711623d2d79caa66dc409c1b
parentaf0657e3b36d8fc9d2652e83e6e9032272629631
signature Signed by PGP key B5690EEEBB952194

feat(web): add opt-in thread notifications and sounds (#11481)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

12 files changed, 333 insertions(+), 0 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
......@@ -18,6 +18,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts";
1818
1919const clientSettings: ClientSettings = {
2020 ...DEFAULT_CLIENT_SETTINGS,
21 notificationMode: "notifications-and-sound",
2122 appearanceContrast: 100,
2223 browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
2324 browserDefaultZoomFactor: 1.25,
apps/web/src/assets/notification-completion.mp3 created
Binary files /dev/null and b/apps/web/src/assets/notification-completion.mp3 differ
apps/web/src/assets/notification-input.mp3 created
Binary files /dev/null and b/apps/web/src/assets/notification-input.mp3 differ
apps/web/src/components/ThreadNotificationCoordinator.tsx created+113
......@@ -0,0 +1,113 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useNavigate } from "@tanstack/react-router";
3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import * as Option from "effect/Option";
5import { useEffect, useRef } from "react";
6
7import { getClientSettings, useClientSettings } from "../hooks/useSettings";
8import { useEnvironments } from "../state/environments";
9import { environmentShell } from "../state/shell";
10import {
11 hasDesktopNotifications,
12 hasNotificationSound,
13 playNotificationSound,
14 unlockNotificationAudio,
15} from "../threadNotifications";
16import { resolveSidebarThreadStatus } from "./Sidebar.logic";
17
18export function ThreadNotificationCoordinator() {
19 const { environments } = useEnvironments();
20 const mode = useClientSettings((settings) => settings.notificationMode);
21
22 useEffect(() => {
23 if (!hasNotificationSound(mode)) return;
24 document.addEventListener("pointerdown", unlockNotificationAudio);
25 document.addEventListener("keydown", unlockNotificationAudio);
26 return () => {
27 document.removeEventListener("pointerdown", unlockNotificationAudio);
28 document.removeEventListener("keydown", unlockNotificationAudio);
29 };
30 }, [mode]);
31
32 if (mode === "off") return null;
33
34 return environments.map((environment) => (
35 <EnvironmentNotifications
36 key={environment.environmentId}
37 environmentId={environment.environmentId}
38 />
39 ));
40}
41
42function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) {
43 const shell = useAtomValue(environmentShell.stateValueAtom(environmentId));
44 const mode = useClientSettings((settings) => settings.notificationMode);
45 const navigate = useNavigate();
46 const previous = useRef(new Map<ThreadId, { input: string | null; completion: number | null }>());
47
48 useEffect(() => {
49 if (shell.status !== "live" || Option.isNone(shell.snapshot)) {
50 previous.current.clear();
51 return;
52 }
53 const next = new Map<ThreadId, { input: string | null; completion: number | null }>();
54 for (const thread of shell.snapshot.value.threads) {
55 const status = resolveSidebarThreadStatus(thread);
56 const prior = previous.current.get(thread.id);
57 const input =
58 status === "input" || status === "approval"
59 ? `${thread.latestTurn?.turnId ?? ""}:${status}`
60 : null;
61 const completedAt = Date.parse(thread.latestTurn?.completedAt ?? "");
62 const completion =
63 status === "ready" &&
64 thread.latestTurn?.state === "completed" &&
65 Number.isFinite(completedAt)
66 ? completedAt
67 : (prior?.completion ?? null);
68 next.set(thread.id, { input, completion });
69 if (!prior || mode === "off" || thread.archivedAt !== null) continue;
70 const kind =
71 input && input !== prior.input
72 ? "input"
73 : completion !== null && (prior.completion === null || completion > prior.completion)
74 ? "completion"
75 : null;
76 if (!kind) continue;
77 if (hasNotificationSound(mode)) {
78 void playNotificationSound(kind, () =>
79 hasNotificationSound(getClientSettings().notificationMode),
80 );
81 }
82 if (
83 !hasDesktopNotifications(mode) ||
84 typeof Notification === "undefined" ||
85 Notification.permission !== "granted"
86 )
87 continue;
88 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 );
97 notification.addEventListener("click", () => {
98 notification.close();
99 window.focus();
100 void navigate({
101 to: "/$environmentId/$threadId",
102 params: { environmentId, threadId: thread.id },
103 });
104 });
105 } catch {
106 // Some browsers expose Notification but reject desktop presentation.
107 }
108 }
109 previous.current = next;
110 }, [environmentId, mode, navigate, shell]);
111
112 return null;
113}
apps/web/src/components/settings/NotificationSettings.tsx created+83
......@@ -0,0 +1,83 @@
1import { useState } from "react";
2
3import {
4 hasDesktopNotifications,
5 hasNotificationSound,
6 NOTIFICATION_MODE_LABELS,
7 unlockNotificationAudio,
8} from "../../threadNotifications";
9import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
10import { SettingsRow } from "./settingsLayout";
11import { searchableSetting } from "./settingsSearch";
12import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
13
14export function NotificationSettings() {
15 const mode = useScopedSettings((settings) => settings.notificationMode);
16 const updateSettings = useUpdateScopedSettings();
17 const [permissionMessage, setPermissionMessage] = useState<string | null>(null);
18 const [requesting, setRequesting] = useState(false);
19
20 return (
21 <SettingsRow
22 {...searchableSetting("thread-notifications")}
23 description={
24 permissionMessage ??
25 "Alert when a thread finishes or needs input. Applies to this device while T3 Code is open."
26 }
27 control={
28 <Select
29 value={mode}
30 disabled={requesting}
31 onValueChange={async (value) => {
32 if (
33 value !== "off" &&
34 value !== "notifications" &&
35 value !== "sound" &&
36 value !== "notifications-and-sound"
37 )
38 return;
39 setPermissionMessage(null);
40 if (hasNotificationSound(value)) unlockNotificationAudio();
41 if (hasDesktopNotifications(value)) {
42 if (typeof Notification === "undefined" || !window.isSecureContext) {
43 setPermissionMessage(
44 "Notifications need a supported browser over HTTPS, or the desktop app. Sound only is still available.",
45 );
46 return;
47 }
48 setRequesting(true);
49 try {
50 const permission = await Notification.requestPermission();
51 if (permission !== "granted") {
52 setPermissionMessage(
53 "Allow notifications in your browser or system settings, then choose this option again. Sound only is still available.",
54 );
55 return;
56 }
57 } catch {
58 setPermissionMessage(
59 "Notifications are unavailable in this browser. Sound only is still available.",
60 );
61 return;
62 } finally {
63 setRequesting(false);
64 }
65 }
66 updateSettings({ notificationMode: value });
67 }}
68 >
69 <SelectTrigger size="sm" className="w-full sm:w-56" aria-label="Thread notifications">
70 <SelectValue>{NOTIFICATION_MODE_LABELS[mode]}</SelectValue>
71 </SelectTrigger>
72 <SelectPopup align="end" alignItemWithTrigger={false}>
73 {Object.entries(NOTIFICATION_MODE_LABELS).map(([value, label]) => (
74 <SelectItem key={value} hideIndicator value={value}>
75 {label}
76 </SelectItem>
77 ))}
78 </SelectPopup>
79 </Select>
80 }
81 />
82 );
83}
apps/web/src/components/settings/SettingsPanels.tsx+7
......@@ -1,4 +1,5 @@
11import { Spinner } from "~/components/ui/spinner";
2import { NotificationSettings } from "./NotificationSettings";
23import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react";
34import { Link, useNavigate } from "@tanstack/react-router";
45import type { CSSProperties, ReactNode } from "react";
......@@ -517,6 +518,9 @@ export function useSettingsRestore(onRestored?: () => void) {
517518 ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
518519 ? ["Time format"]
519520 : []),
521 ...(settings.notificationMode !== DEFAULT_UNIFIED_SETTINGS.notificationMode
522 ? ["Thread notifications"]
523 : []),
520524 ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount
521525 ? ["Visible threads"]
522526 : []),
......@@ -636,6 +640,7 @@ export function useSettingsRestore(onRestored?: () => void) {
636640 settings.sidebarThreadPreviewCount,
637641 settings.showSkillsInSlashMenu,
638642 settings.timestampFormat,
643 settings.notificationMode,
639644 settings.wordWrap,
640645 followSystem,
641646 theme,
......@@ -709,6 +714,7 @@ export function useSettingsRestore(onRestored?: () => void) {
709714 appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
710715 diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
711716 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
717 notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
712718 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
713719 diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed,
714720 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
......@@ -2243,6 +2249,7 @@ export function GeneralSettingsPanel() {
22432249 </SettingsSection>
22442250
22452251 <SettingsSection id="behavior" title="Behavior">
2252 <NotificationSettings />
22462253 <SettingsRow
22472254 {...searchableSetting("time-format")}
22482255 description="System default follows your browser or OS clock preference."
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -235,6 +235,12 @@ export const SETTINGS_SEARCH_ITEMS = [
235235 requiresThreadAutoSettlement: true,
236236 scope: "project-defaults",
237237 },
238 {
239 id: "thread-notifications",
240 title: "Thread notifications",
241 to: "/settings/general",
242 searchTerms: ["notification sound alert completion input approval desktop"],
243 },
238244 {
239245 id: "time-format",
240246 title: "Time format",
apps/web/src/routes/__root.tsx+2
......@@ -24,6 +24,7 @@ import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPrompt
2424import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator";
2525import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";
2626import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
27import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";
2728import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
2829import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
2930import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
......@@ -199,6 +200,7 @@ function RootRouteView() {
199200 <ConnectOnboardingDialog />
200201 <SshPasswordPromptDialog />
201202 <SnapShotCoordinator />
203 <ThreadNotificationCoordinator />
202204 <ConfirmDialogHost />
203205 <SlowRpcRequestToastCoordinator />
204206 <HostedStaticEnvironmentBootstrap />
apps/web/src/threadNotifications.ts created+55
......@@ -0,0 +1,55 @@
1import type { ClientSettings } from "@t3tools/contracts/settings";
2
3import completionUrl from "./assets/notification-completion.mp3";
4import inputUrl from "./assets/notification-input.mp3";
5
6type NotificationMode = ClientSettings["notificationMode"];
7export const NOTIFICATION_MODE_LABELS = {
8 off: "Off",
9 notifications: "Notifications only",
10 sound: "Sound only",
11 "notifications-and-sound": "Notifications with sound",
12} satisfies Record<NotificationMode, string>;
13
14export function hasNotificationSound(mode: NotificationMode) {
15 return mode === "sound" || mode === "notifications-and-sound";
16}
17
18export function hasDesktopNotifications(mode: NotificationMode) {
19 return mode === "notifications" || mode === "notifications-and-sound";
20}
21
22let audioContext: AudioContext | undefined;
23const buffers = new Map<string, Promise<AudioBuffer>>();
24
25/** Called from a gesture so browsers allow later background playback. */
26export function unlockNotificationAudio() {
27 audioContext ??= new AudioContext();
28 void audioContext.resume().catch(() => undefined);
29}
30
31export async function playNotificationSound(
32 kind: "completion" | "input",
33 shouldPlay: () => boolean,
34) {
35 if (!audioContext || audioContext.state !== "running") return;
36 const context = audioContext;
37 const url = kind === "completion" ? completionUrl : inputUrl;
38 try {
39 let buffer = buffers.get(url);
40 if (!buffer) {
41 buffer = fetch(url)
42 .then((response) => response.arrayBuffer())
43 .then((data) => context.decodeAudioData(data));
44 buffers.set(url, buffer);
45 }
46 const decoded = await buffer;
47 if (!shouldPlay() || context.state !== "running") return;
48 const source = context.createBufferSource();
49 source.buffer = decoded;
50 source.connect(context.destination);
51 source.start();
52 } catch {
53 buffers.delete(url);
54 }
55}
packages/contracts/src/settings.test.ts+26
......@@ -164,6 +164,32 @@ describe("ClaudeSettings auto-compaction", () => {
164164 });
165165});
166166
167describe("ClientSettings notifications", () => {
168 it("requires opt-in when existing settings omit notification preferences", () => {
169 expect(decodeClientSettings({}).notificationMode).toBe("off");
170 expect(decodeClientSettingsPatch({})).not.toHaveProperty("notificationMode");
171 });
172
173 it.each(["off", "notifications", "sound", "notifications-and-sound"])(
174 "round-trips the %s mode",
175 (notificationMode) => {
176 const settings = decodeClientSettings({ notificationMode });
177 expect(encodeClientSettings(settings).notificationMode).toBe(notificationMode);
178 expect(decodeClientSettingsPatch({ notificationMode }).notificationMode).toBe(
179 notificationMode,
180 );
181 },
182 );
183
184 it.each(["always", true, null])(
185 "rejects unsupported notification mode %s",
186 (notificationMode) => {
187 expect(() => decodeClientSettings({ notificationMode })).toThrow();
188 expect(() => decodeClientSettingsPatch({ notificationMode })).toThrow();
189 },
190 );
191});
192
167193describe("ClientSettings default diff file state", () => {
168194 it("keeps files expanded when existing settings omit the preference", () => {
169195 expect(decodeClientSettings({}).diffFilesCollapsed).toBe(false);
packages/contracts/src/settings.ts+12
......@@ -214,6 +214,14 @@ const DEFAULT_SNAP_SHOT_SHORTCUT: SnapShotShortcut = {
214214 kind: "both-shift-keys",
215215};
216216
217export const NotificationMode = Schema.Literals([
218 "off",
219 "notifications",
220 "sound",
221 "notifications-and-sound",
222]);
223export type NotificationMode = typeof NotificationMode.Type;
224
217225export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]);
218226export type QuitConfirmationMode = typeof QuitConfirmationMode.Type;
219227const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold";
......@@ -280,6 +288,9 @@ export const LoadBalancingWeights = Schema.Record(
280288export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]);
281289
282290export const ClientSettingsSchema = Schema.Struct({
291 notificationMode: NotificationMode.pipe(
292 Schema.withDecodingDefault(Effect.succeed("off" as const)),
293 ),
283294 diffColorScheme: DiffColorScheme.pipe(
284295 Schema.withDecodingDefault(Effect.succeed("red-green" as const)),
285296 ),
......@@ -1416,6 +1427,7 @@ export const ServerSettingsPatch = Schema.Struct({
14161427export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
14171428
14181429export const ClientSettingsPatch = Schema.Struct({
1430 notificationMode: Schema.optionalKey(NotificationMode),
14191431 diffColorScheme: Schema.optionalKey(DiffColorScheme),
14201432 loadBalancingEnabled: Schema.optionalKey(Schema.Boolean),
14211433 loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),
third-party-licenses.config.json+28
......@@ -1,5 +1,33 @@
11{
22 "customNotices": [
3 {
4 "bundles": ["assets", "desktop", "web"],
5 "license": "CC0-1.0",
6 "name": "Notification Sound 1 by deadrobotmusic",
7 "sourceUrl": "https://freesound.org/people/deadrobotmusic/sounds/750607/",
8 "generatedNotices": [
9 {
10 "licenseId": "CC0-1.0",
11 "preamble": [
12 "The bundled notification-completion.mp3 is derived from Notification Sound 1 by deadrobotmusic:\nhttps://freesound.org/people/deadrobotmusic/sounds/750607/\n\nThe original sound is dedicated to the public domain under CC0 1.0 Universal."
13 ]
14 }
15 ]
16 },
17 {
18 "bundles": ["assets", "desktop", "web"],
19 "license": "CC0-1.0",
20 "name": "Notification Sound 3 by deadrobotmusic",
21 "sourceUrl": "https://freesound.org/people/deadrobotmusic/sounds/750609/",
22 "generatedNotices": [
23 {
24 "licenseId": "CC0-1.0",
25 "preamble": [
26 "The bundled notification-input.mp3 is derived from Notification Sound 3 by deadrobotmusic:\nhttps://freesound.org/people/deadrobotmusic/sounds/750609/\n\nThe original sound is dedicated to the public domain under CC0 1.0 Universal."
27 ]
28 }
29 ]
30 },
331 {
432 "bundles": ["assets", "desktop", "web"],
533 "license": "CC0-1.0",