diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index f6622049ab49..5c1e01cb1471 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -18,6 +18,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts";
const clientSettings: ClientSettings = {
...DEFAULT_CLIENT_SETTINGS,
+ notificationMode: "notifications-and-sound",
appearanceContrast: 100,
browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
browserDefaultZoomFactor: 1.25,
diff --git a/apps/web/src/assets/notification-completion.mp3 b/apps/web/src/assets/notification-completion.mp3
new file mode 100644
index 000000000000..1955f5eb05f1
Binary files /dev/null and b/apps/web/src/assets/notification-completion.mp3 differ
diff --git a/apps/web/src/assets/notification-input.mp3 b/apps/web/src/assets/notification-input.mp3
new file mode 100644
index 000000000000..07e10da3bd57
Binary files /dev/null and b/apps/web/src/assets/notification-input.mp3 differ
diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx
new file mode 100644
index 000000000000..e89175a77808
--- /dev/null
+++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx
@@ -0,0 +1,113 @@
+import { useAtomValue } from "@effect/atom-react";
+import { useNavigate } from "@tanstack/react-router";
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { useEffect, useRef } from "react";
+
+import { getClientSettings, useClientSettings } from "../hooks/useSettings";
+import { useEnvironments } from "../state/environments";
+import { environmentShell } from "../state/shell";
+import {
+ hasDesktopNotifications,
+ hasNotificationSound,
+ playNotificationSound,
+ unlockNotificationAudio,
+} from "../threadNotifications";
+import { resolveSidebarThreadStatus } from "./Sidebar.logic";
+
+export function ThreadNotificationCoordinator() {
+ const { environments } = useEnvironments();
+ const mode = useClientSettings((settings) => settings.notificationMode);
+
+ useEffect(() => {
+ if (!hasNotificationSound(mode)) return;
+ document.addEventListener("pointerdown", unlockNotificationAudio);
+ document.addEventListener("keydown", unlockNotificationAudio);
+ return () => {
+ document.removeEventListener("pointerdown", unlockNotificationAudio);
+ document.removeEventListener("keydown", unlockNotificationAudio);
+ };
+ }, [mode]);
+
+ if (mode === "off") return null;
+
+ return environments.map((environment) => (
+
+ ));
+}
+
+function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) {
+ const shell = useAtomValue(environmentShell.stateValueAtom(environmentId));
+ const mode = useClientSettings((settings) => settings.notificationMode);
+ const navigate = useNavigate();
+ const previous = useRef(new Map());
+
+ useEffect(() => {
+ if (shell.status !== "live" || Option.isNone(shell.snapshot)) {
+ previous.current.clear();
+ return;
+ }
+ const next = new Map();
+ for (const thread of shell.snapshot.value.threads) {
+ const status = resolveSidebarThreadStatus(thread);
+ const prior = previous.current.get(thread.id);
+ const input =
+ status === "input" || status === "approval"
+ ? `${thread.latestTurn?.turnId ?? ""}:${status}`
+ : null;
+ const completedAt = Date.parse(thread.latestTurn?.completedAt ?? "");
+ const completion =
+ status === "ready" &&
+ thread.latestTurn?.state === "completed" &&
+ Number.isFinite(completedAt)
+ ? completedAt
+ : (prior?.completion ?? null);
+ next.set(thread.id, { input, completion });
+ if (!prior || mode === "off" || thread.archivedAt !== null) continue;
+ const kind =
+ input && input !== prior.input
+ ? "input"
+ : completion !== null && (prior.completion === null || completion > prior.completion)
+ ? "completion"
+ : null;
+ if (!kind) continue;
+ if (hasNotificationSound(mode)) {
+ void playNotificationSound(kind, () =>
+ hasNotificationSound(getClientSettings().notificationMode),
+ );
+ }
+ if (
+ !hasDesktopNotifications(mode) ||
+ typeof Notification === "undefined" ||
+ Notification.permission !== "granted"
+ )
+ continue;
+ try {
+ const notification = new Notification(
+ kind === "completion"
+ ? "Thread completed"
+ : status === "approval"
+ ? "Approval needed"
+ : "Input needed",
+ { body: thread.title, tag: `${environmentId}:${thread.id}`, silent: true },
+ );
+ notification.addEventListener("click", () => {
+ notification.close();
+ window.focus();
+ void navigate({
+ to: "/$environmentId/$threadId",
+ params: { environmentId, threadId: thread.id },
+ });
+ });
+ } catch {
+ // Some browsers expose Notification but reject desktop presentation.
+ }
+ }
+ previous.current = next;
+ }, [environmentId, mode, navigate, shell]);
+
+ return null;
+}
diff --git a/apps/web/src/components/settings/NotificationSettings.tsx b/apps/web/src/components/settings/NotificationSettings.tsx
new file mode 100644
index 000000000000..22b4d2912302
--- /dev/null
+++ b/apps/web/src/components/settings/NotificationSettings.tsx
@@ -0,0 +1,83 @@
+import { useState } from "react";
+
+import {
+ hasDesktopNotifications,
+ hasNotificationSound,
+ NOTIFICATION_MODE_LABELS,
+ unlockNotificationAudio,
+} from "../../threadNotifications";
+import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
+import { SettingsRow } from "./settingsLayout";
+import { searchableSetting } from "./settingsSearch";
+import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
+
+export function NotificationSettings() {
+ const mode = useScopedSettings((settings) => settings.notificationMode);
+ const updateSettings = useUpdateScopedSettings();
+ const [permissionMessage, setPermissionMessage] = useState(null);
+ const [requesting, setRequesting] = useState(false);
+
+ return (
+ {
+ if (
+ value !== "off" &&
+ value !== "notifications" &&
+ value !== "sound" &&
+ value !== "notifications-and-sound"
+ )
+ return;
+ setPermissionMessage(null);
+ if (hasNotificationSound(value)) unlockNotificationAudio();
+ if (hasDesktopNotifications(value)) {
+ if (typeof Notification === "undefined" || !window.isSecureContext) {
+ setPermissionMessage(
+ "Notifications need a supported browser over HTTPS, or the desktop app. Sound only is still available.",
+ );
+ return;
+ }
+ setRequesting(true);
+ try {
+ const permission = await Notification.requestPermission();
+ if (permission !== "granted") {
+ setPermissionMessage(
+ "Allow notifications in your browser or system settings, then choose this option again. Sound only is still available.",
+ );
+ return;
+ }
+ } catch {
+ setPermissionMessage(
+ "Notifications are unavailable in this browser. Sound only is still available.",
+ );
+ return;
+ } finally {
+ setRequesting(false);
+ }
+ }
+ updateSettings({ notificationMode: value });
+ }}
+ >
+
+ {NOTIFICATION_MODE_LABELS[mode]}
+
+
+ {Object.entries(NOTIFICATION_MODE_LABELS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+ }
+ />
+ );
+}
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 98684f50721b..c9700496f420 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -1,4 +1,5 @@
import { Spinner } from "~/components/ui/spinner";
+import { NotificationSettings } from "./NotificationSettings";
import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react";
import { Link, useNavigate } from "@tanstack/react-router";
import type { CSSProperties, ReactNode } from "react";
@@ -517,6 +518,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
? ["Time format"]
: []),
+ ...(settings.notificationMode !== DEFAULT_UNIFIED_SETTINGS.notificationMode
+ ? ["Thread notifications"]
+ : []),
...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount
? ["Visible threads"]
: []),
@@ -636,6 +640,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.sidebarThreadPreviewCount,
settings.showSkillsInSlashMenu,
settings.timestampFormat,
+ settings.notificationMode,
settings.wordWrap,
followSystem,
theme,
@@ -709,6 +714,7 @@ export function useSettingsRestore(onRestored?: () => void) {
appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
+ notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed,
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
@@ -2243,6 +2249,7 @@ export function GeneralSettingsPanel() {
+
+
diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts
new file mode 100644
index 000000000000..8050411382aa
--- /dev/null
+++ b/apps/web/src/threadNotifications.ts
@@ -0,0 +1,55 @@
+import type { ClientSettings } from "@t3tools/contracts/settings";
+
+import completionUrl from "./assets/notification-completion.mp3";
+import inputUrl from "./assets/notification-input.mp3";
+
+type NotificationMode = ClientSettings["notificationMode"];
+export const NOTIFICATION_MODE_LABELS = {
+ off: "Off",
+ notifications: "Notifications only",
+ sound: "Sound only",
+ "notifications-and-sound": "Notifications with sound",
+} satisfies Record;
+
+export function hasNotificationSound(mode: NotificationMode) {
+ return mode === "sound" || mode === "notifications-and-sound";
+}
+
+export function hasDesktopNotifications(mode: NotificationMode) {
+ return mode === "notifications" || mode === "notifications-and-sound";
+}
+
+let audioContext: AudioContext | undefined;
+const buffers = new Map>();
+
+/** Called from a gesture so browsers allow later background playback. */
+export function unlockNotificationAudio() {
+ audioContext ??= new AudioContext();
+ void audioContext.resume().catch(() => undefined);
+}
+
+export async function playNotificationSound(
+ kind: "completion" | "input",
+ shouldPlay: () => boolean,
+) {
+ if (!audioContext || audioContext.state !== "running") return;
+ const context = audioContext;
+ const url = kind === "completion" ? completionUrl : inputUrl;
+ try {
+ let buffer = buffers.get(url);
+ if (!buffer) {
+ buffer = fetch(url)
+ .then((response) => response.arrayBuffer())
+ .then((data) => context.decodeAudioData(data));
+ buffers.set(url, buffer);
+ }
+ const decoded = await buffer;
+ if (!shouldPlay() || context.state !== "running") return;
+ const source = context.createBufferSource();
+ source.buffer = decoded;
+ source.connect(context.destination);
+ source.start();
+ } catch {
+ buffers.delete(url);
+ }
+}
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 48f147be051e..7bb2e1da5c0b 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -164,6 +164,32 @@ describe("ClaudeSettings auto-compaction", () => {
});
});
+describe("ClientSettings notifications", () => {
+ it("requires opt-in when existing settings omit notification preferences", () => {
+ expect(decodeClientSettings({}).notificationMode).toBe("off");
+ expect(decodeClientSettingsPatch({})).not.toHaveProperty("notificationMode");
+ });
+
+ it.each(["off", "notifications", "sound", "notifications-and-sound"])(
+ "round-trips the %s mode",
+ (notificationMode) => {
+ const settings = decodeClientSettings({ notificationMode });
+ expect(encodeClientSettings(settings).notificationMode).toBe(notificationMode);
+ expect(decodeClientSettingsPatch({ notificationMode }).notificationMode).toBe(
+ notificationMode,
+ );
+ },
+ );
+
+ it.each(["always", true, null])(
+ "rejects unsupported notification mode %s",
+ (notificationMode) => {
+ expect(() => decodeClientSettings({ notificationMode })).toThrow();
+ expect(() => decodeClientSettingsPatch({ notificationMode })).toThrow();
+ },
+ );
+});
+
describe("ClientSettings default diff file state", () => {
it("keeps files expanded when existing settings omit the preference", () => {
expect(decodeClientSettings({}).diffFilesCollapsed).toBe(false);
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index b420b737ee0a..004a53b320e8 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -214,6 +214,14 @@ const DEFAULT_SNAP_SHOT_SHORTCUT: SnapShotShortcut = {
kind: "both-shift-keys",
};
+export const NotificationMode = Schema.Literals([
+ "off",
+ "notifications",
+ "sound",
+ "notifications-and-sound",
+]);
+export type NotificationMode = typeof NotificationMode.Type;
+
export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]);
export type QuitConfirmationMode = typeof QuitConfirmationMode.Type;
const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold";
@@ -280,6 +288,9 @@ export const LoadBalancingWeights = Schema.Record(
export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]);
export const ClientSettingsSchema = Schema.Struct({
+ notificationMode: NotificationMode.pipe(
+ Schema.withDecodingDefault(Effect.succeed("off" as const)),
+ ),
diffColorScheme: DiffColorScheme.pipe(
Schema.withDecodingDefault(Effect.succeed("red-green" as const)),
),
@@ -1416,6 +1427,7 @@ export const ServerSettingsPatch = Schema.Struct({
export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
export const ClientSettingsPatch = Schema.Struct({
+ notificationMode: Schema.optionalKey(NotificationMode),
diffColorScheme: Schema.optionalKey(DiffColorScheme),
loadBalancingEnabled: Schema.optionalKey(Schema.Boolean),
loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),
diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json
index 8670eaf64ad5..01955554adf6 100644
--- a/third-party-licenses.config.json
+++ b/third-party-licenses.config.json
@@ -1,5 +1,33 @@
{
"customNotices": [
+ {
+ "bundles": ["assets", "desktop", "web"],
+ "license": "CC0-1.0",
+ "name": "Notification Sound 1 by deadrobotmusic",
+ "sourceUrl": "https://freesound.org/people/deadrobotmusic/sounds/750607/",
+ "generatedNotices": [
+ {
+ "licenseId": "CC0-1.0",
+ "preamble": [
+ "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."
+ ]
+ }
+ ]
+ },
+ {
+ "bundles": ["assets", "desktop", "web"],
+ "license": "CC0-1.0",
+ "name": "Notification Sound 3 by deadrobotmusic",
+ "sourceUrl": "https://freesound.org/people/deadrobotmusic/sounds/750609/",
+ "generatedNotices": [
+ {
+ "licenseId": "CC0-1.0",
+ "preamble": [
+ "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."
+ ]
+ }
+ ]
+ },
{
"bundles": ["assets", "desktop", "web"],
"license": "CC0-1.0",