From 1070f77dd6dfabbcaf88525e0cc7226fad19d122 Mon Sep 17 00:00:00 2001 From: d3oxy Date: Mon, 27 Apr 2026 22:29:11 +0530 Subject: [PATCH 1/3] feat(web): notification ding when agent needs attention Opt-in audible ding (not a desktop/OS notification or popup) that plays when an agent finishes a turn, requests approval, or asks a question. Per-device settings with focus-aware playback and a 5s throttle. New "Notifications" section in Settings with master toggle, three event sub-toggles, focus rule, and a Play Sound preview button. --- apps/web/public/sounds/notification.mp3 | Bin 0 -> 53760 bytes .../components/settings/SettingsPanels.tsx | 218 ++++++++++++ apps/web/src/environments/runtime/service.ts | 55 +++ apps/web/src/localApi.test.ts | 10 + apps/web/src/notificationSound.test.ts | 327 ++++++++++++++++++ apps/web/src/notificationSound.ts | 246 +++++++++++++ apps/web/src/routes/__root.tsx | 28 +- packages/contracts/src/settings.ts | 27 ++ 8 files changed, 910 insertions(+), 1 deletion(-) create mode 100644 apps/web/public/sounds/notification.mp3 create mode 100644 apps/web/src/notificationSound.test.ts create mode 100644 apps/web/src/notificationSound.ts diff --git a/apps/web/public/sounds/notification.mp3 b/apps/web/public/sounds/notification.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..59fec1ee94be25dd706033743b26081efa309248 GIT binary patch literal 53760 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 z

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 literal 0 HcmV?d00001 diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 29d09a5cdc..a8eff33350 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -46,6 +46,7 @@ import { resolveAppModelSelectionState, } from "../../modelSelection"; import { ensureLocalApi, readLocalApi } from "../../localApi"; +import { notificationSoundManager } from "../../notificationSound"; import { useShallow } from "zustand/react/shallow"; import { selectProjectsAcrossEnvironments, @@ -100,6 +101,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; + type InstallProviderSettings = { provider: ProviderKind; title: string; @@ -490,6 +497,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"] : []), ], @@ -503,6 +529,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, ], @@ -1168,6 +1199,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 c361cbd787..b247ded7c7 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -541,6 +541,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, @@ -600,6 +605,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..42b8a74b55 --- /dev/null +++ b/apps/web/src/notificationSound.test.ts @@ -0,0 +1,327 @@ +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, + latestTurn: 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 running -> completed", () => { + const prev = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "running" } })]]); + const next = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "completed" } })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([ + { threadId: THREAD_A, kind: "turn-end" }, + ]); + }); + + it("fires turn-end on running -> error", () => { + const prev = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "running" } })]]); + const next = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "error" } })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([ + { threadId: THREAD_A, kind: "turn-end" }, + ]); + }); + + it("fires turn-end on running -> interrupted", () => { + const prev = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "running" } })]]); + const next = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "interrupted" } })]]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([ + { threadId: THREAD_A, kind: "turn-end" }, + ]); + }); + + it("does not fire turn-end on completed -> completed", () => { + const prev = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "completed" } })]]); + const next = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "completed" } })]]); + 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({ latestTurn: { state: "completed" } })]]); + 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", + latestTurn: { state: "running" }, + }), + ], + ]); + const next = shellMap([ + [ + THREAD_A, + makeShell({ + archivedAt: "2026-04-27T00:00:00.000Z", + latestTurn: { state: "completed" }, + hasPendingApprovals: true, + hasPendingUserInput: true, + }), + ], + ]); + expect(deriveNotificationTriggers(prev, next, [])).toEqual([]); + }); + + it("emits multiple triggers across threads in one batch", () => { + const prev = shellMap([ + [THREAD_A, makeShell({ latestTurn: { state: "running" } })], + [THREAD_B, makeShell({ hasPendingUserInput: false })], + ]); + const next = shellMap([ + [THREAD_A, makeShell({ latestTurn: { state: "completed" } })], + [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..ff28f7c721 --- /dev/null +++ b/apps/web/src/notificationSound.ts @@ -0,0 +1,246 @@ +/** + * 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, 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. + */ +export interface NotificationThreadShellLike { + readonly archivedAt: string | null; + readonly latestTurn: { readonly state: "running" | "completed" | "interrupted" | "error" } | 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; + +const TERMINAL_TURN_STATES = new Set< + NotificationThreadShellLike["latestTurn"] extends infer T + ? T extends { state: infer S } + ? S + : never + : never +>(["completed", "error", "interrupted"]); + +/** + * 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 was running, next is in a terminal state. + if ( + previousShell.latestTurn?.state === "running" && + nextShell.latestTurn !== null && + TERMINAL_TURN_STATES.has(nextShell.latestTurn.state) + ) { + 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 2b50957a79..ec58d98a87 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -30,6 +30,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))), @@ -57,6 +66,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; @@ -263,5 +285,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; From bc146d3de4e5ce2584ba0dc77310a6bc908bbb39 Mon Sep 17 00:00:00 2001 From: d3oxy Date: Mon, 27 Apr 2026 22:36:08 +0530 Subject: [PATCH 2/3] fix(desktop): add notification settings to clientPersistence test fixture ClientSettings shape now requires the 5 notification fields; the desktop test fixture was missed in the initial change. --- apps/desktop/src/clientPersistence.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 192d7ac106..4a7b00889d 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", sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", From a274cfcfa9af01a898d77fd3158d8fa13a264091 Mon Sep 17 00:00:00 2001 From: d3oxy Date: Mon, 27 Apr 2026 23:38:26 +0530 Subject: [PATCH 3/3] fix(web): use session.orchestrationStatus for turn-end detection `latestTurn.state` is not a reliable end-of-turn signal: the projector flips it to "completed" on every `thread.turn-diff-completed` event, which fires mid-turn whenever a checkpoint is captured for a git repo (see ProviderRuntimeIngestion turn.diff.updated handling). The next session-set running event then flips it back, producing running -> completed -> running oscillations through a single turn and spurious notification dings on the first mid-turn diff capture. Switch turn-end detection to `session.orchestrationStatus`, which the provider keeps as "running" continuously through tool calls and only transitions out at actual turn end. "starting" is treated as still active so session restarts/resumes mid-turn don't trigger. --- apps/web/src/environments/runtime/service.ts | 2 +- apps/web/src/notificationSound.test.ts | 76 +++++++++++++++----- apps/web/src/notificationSound.ts | 35 ++++----- 3 files changed, 78 insertions(+), 35 deletions(-) diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index f7d5393b3e..08ee4bb181 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -631,7 +631,7 @@ export function shouldApplyTerminalEvent(input: { function summaryToNotificationShell(summary: SidebarThreadSummary): NotificationThreadShellLike { return { archivedAt: summary.archivedAt, - latestTurn: summary.latestTurn ? { state: summary.latestTurn.state } : null, + session: summary.session ? { orchestrationStatus: summary.session.orchestrationStatus } : null, hasPendingApprovals: summary.hasPendingApprovals, hasPendingUserInput: summary.hasPendingUserInput, hasActionableProposedPlan: summary.hasActionableProposedPlan, diff --git a/apps/web/src/notificationSound.test.ts b/apps/web/src/notificationSound.test.ts index 42b8a74b55..147df00c22 100644 --- a/apps/web/src/notificationSound.test.ts +++ b/apps/web/src/notificationSound.test.ts @@ -20,7 +20,7 @@ function makeShell( ): NotificationThreadShellLike { return { archivedAt: null, - latestTurn: null, + session: null, hasPendingApprovals: false, hasActionableProposedPlan: false, hasPendingUserInput: false, @@ -55,39 +55,79 @@ function makeFocus(overrides: Partial = {}): Notificat } describe("deriveNotificationTriggers", () => { - it("fires turn-end on running -> completed", () => { - const prev = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "running" } })]]); - const next = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "completed" } })]]); + 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 running -> error", () => { - const prev = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "running" } })]]); - const next = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "error" } })]]); + 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 running -> interrupted", () => { - const prev = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "running" } })]]); - const next = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "interrupted" } })]]); + 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("does not fire turn-end on completed -> completed", () => { - const prev = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "completed" } })]]); - const next = shellMap([[THREAD_A, makeShell({ latestTurn: { state: "completed" } })]]); + 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({ latestTurn: { state: "completed" } })]]); + const next = shellMap([[THREAD_A, makeShell({ session: { orchestrationStatus: "idle" } })]]); expect(deriveNotificationTriggers(prev, next, [])).toEqual([]); }); @@ -149,7 +189,7 @@ describe("deriveNotificationTriggers", () => { THREAD_A, makeShell({ archivedAt: "2026-04-27T00:00:00.000Z", - latestTurn: { state: "running" }, + session: { orchestrationStatus: "running" }, }), ], ]); @@ -158,7 +198,7 @@ describe("deriveNotificationTriggers", () => { THREAD_A, makeShell({ archivedAt: "2026-04-27T00:00:00.000Z", - latestTurn: { state: "completed" }, + session: { orchestrationStatus: "idle" }, hasPendingApprovals: true, hasPendingUserInput: true, }), @@ -169,11 +209,11 @@ describe("deriveNotificationTriggers", () => { it("emits multiple triggers across threads in one batch", () => { const prev = shellMap([ - [THREAD_A, makeShell({ latestTurn: { state: "running" } })], + [THREAD_A, makeShell({ session: { orchestrationStatus: "running" } })], [THREAD_B, makeShell({ hasPendingUserInput: false })], ]); const next = shellMap([ - [THREAD_A, makeShell({ latestTurn: { state: "completed" } })], + [THREAD_A, makeShell({ session: { orchestrationStatus: "idle" } })], [THREAD_B, makeShell({ hasPendingUserInput: true })], ]); expect(deriveNotificationTriggers(prev, next, [])).toEqual([ diff --git a/apps/web/src/notificationSound.ts b/apps/web/src/notificationSound.ts index ff28f7c721..ece1ffa4d2 100644 --- a/apps/web/src/notificationSound.ts +++ b/apps/web/src/notificationSound.ts @@ -9,7 +9,7 @@ * 3. `notificationSoundManager` — singleton: lazy-loads the audio element, * reads runtime focus context, and plays the sound when allowed. */ -import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; +import type { OrchestrationEvent, OrchestrationSessionStatus, ThreadId } from "@t3tools/contracts"; import type { NotificationSoundFocusRule, UnifiedSettings } from "@t3tools/contracts/settings"; export const NOTIFICATION_THROTTLE_MS = 5000; @@ -19,10 +19,18 @@ 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 latestTurn: { readonly state: "running" | "completed" | "interrupted" | "error" } | null; + readonly session: { readonly orchestrationStatus: OrchestrationSessionStatus } | null; readonly hasPendingApprovals: boolean; readonly hasActionableProposedPlan: boolean; readonly hasPendingUserInput: boolean; @@ -52,14 +60,6 @@ export type NotificationSettingsSlice = Pick< export type ThreadShellMap = ReadonlyMap; -const TERMINAL_TURN_STATES = new Set< - NotificationThreadShellLike["latestTurn"] extends infer T - ? T extends { state: infer S } - ? S - : never - : never ->(["completed", "error", "interrupted"]); - /** * Detects rising-edge transitions across a snapshot of thread shells. * @@ -85,12 +85,15 @@ export function deriveNotificationTriggers( continue; } - // turn-end: prev was running, next is in a terminal state. - if ( - previousShell.latestTurn?.state === "running" && - nextShell.latestTurn !== null && - TERMINAL_TURN_STATES.has(nextShell.latestTurn.state) - ) { + // 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" }); }