diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea7ec6e1512..8ec6ec899f9 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,6 +18,7 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, + enableCompletionSounds: false, favorites: [], providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/web/package.json b/apps/web/package.json index 5a1579a478b..237c45c867c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", + "cuelume": "^0.1.0", "effect": "catalog:", "jose": "catalog:", "lexical": "^0.41.0", diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 40017d56314..081918fbd51 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -395,6 +395,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), + ...(settings.enableCompletionSounds !== DEFAULT_UNIFIED_SETTINGS.enableCompletionSounds + ? ["Completion sound"] + : []), ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? ["Auto-open task panel"] : []), @@ -432,6 +435,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, + settings.enableCompletionSounds, settings.automaticGitFetchInterval, settings.enableAssistantStreaming, settings.sidebarThreadPreviewCount, @@ -456,6 +460,7 @@ export function useSettingsRestore(onRestored?: () => void) { timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, + enableCompletionSounds: DEFAULT_UNIFIED_SETTINGS.enableCompletionSounds, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, @@ -615,6 +620,32 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + enableCompletionSounds: DEFAULT_UNIFIED_SETTINGS.enableCompletionSounds, + }) + } + /> + ) : null + } + control={ + + updateSettings({ enableCompletionSounds: Boolean(checked) }) + } + aria-label="Play a sound when a turn completes" + /> + } + /> + = {}): EnvironmentThreadShell { + return { + environmentId: "environment-1", + id: "thread-1", + projectId: "project-1", + title: "Thread", + modelSelection: null, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-07-11T12:00:00.000Z", + updatedAt: "2026-07-11T12:00:00.000Z", + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + } as EnvironmentThreadShell; +} + +describe("interaction sounds", () => { + it("plays success when a turn becomes completed", () => { + const running = makeThread({ + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "running", + requestedAt: "2026-07-11T12:00:00.000Z", + startedAt: "2026-07-11T12:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + }); + const completed = makeThread({ + latestTurn: { + ...running.latestTurn!, + state: "completed", + completedAt: "2026-07-11T12:00:05.000Z", + }, + }); + + expect(deriveInteractionSoundCues(captureThreadSoundState([running]), [completed])).toEqual([ + "success", + ]); + }); + + it("plays bloom when a thread starts requesting user input", () => { + const thread = makeThread(); + + expect( + deriveInteractionSoundCues(captureThreadSoundState([thread]), [ + makeThread({ hasPendingUserInput: true }), + ]), + ).toEqual(["bloom"]); + }); + + it("plays bloom when a thread starts requesting approval", () => { + const thread = makeThread(); + + expect( + deriveInteractionSoundCues(captureThreadSoundState([thread]), [ + makeThread({ hasPendingApprovals: true }), + ]), + ).toEqual(["bloom"]); + }); + + it("does not replay cues for unchanged state", () => { + const thread = makeThread({ + hasPendingUserInput: true, + hasPendingApprovals: true, + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-07-11T12:00:00.000Z", + startedAt: "2026-07-11T12:00:01.000Z", + completedAt: "2026-07-11T12:00:05.000Z", + assistantMessageId: null, + }, + }); + + expect(deriveInteractionSoundCues(captureThreadSoundState([thread]), [thread])).toEqual([]); + }); + + it("does not play cues while existing threads are first hydrated", () => { + const thread = makeThread({ + hasPendingUserInput: true, + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-07-11T12:00:00.000Z", + startedAt: "2026-07-11T12:00:01.000Z", + completedAt: "2026-07-11T12:00:05.000Z", + assistantMessageId: null, + }, + }); + + expect(deriveInteractionSoundCues(new Map(), [thread])).toEqual([]); + }); + + it("preserves pre-hydration thread state so cues can play after settings hydrate", () => { + const running = makeThread({ + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "running", + requestedAt: "2026-07-11T12:00:00.000Z", + startedAt: "2026-07-11T12:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + }); + const completed = makeThread({ + latestTurn: { + ...running.latestTurn!, + state: "completed", + completedAt: "2026-07-11T12:00:05.000Z", + }, + }); + + const seeded = captureThreadSoundStateWhileSettingsHydrating(null, [running]); + const frozen = captureThreadSoundStateWhileSettingsHydrating(seeded, [completed]); + + expect(deriveInteractionSoundCues(frozen, [completed])).toEqual(["success"]); + }); + + it("admits newly seen threads while settings are hydrating", () => { + const seeded = captureThreadSoundStateWhileSettingsHydrating(null, []); + const withThread = captureThreadSoundStateWhileSettingsHydrating(seeded, [ + makeThread({ hasPendingUserInput: true }), + ]); + + expect( + deriveInteractionSoundCues(withThread, [makeThread({ hasPendingUserInput: true })]), + ).toEqual([]); + }); +}); diff --git a/apps/web/src/interactionSounds.ts b/apps/web/src/interactionSounds.ts new file mode 100644 index 00000000000..eedf1f42dc9 --- /dev/null +++ b/apps/web/src/interactionSounds.ts @@ -0,0 +1,81 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; + +export type InteractionSoundCue = "bloom" | "success"; + +interface ThreadSoundState { + readonly completedTurn: string | null; + readonly hasPendingUserAction: boolean; +} + +export type ThreadSoundStateByKey = ReadonlyMap; + +function threadKey(thread: EnvironmentThreadShell): string { + return `${thread.environmentId}:${thread.id}`; +} + +function completedTurn(thread: EnvironmentThreadShell): string | null { + const latestTurn = thread.latestTurn; + if (latestTurn?.state !== "completed" || latestTurn.completedAt === null) { + return null; + } + return `${latestTurn.turnId}:${latestTurn.completedAt}`; +} + +export function captureThreadSoundState( + threads: ReadonlyArray, +): ThreadSoundStateByKey { + return new Map( + threads.map((thread) => [ + threadKey(thread), + { + completedTurn: completedTurn(thread), + hasPendingUserAction: thread.hasPendingUserInput || thread.hasPendingApprovals, + }, + ]), + ); +} + +/** + * While client settings are still hydrating, keep a sound baseline without + * advancing known thread state. Newly seen threads are admitted so later + * transitions can still produce cues once hydration completes. + */ +export function captureThreadSoundStateWhileSettingsHydrating( + previous: ThreadSoundStateByKey | null, + threads: ReadonlyArray, +): ThreadSoundStateByKey { + const next = captureThreadSoundState(threads); + if (previous === null) { + return next; + } + + const merged = new Map(previous); + for (const [key, state] of next) { + if (!merged.has(key)) { + merged.set(key, state); + } + } + return merged; +} + +export function deriveInteractionSoundCues( + previous: ThreadSoundStateByKey, + threads: ReadonlyArray, +): InteractionSoundCue[] { + const cues: InteractionSoundCue[] = []; + + for (const thread of threads) { + const prior = previous.get(threadKey(thread)); + const nextCompletedTurn = completedTurn(thread); + + if (prior && nextCompletedTurn !== null && prior.completedTurn !== nextCompletedTurn) { + cues.push("success"); + } + const hasPendingUserAction = thread.hasPendingUserInput || thread.hasPendingApprovals; + if (prior && hasPendingUserAction && !prior.hasPendingUserAction) { + cues.push("bloom"); + } + } + + return cues; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 69739ce4570..38579dc7c83 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -9,6 +9,7 @@ import { useNavigate, } from "@tanstack/react-router"; import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { play } from "cuelume"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; @@ -27,7 +28,7 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useClientSettingsHydrated } from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -47,7 +48,18 @@ import { primaryServerConfigEventAtom, primaryServerWelcomeAtom, } from "../state/server"; -import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; +import { + readProject, + setActiveEnvironmentId, + useActiveEnvironmentId, + useThreadShells, +} from "../state/entities"; +import { + captureThreadSoundState, + captureThreadSoundStateWhileSettingsHydrating, + deriveInteractionSoundCues, + type ThreadSoundStateByKey, +} from "../interactionSounds"; import { createKeybindingsUpdateToastController, type KeybindingsUpdateToastController, @@ -134,6 +146,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {appShell} @@ -141,6 +154,33 @@ function RootRouteView() { ); } +function InteractionSoundCoordinator() { + const threads = useThreadShells(); + const completionSoundEnabled = useClientSettings((settings) => settings.enableCompletionSounds); + const settingsHydrated = useClientSettingsHydrated(); + const previousStateRef = useRef(null); + + useEffect(() => { + if (!settingsHydrated) { + previousStateRef.current = captureThreadSoundStateWhileSettingsHydrating( + previousStateRef.current, + threads, + ); + return; + } + + const previous = previousStateRef.current; + if (completionSoundEnabled && previous !== null) { + for (const cue of deriveInteractionSoundCues(previous, threads)) { + play(cue); + } + } + previousStateRef.current = captureThreadSoundState(threads); + }, [completionSoundEnabled, settingsHydrated, threads]); + + return null; +} + function DocumentTitleSync() { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ac2d47ca336..3e5a3ad6cde 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -31,6 +31,18 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings completion sound", () => { + it("defaults the completion sound on", () => { + expect(decodeClientSettings({}).enableCompletionSounds).toBe(true); + }); + + it("preserves an explicit disabled preference", () => { + expect(decodeClientSettings({ enableCompletionSounds: false }).enableCompletionSounds).toBe( + false, + ); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6ccd65533dd..af40a00342d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -47,6 +47,7 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + enableCompletionSounds: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal ยท gpt-5") without @@ -538,6 +539,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), + enableCompletionSounds: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee737f9e750..38d557658d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -566,6 +566,9 @@ importers: class-variance-authority: specifier: ^0.7.1 version: 0.7.1 + cuelume: + specifier: ^0.1.0 + version: 0.1.0 effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) @@ -6030,6 +6033,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cuelume@0.1.0: + resolution: {integrity: sha512-J2RRt92Gh1a0ztnjwLEyLfGnDIiWz14vC9aTmg7ZgxRkEk8vR+QxFOz+v1tqntFvl06n5G4si8QCQgHfWjnneA==} + culori@4.0.2: resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -16222,6 +16228,8 @@ snapshots: csstype@3.2.3: {} + cuelume@0.1.0: {} + culori@4.0.2: {} debounce-fn@4.0.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7d85b1059d5..1110fb95ebf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -58,6 +58,7 @@ minimumReleaseAgeExclude: - "@clerk/expo@3.7.2" - "@clerk/react@6.12.1" - "@clerk/shared@4.25.1" + - cuelume@0.1.0 overrides: "@clerk/backend": "catalog:"