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",