diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8f50aa8f882..bfb69a811a5 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: [], glassOpacity: 80, providerModelPreferences: {}, 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 fa7c7299667..442087e2652 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -415,6 +415,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"] : []), @@ -456,6 +459,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, + settings.enableCompletionSounds, settings.glassOpacity, settings.automaticGitFetchInterval, settings.enableAssistantStreaming, @@ -483,6 +487,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, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, @@ -736,6 +741,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({ + latestUserMessageAt: "2026-07-11T12:00:00.000Z", + 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({ + latestUserMessageAt: running.latestUserMessageAt, + latestTurn: { + ...running.latestTurn!, + state: "completed", + completedAt: "2026-07-11T12:00:05.000Z", + }, + }); + + expect(deriveInteractionSoundCues(captureThreadSoundState([running]), [completed])).toEqual([ + "success", + ]); + }); + + it("does not play success when a background subagent turn completes", () => { + const backgroundRunning = makeThread({ + latestUserMessageAt: "2026-07-11T12:00:00.000Z", + latestTurn: { + turnId: TurnId.make("subagent-turn"), + state: "running", + requestedAt: "2026-07-11T12:05:00.000Z", + startedAt: "2026-07-11T12:05:00.000Z", + completedAt: null, + assistantMessageId: null, + }, + }); + const completedAfterNextUserMessage = makeThread({ + latestUserMessageAt: "2026-07-11T12:06:00.000Z", + latestTurn: { + ...backgroundRunning.latestTurn!, + state: "completed", + completedAt: "2026-07-11T12:06:00.000Z", + }, + }); + + expect( + deriveInteractionSoundCues(captureThreadSoundState([backgroundRunning]), [ + completedAfterNextUserMessage, + ]), + ).toEqual([]); + }); + + 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 replay success when a completed turn timestamp is corrected", () => { + const completed = makeThread({ + latestUserMessageAt: "2026-07-11T12:00:00.000Z", + 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, + }, + }); + const corrected = makeThread({ + latestUserMessageAt: completed.latestUserMessageAt, + latestTurn: { + ...completed.latestTurn!, + completedAt: "2026-07-11T12:00:06.000Z", + }, + }); + + expect(deriveInteractionSoundCues(captureThreadSoundState([completed]), [corrected])).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({ + latestUserMessageAt: "2026-07-11T12:00:00.000Z", + 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({ + latestUserMessageAt: running.latestUserMessageAt, + 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..eb0f26818ea --- /dev/null +++ b/apps/web/src/interactionSounds.ts @@ -0,0 +1,119 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; + +export type InteractionSoundCue = "bloom" | "success"; + +interface ThreadSoundState { + readonly completedTurn: string | null; + readonly latestTurn: string | null; + readonly userInitiatedTurn: 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; +} + +function latestTurn(thread: EnvironmentThreadShell): string | null { + return thread.latestTurn?.turnId ?? null; +} + +function userInitiatedTurn(thread: EnvironmentThreadShell): string | null { + const latestTurn = thread.latestTurn; + if (latestTurn === null || thread.latestUserMessageAt === null) { + return null; + } + + // Providers can surface background/subagent output as a synthetic turn with + // no corresponding user message. Steering can add a later user message to a + // real turn, so user activity at or after requestedAt still counts. + const requestedAt = Date.parse(latestTurn.requestedAt); + const latestUserMessageAt = Date.parse(thread.latestUserMessageAt); + if ( + !Number.isFinite(requestedAt) || + !Number.isFinite(latestUserMessageAt) || + latestUserMessageAt < requestedAt + ) { + return null; + } + + return latestTurn.turnId; +} + +export function captureThreadSoundState( + threads: ReadonlyArray, +): ThreadSoundStateByKey { + return new Map( + threads.map((thread) => [ + threadKey(thread), + { + completedTurn: completedTurn(thread), + latestTurn: latestTurn(thread), + userInitiatedTurn: userInitiatedTurn(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 && + // Eligibility comes from the observed pre-completion state so a later + // user message cannot retroactively turn a background turn into a cue. + prior.latestTurn === nextCompletedTurn && + prior.userInitiatedTurn === 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 346991d114d..d6c4365d104 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, @@ -135,6 +147,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {appShell} @@ -142,6 +155,32 @@ 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 GlassAppearanceSync() { const glassOpacity = useClientSettings((settings) => settings.glassOpacity); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e8eaf723e17..9f26c89349b 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -33,6 +33,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("ClientSettings glass opacity", () => { it("defaults to a readable translucent surface", () => { expect(decodeClientSettings({}).glassOpacity).toBe(80); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 06f7de3db67..50568db5369 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -67,6 +67,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))), glassOpacity: GlassOpacity.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), ), @@ -573,6 +574,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), glassOpacity: Schema.optionalKey(GlassOpacity), favorites: Schema.optionalKey( Schema.Array( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea636d3807..8838ed8976d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -577,6 +577,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) @@ -6044,6 +6047,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} @@ -16214,6 +16220,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 ff840659efc..b94c364fa20 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -58,6 +58,7 @@ minimumReleaseAgeExclude: - "@clerk/expo@4.0.2" - "@clerk/react@6.12.7" - "@clerk/shared@4.25.7" + - cuelume@0.1.0 overrides: "@clerk/backend": "catalog:"