diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 71b6b7df4e..79765c3c2e 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -54,6 +54,11 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, diffWordWrap: true, favorites: [], + notificationSoundEnabled: false, + notificationSoundOnTurnEnd: false, + notificationSoundOnApproval: false, + notificationSoundOnQuestion: false, + notificationSoundFocusRule: "unfocused-or-different-thread", providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { diff --git a/apps/web/public/sounds/notification.mp3 b/apps/web/public/sounds/notification.mp3 new file mode 100644 index 0000000000..59fec1ee94 Binary files /dev/null and b/apps/web/public/sounds/notification.mp3 differ diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index af904f562d..3b5f8cbe21 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -41,6 +41,7 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; +import { notificationSoundManager } from "../../notificationSound"; import { useShallow } from "zustand/react/shallow"; import { selectProjectsAcrossEnvironments, @@ -95,6 +96,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const NOTIFICATION_FOCUS_RULE_LABELS = { + always: "Always", + "unfocused-only": "Window not focused", + "unfocused-or-different-thread": "Window not focused or viewing a different thread", +} as const; + const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); function withoutProviderInstanceKey( @@ -404,6 +411,25 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), + ...(settings.notificationSoundEnabled !== DEFAULT_UNIFIED_SETTINGS.notificationSoundEnabled + ? ["Notification sound"] + : []), + ...(settings.notificationSoundOnTurnEnd !== + DEFAULT_UNIFIED_SETTINGS.notificationSoundOnTurnEnd + ? ["Notification on agent finish"] + : []), + ...(settings.notificationSoundOnApproval !== + DEFAULT_UNIFIED_SETTINGS.notificationSoundOnApproval + ? ["Notification on approval"] + : []), + ...(settings.notificationSoundOnQuestion !== + DEFAULT_UNIFIED_SETTINGS.notificationSoundOnQuestion + ? ["Notification on question"] + : []), + ...(settings.notificationSoundFocusRule !== + DEFAULT_UNIFIED_SETTINGS.notificationSoundFocusRule + ? ["Notification focus rule"] + : []), ...(isGitWritingModelDirty ? ["Git writing model"] : []), ...(areProviderSettingsDirty ? ["Providers"] : []), ], @@ -417,6 +443,11 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, + settings.notificationSoundEnabled, + settings.notificationSoundOnTurnEnd, + settings.notificationSoundOnApproval, + settings.notificationSoundOnQuestion, + settings.notificationSoundFocusRule, settings.timestampFormat, theme, ], @@ -1138,6 +1169,193 @@ export function GeneralSettingsPanel() { /> + + + updateSettings({ + notificationSoundEnabled: DEFAULT_UNIFIED_SETTINGS.notificationSoundEnabled, + }) + } + /> + ) : null + } + control={ + <> + + + updateSettings({ notificationSoundEnabled: Boolean(checked) }) + } + aria-label="Enable notification sound" + /> + + } + /> + + + updateSettings({ + notificationSoundOnTurnEnd: DEFAULT_UNIFIED_SETTINGS.notificationSoundOnTurnEnd, + }) + } + /> + ) : null + } + control={ + + updateSettings({ notificationSoundOnTurnEnd: Boolean(checked) }) + } + aria-label="Play sound when an agent finishes" + /> + } + /> + + + updateSettings({ + notificationSoundOnApproval: + DEFAULT_UNIFIED_SETTINGS.notificationSoundOnApproval, + }) + } + /> + ) : null + } + control={ + + updateSettings({ notificationSoundOnApproval: Boolean(checked) }) + } + aria-label="Play sound when an approval is requested" + /> + } + /> + + + updateSettings({ + notificationSoundOnQuestion: + DEFAULT_UNIFIED_SETTINGS.notificationSoundOnQuestion, + }) + } + /> + ) : null + } + control={ + + updateSettings({ notificationSoundOnQuestion: Boolean(checked) }) + } + aria-label="Play sound when a question is asked" + /> + } + /> + + + updateSettings({ + notificationSoundFocusRule: DEFAULT_UNIFIED_SETTINGS.notificationSoundFocusRule, + }) + } + /> + ) : null + } + control={ + + } + /> + + (); + for (const [threadId, summary] of Object.entries(environmentState.sidebarThreadSummaryById)) { + map.set(threadId as ThreadId, summaryToNotificationShell(summary)); + } + return map; +} + function applyRecoveredEventBatch( events: ReadonlyArray, environmentId: EnvironmentId, @@ -629,6 +658,8 @@ function applyRecoveredEventBatch( return; } + const previousNotificationShells = snapshotNotificationShells(environmentId); + const batchEffects = deriveOrchestrationBatchEffects(events); const uiEvents = coalesceOrchestrationUiEvents(events); const needsProjectUiSync = events.some( @@ -689,6 +720,16 @@ function applyRecoveredEventBatch( } reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); + + const nextNotificationShells = snapshotNotificationShells(environmentId); + const notificationTriggers = deriveNotificationTriggers( + previousNotificationShells, + nextNotificationShells, + events, + ); + if (notificationTriggers.length > 0) { + notificationSoundManager.maybePlay(notificationTriggers, getClientSettings()); + } } export function applyEnvironmentThreadDetailEvent( @@ -716,10 +757,24 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En : null; const threadRef = threadId ? scopeThreadRef(environmentId, threadId) : null; const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined; + const previousNotificationShells = + event.kind === "thread-upserted" ? snapshotNotificationShells(environmentId) : null; useStore.getState().applyShellEvent(event, environmentId); markAppliedProjectionEvent(environmentId, event.sequence); + if (event.kind === "thread-upserted" && previousNotificationShells !== null) { + const nextNotificationShells = snapshotNotificationShells(environmentId); + const notificationTriggers = deriveNotificationTriggers( + previousNotificationShells, + nextNotificationShells, + [], + ); + if (notificationTriggers.length > 0) { + notificationSoundManager.maybePlay(notificationTriggers, getClientSettings()); + } + } + switch (event.kind) { case "project-upserted": case "project-removed": diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index b627286199..894d82f5ca 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -547,6 +547,11 @@ describe("wsApi", () => { sidebarProjectSortOrder: "manual" as const, sidebarThreadSortOrder: "created_at" as const, timestampFormat: "24-hour" as const, + notificationSoundEnabled: false, + notificationSoundOnTurnEnd: false, + notificationSoundOnApproval: false, + notificationSoundOnQuestion: false, + notificationSoundFocusRule: "unfocused-or-different-thread" as const, }; const getClientSettings = vi.fn().mockResolvedValue({ ...clientSettings, @@ -607,6 +612,11 @@ describe("wsApi", () => { sidebarProjectSortOrder: "manual" as const, sidebarThreadSortOrder: "created_at" as const, timestampFormat: "24-hour" as const, + notificationSoundEnabled: false, + notificationSoundOnTurnEnd: false, + notificationSoundOnApproval: false, + notificationSoundOnQuestion: false, + notificationSoundFocusRule: "unfocused-or-different-thread" as const, }; await api.persistence.setClientSettings(clientSettings); diff --git a/apps/web/src/notificationSound.test.ts b/apps/web/src/notificationSound.test.ts new file mode 100644 index 0000000000..147df00c22 --- /dev/null +++ b/apps/web/src/notificationSound.test.ts @@ -0,0 +1,367 @@ +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + NOTIFICATION_THROTTLE_MS, + deriveNotificationTriggers, + shouldPlay, + type NotificationFocusContext, + type NotificationSettingsSlice, + type NotificationThreadShellLike, + type NotificationTrigger, + type ThreadShellMap, +} from "./notificationSound"; + +const THREAD_A = ThreadId.make("thread-a"); +const THREAD_B = ThreadId.make("thread-b"); + +function makeShell( + overrides: Partial = {}, +): NotificationThreadShellLike { + return { + archivedAt: null, + session: null, + hasPendingApprovals: false, + hasActionableProposedPlan: false, + hasPendingUserInput: false, + ...overrides, + }; +} + +function shellMap(entries: Array<[ThreadId, NotificationThreadShellLike]>): ThreadShellMap { + return new Map(entries); +} + +function makeSettings( + overrides: Partial = {}, +): NotificationSettingsSlice { + return { + notificationSoundEnabled: true, + notificationSoundOnTurnEnd: true, + notificationSoundOnApproval: true, + notificationSoundOnQuestion: true, + notificationSoundFocusRule: "always", + ...overrides, + }; +} + +function makeFocus(overrides: Partial = {}): NotificationFocusContext { + return { + documentVisible: true, + windowFocused: true, + currentThreadId: null, + ...overrides, + }; +} + +describe("deriveNotificationTriggers", () => { + it("fires turn-end on session running -> idle", () => { + const prev = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "running" } })]]); + const next = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "idle" } })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([ + { threadId: THREAD_A, kind: "turn-end" }, + ]); + }); + + it("fires turn-end on session running -> error", () => { + const prev = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "running" } })]]); + const next = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "error" } })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([ + { threadId: THREAD_A, kind: "turn-end" }, + ]); + }); + + it("fires turn-end on session running -> interrupted", () => { + const prev = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "running" } })]]); + const next = shellMap([ + [THREAD_A, makeShell({ session: { orchestrationStatus: "interrupted" } })], + ]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([ + { threadId: THREAD_A, kind: "turn-end" }, + ]); + }); + + it("fires turn-end on session running -> stopped", () => { + const prev = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "running" } })]]); + const next = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "stopped" } })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([ + { threadId: THREAD_A, kind: "turn-end" }, + ]); + }); + + it("fires turn-end on session running -> ready", () => { + const prev = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "running" } })]]); + const next = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "ready" } })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([ + { threadId: THREAD_A, kind: "turn-end" }, + ]); + }); + + it("fires turn-end on session running -> null session", () => { + const prev = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "running" } })]]); + const next = shellMap([[THREAD_A, makeShell({ session: null })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([ + { threadId: THREAD_A, kind: "turn-end" }, + ]); + }); + + it("does not fire turn-end on running -> running (no transition)", () => { + const prev = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "running" } })]]); + const next = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "running" } })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([]); + }); + + it("does not fire turn-end on running -> starting (still active, e.g. session restart)", () => { + const prev = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "running" } })]]); + const next = shellMap([ + [THREAD_A, makeShell({ session: { orchestrationStatus: "starting" } })], + ]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([]); + }); + + it("does not fire turn-end on idle -> idle", () => { + const prev = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "idle" } })]]); + const next = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "idle" } })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([]); + }); + + it("does not fire turn-end when prev is undefined (bootstrap)", () => { + const prev = shellMap([]); + const next = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "idle" } })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([]); + }); + + it("fires approval on hasPendingApprovals false -> true", () => { + const prev = shellMap([[THREAD_A, makeShell({ hasPendingApprovals: false })]]); + const next = shellMap([[THREAD_A, makeShell({ hasPendingApprovals: true })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([ + { threadId: THREAD_A, kind: "approval" }, + ]); + }); + + it("fires approval on hasActionableProposedPlan false -> true", () => { + const prev = shellMap([[THREAD_A, makeShell({ hasActionableProposedPlan: false })]]); + const next = shellMap([[THREAD_A, makeShell({ hasActionableProposedPlan: true })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([ + { threadId: THREAD_A, kind: "approval" }, + ]); + }); + + it("fires approval when both approval and plan rise together", () => { + const prev = shellMap([ + [THREAD_A, makeShell({ hasPendingApprovals: false, hasActionableProposedPlan: false })], + ]); + const next = shellMap([ + [THREAD_A, makeShell({ hasPendingApprovals: true, hasActionableProposedPlan: true })], + ]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([ + { threadId: THREAD_A, kind: "approval" }, + ]); + }); + + it("does not fire approval when transitioning from one approval to another (still pending)", () => { + const prev = shellMap([ + [THREAD_A, makeShell({ hasPendingApprovals: true, hasActionableProposedPlan: false })], + ]); + const next = shellMap([ + [THREAD_A, makeShell({ hasPendingApprovals: true, hasActionableProposedPlan: true })], + ]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([]); + }); + + it("fires question on hasPendingUserInput false -> true", () => { + const prev = shellMap([[THREAD_A, makeShell({ hasPendingUserInput: false })]]); + const next = shellMap([[THREAD_A, makeShell({ hasPendingUserInput: true })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([ + { threadId: THREAD_A, kind: "question" }, + ]); + }); + + it("does not fire question when prev is undefined (bootstrap)", () => { + const prev = shellMap([]); + const next = shellMap([[THREAD_A, makeShell({ hasPendingUserInput: true })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([]); + }); + + it("skips archived threads even on transitions", () => { + const prev = shellMap([ + [ + THREAD_A, + makeShell({ + archivedAt: "2026-04-27T00:00:00.000Z", + session: { orchestrationStatus: "running" }, + }), + ], + ]); + const next = shellMap([ + [ + THREAD_A, + makeShell({ + archivedAt: "2026-04-27T00:00:00.000Z", + session: { orchestrationStatus: "idle" }, + hasPendingApprovals: true, + hasPendingUserInput: true, + }), + ], + ]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([]); + }); + + it("emits multiple triggers across threads in one batch", () => { + const prev = shellMap([ + [THREAD_A, makeShell({ session: { orchestrationStatus: "running" } })], + [THREAD_B, makeShell({ hasPendingUserInput: false })], + ]); + const next = shellMap([ + [THREAD_A, makeShell({ session: { orchestrationStatus: "idle" } })], + [THREAD_B, makeShell({ hasPendingUserInput: true })], + ]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([ + { threadId: THREAD_A, kind: "turn-end" }, + { threadId: THREAD_B, kind: "question" }, + ]); + }); +}); + +describe("shouldPlay", () => { + const triggers: readonly NotificationTrigger[] = [{ threadId: THREAD_A, kind: "turn-end" }]; + // Use a `now` past the throttle window so non-throttle tests are unaffected. + const NOW = NOTIFICATION_THROTTLE_MS * 10; + + it("returns false when master toggle is off", () => { + expect( + shouldPlay(triggers, makeSettings({ notificationSoundEnabled: false }), makeFocus(), NOW, 0), + ).toBe(false); + }); + + it("returns false when the per-kind toggle is off", () => { + expect( + shouldPlay( + triggers, + makeSettings({ notificationSoundOnTurnEnd: false }), + makeFocus(), + NOW, + 0, + ), + ).toBe(false); + }); + + it("returns false when there are no triggers", () => { + expect(shouldPlay([], makeSettings(), makeFocus(), NOW, 0)).toBe(false); + }); + + describe('focus rule "always"', () => { + const settings = makeSettings({ notificationSoundFocusRule: "always" }); + it("passes when focused on the same thread", () => { + expect( + shouldPlay( + triggers, + settings, + makeFocus({ documentVisible: true, windowFocused: true, currentThreadId: THREAD_A }), + NOW, + 0, + ), + ).toBe(true); + }); + it("passes when unfocused", () => { + expect(shouldPlay(triggers, settings, makeFocus({ windowFocused: false }), NOW, 0)).toBe( + true, + ); + }); + }); + + describe('focus rule "unfocused-only"', () => { + const settings = makeSettings({ notificationSoundFocusRule: "unfocused-only" }); + it("blocks when focused and visible", () => { + expect( + shouldPlay( + triggers, + settings, + makeFocus({ documentVisible: true, windowFocused: true }), + NOW, + 0, + ), + ).toBe(false); + }); + it("passes when window unfocused", () => { + expect(shouldPlay(triggers, settings, makeFocus({ windowFocused: false }), NOW, 0)).toBe( + true, + ); + }); + it("passes when document hidden", () => { + expect(shouldPlay(triggers, settings, makeFocus({ documentVisible: false }), NOW, 0)).toBe( + true, + ); + }); + }); + + describe('focus rule "unfocused-or-different-thread"', () => { + const settings = makeSettings({ notificationSoundFocusRule: "unfocused-or-different-thread" }); + it("blocks when focused, visible, and on the same thread", () => { + expect( + shouldPlay( + triggers, + settings, + makeFocus({ + documentVisible: true, + windowFocused: true, + currentThreadId: THREAD_A, + }), + NOW, + 0, + ), + ).toBe(false); + }); + it("passes when window unfocused", () => { + expect( + shouldPlay( + triggers, + settings, + makeFocus({ windowFocused: false, currentThreadId: THREAD_A }), + NOW, + 0, + ), + ).toBe(true); + }); + it("passes when document hidden", () => { + expect( + shouldPlay( + triggers, + settings, + makeFocus({ documentVisible: false, currentThreadId: THREAD_A }), + NOW, + 0, + ), + ).toBe(true); + }); + it("passes when viewing a different thread", () => { + expect( + shouldPlay( + triggers, + settings, + makeFocus({ + documentVisible: true, + windowFocused: true, + currentThreadId: THREAD_B, + }), + NOW, + 0, + ), + ).toBe(true); + }); + }); + + describe("throttle", () => { + const settings = makeSettings(); + const focus = makeFocus(); + it("blocks within the throttle window", () => { + expect(shouldPlay(triggers, settings, focus, NOTIFICATION_THROTTLE_MS - 1, 0)).toBe(false); + }); + it("passes at exactly the throttle window", () => { + expect(shouldPlay(triggers, settings, focus, NOTIFICATION_THROTTLE_MS, 0)).toBe(true); + }); + it("passes after the throttle window", () => { + expect(shouldPlay(triggers, settings, focus, NOTIFICATION_THROTTLE_MS + 100, 0)).toBe(true); + }); + }); +}); diff --git a/apps/web/src/notificationSound.ts b/apps/web/src/notificationSound.ts new file mode 100644 index 0000000000..ece1ffa4d2 --- /dev/null +++ b/apps/web/src/notificationSound.ts @@ -0,0 +1,249 @@ +/** + * Notification sound — plays a configurable chime when an agent needs the + * user's attention (turn end, approval requested, or question asked). + * + * Three pieces: + * 1. `deriveNotificationTriggers` — pure: detects rising-edge transitions + * from a prev/next thread shell map. + * 2. `shouldPlay` — pure: applies user settings, focus rules, and throttle. + * 3. `notificationSoundManager` — singleton: lazy-loads the audio element, + * reads runtime focus context, and plays the sound when allowed. + */ +import type { OrchestrationEvent, OrchestrationSessionStatus, ThreadId } from "@t3tools/contracts"; +import type { NotificationSoundFocusRule, UnifiedSettings } from "@t3tools/contracts/settings"; + +export const NOTIFICATION_THROTTLE_MS = 5000; +export const NOTIFICATION_SOUND_URL = "/sounds/notification.mp3"; + +/** + * Minimal shell shape used by the notification triggers. Matches the relevant + * subset of `OrchestrationThreadShell` so callers can pass the real shells + * directly without remapping. + * + * Turn-end uses `session.orchestrationStatus` (the provider's authoritative + * runtime state) rather than `latestTurn.state`. The latter flips to + * `"completed"` mid-turn when checkpoints are captured (see + * `apps/server/src/orchestration/projector.ts` handling of + * `thread.turn-diff-completed`), producing spurious rising edges on every + * mid-turn diff capture. `orchestrationStatus` stays `"running"` continuously + * through tool calls and only transitions out at actual turn end. + */ +export interface NotificationThreadShellLike { + readonly archivedAt: string | null; + readonly session: { readonly orchestrationStatus: OrchestrationSessionStatus } | null; + readonly hasPendingApprovals: boolean; + readonly hasActionableProposedPlan: boolean; + readonly hasPendingUserInput: boolean; +} + +export type NotificationTriggerKind = "turn-end" | "approval" | "question"; + +export interface NotificationTrigger { + readonly threadId: ThreadId; + readonly kind: NotificationTriggerKind; +} + +export interface NotificationFocusContext { + readonly documentVisible: boolean; + readonly windowFocused: boolean; + readonly currentThreadId: ThreadId | null; +} + +export type NotificationSettingsSlice = Pick< + UnifiedSettings, + | "notificationSoundEnabled" + | "notificationSoundOnTurnEnd" + | "notificationSoundOnApproval" + | "notificationSoundOnQuestion" + | "notificationSoundFocusRule" +>; + +export type ThreadShellMap = ReadonlyMap; + +/** + * Detects rising-edge transitions across a snapshot of thread shells. + * + * `events` is reserved for future edge-case handling (e.g. coalescing + * multiple transitions inside a single batch). It is currently unused; the + * derivation is fully driven by the prev/next shell maps. + */ +export function deriveNotificationTriggers( + prev: ThreadShellMap, + next: ThreadShellMap, + _events: readonly OrchestrationEvent[], +): NotificationTrigger[] { + const triggers: NotificationTrigger[] = []; + + for (const [threadId, nextShell] of next) { + if (nextShell.archivedAt !== null) { + continue; + } + + const previousShell = prev.get(threadId); + if (previousShell === undefined) { + // Bootstrap edge — skip to avoid spurious dings on initial load. + continue; + } + + // turn-end: prev session was running, next session has stopped running. + // `starting` is treated as still-active to ignore session restarts/resumes + // mid-turn. Any other status (idle/ready/interrupted/stopped/error) or a + // null session indicates the agent stopped working. + const prevRunning = previousShell.session?.orchestrationStatus === "running"; + const nextRunning = + nextShell.session?.orchestrationStatus === "running" || + nextShell.session?.orchestrationStatus === "starting"; + if (prevRunning && !nextRunning) { + triggers.push({ threadId, kind: "turn-end" }); + } + + // approval: prev had no pending approval/plan, next does. + const prevApproval = + previousShell.hasPendingApprovals || previousShell.hasActionableProposedPlan; + const nextApproval = nextShell.hasPendingApprovals || nextShell.hasActionableProposedPlan; + if (!prevApproval && nextApproval) { + triggers.push({ threadId, kind: "approval" }); + } + + // question: prev had no pending user input, next does. + if (!previousShell.hasPendingUserInput && nextShell.hasPendingUserInput) { + triggers.push({ threadId, kind: "question" }); + } + } + + return triggers; +} + +function triggerPassesFocusRule( + trigger: NotificationTrigger, + rule: NotificationSoundFocusRule, + focus: NotificationFocusContext, +): boolean { + switch (rule) { + case "always": + return true; + case "unfocused-only": + return !focus.documentVisible || !focus.windowFocused; + case "unfocused-or-different-thread": + return ( + !focus.documentVisible || !focus.windowFocused || trigger.threadId !== focus.currentThreadId + ); + } +} + +export function shouldPlay( + triggers: readonly NotificationTrigger[], + settings: NotificationSettingsSlice, + focusContext: NotificationFocusContext, + nowMs: number, + lastPlayAtMs: number, +): boolean { + if (!settings.notificationSoundEnabled) return false; + + const enabledByKind = (kind: NotificationTriggerKind): boolean => { + switch (kind) { + case "turn-end": + return settings.notificationSoundOnTurnEnd; + case "approval": + return settings.notificationSoundOnApproval; + case "question": + return settings.notificationSoundOnQuestion; + } + }; + + const enabledTriggers = triggers.filter((trigger) => enabledByKind(trigger.kind)); + if (enabledTriggers.length === 0) return false; + + const passesFocus = enabledTriggers.some((trigger) => + triggerPassesFocusRule(trigger, settings.notificationSoundFocusRule, focusContext), + ); + if (!passesFocus) return false; + + if (nowMs - lastPlayAtMs < NOTIFICATION_THROTTLE_MS) return false; + + return true; +} + +// ── Singleton manager ───────────────────────────────────────────────────── + +type CurrentThreadIdAccessor = () => ThreadId | null; + +class NotificationSoundManager { + private audio: HTMLAudioElement | null = null; + private lastPlayAtMs = 0; + private getCurrentThreadId: CurrentThreadIdAccessor = () => null; + + setCurrentThreadAccessor(accessor: CurrentThreadIdAccessor): void { + this.getCurrentThreadId = accessor; + } + + private ensureAudio(): HTMLAudioElement | null { + if (typeof document === "undefined") return null; + if (this.audio === null) { + const audio = new Audio(NOTIFICATION_SOUND_URL); + audio.preload = "auto"; + audio.volume = 1; + this.audio = audio; + } + return this.audio; + } + + private buildFocusContext(): NotificationFocusContext { + if (typeof document === "undefined") { + return { documentVisible: true, windowFocused: true, currentThreadId: null }; + } + const documentVisible = document.visibilityState === "visible"; + const windowFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true; + return { + documentVisible, + windowFocused, + currentThreadId: this.getCurrentThreadId(), + }; + } + + maybePlay(triggers: readonly NotificationTrigger[], settings: NotificationSettingsSlice): void { + if (triggers.length === 0) return; + const focusContext = this.buildFocusContext(); + const nowMs = Date.now(); + if (!shouldPlay(triggers, settings, focusContext, nowMs, this.lastPlayAtMs)) { + return; + } + const audio = this.ensureAudio(); + if (!audio) return; + this.lastPlayAtMs = nowMs; + try { + audio.currentTime = 0; + } catch { + // Some browsers throw when setting currentTime before metadata loads. + } + void audio.play().catch((error) => { + console.warn("[NOTIFICATION_SOUND] play failed", error); + }); + } + + /** + * Bypasses focus, throttle, and settings checks. Returns the play promise + * so callers can show a toast on rejection (e.g. autoplay blocked). + */ + async playTest(): Promise { + const audio = this.ensureAudio(); + if (!audio) { + throw new Error("Audio playback is not available in this environment."); + } + try { + audio.currentTime = 0; + } catch { + // ignore + } + await audio.play(); + } + + /** Test-only: reset internal state. */ + resetForTests(): void { + this.audio = null; + this.lastPlayAtMs = 0; + this.getCurrentThreadId = () => null; + } +} + +export const notificationSoundManager = new NotificationSoundManager(); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 87e8667901..700b971ede 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; +import { type ServerLifecycleWelcomePayload, type ThreadId } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; import { Outlet, @@ -49,6 +49,7 @@ import { startEnvironmentConnectionService, } from "../environments/runtime"; import { configureClientTracing } from "../observability/clientTracing"; +import { notificationSoundManager } from "../notificationSound"; import { ensurePrimaryEnvironmentReady, resolveInitialServerAuthGateState, @@ -100,6 +101,7 @@ function RootRouteView() { + @@ -210,6 +212,30 @@ function EnvironmentConnectionManagerBootstrap() { return null; } +function NotificationSoundBootstrap() { + // Track the active thread route param so the notification manager can + // honour the "different thread" focus rule from non-React land. + const threadId = useLocation({ + select: (location) => { + // Path layout: `/{environmentId}/{threadId}` (server) or `/draft/{draftId}`. + const segments = location.pathname.split("/").filter(Boolean); + if (segments.length < 2 || segments[0] === "draft" || segments[0] === "settings") { + return null; + } + return segments[1] as ThreadId; + }, + }); + + useEffect(() => { + notificationSoundManager.setCurrentThreadAccessor(() => threadId); + return () => { + notificationSoundManager.setCurrentThreadAccessor(() => null); + }; + }, [threadId]); + + return null; +} + function EventRouter() { const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const navigate = useNavigate(); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 087b031402..2040067eb6 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -28,6 +28,15 @@ export const SidebarProjectGroupingMode = Schema.Literals([ export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; +export const NotificationSoundFocusRule = Schema.Literals([ + "always", + "unfocused-only", + "unfocused-or-different-thread", +]); +export type NotificationSoundFocusRule = typeof NotificationSoundFocusRule.Type; +export const DEFAULT_NOTIFICATION_SOUND_FOCUS_RULE: NotificationSoundFocusRule = + "unfocused-or-different-thread"; + export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -74,6 +83,19 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), + notificationSoundEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + notificationSoundOnTurnEnd: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), + notificationSoundOnApproval: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), + notificationSoundOnQuestion: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), + notificationSoundFocusRule: NotificationSoundFocusRule.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_NOTIFICATION_SOUND_FOCUS_RULE)), + ), }); export type ClientSettings = typeof ClientSettingsSchema.Type; @@ -480,5 +502,10 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), timestampFormat: Schema.optionalKey(TimestampFormat), + notificationSoundEnabled: Schema.optionalKey(Schema.Boolean), + notificationSoundOnTurnEnd: Schema.optionalKey(Schema.Boolean), + notificationSoundOnApproval: Schema.optionalKey(Schema.Boolean), + notificationSoundOnQuestion: Schema.optionalKey(Schema.Boolean), + notificationSoundFocusRule: Schema.optionalKey(NotificationSoundFocusRule), }); export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;