From 1cec70ed69c2c021ad624425e7f0acff5a454bfa Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 14:04:59 -0400 Subject: [PATCH 1/3] Add sounds for completed turns and user input requests - Add interaction sound cue detection with hydration-safe tests - Play bloom and success cues from the web app --- apps/web/package.json | 1 + apps/web/src/interactionSounds.test.ts | 96 ++++++++++++++++++++++++++ apps/web/src/interactionSounds.ts | 57 +++++++++++++++ apps/web/src/routes/__root.tsx | 31 ++++++++- pnpm-lock.yaml | 8 +++ pnpm-workspace.yaml | 1 + 6 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/interactionSounds.test.ts create mode 100644 apps/web/src/interactionSounds.ts 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/interactionSounds.test.ts b/apps/web/src/interactionSounds.test.ts new file mode 100644 index 00000000000..6a114221d89 --- /dev/null +++ b/apps/web/src/interactionSounds.test.ts @@ -0,0 +1,96 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { captureThreadSoundState, deriveInteractionSoundCues } from "./interactionSounds"; + +function makeThread(overrides: Partial = {}): 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("does not replay cues for unchanged state", () => { + 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(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([]); + }); +}); diff --git a/apps/web/src/interactionSounds.ts b/apps/web/src/interactionSounds.ts new file mode 100644 index 00000000000..07efabdbf7d --- /dev/null +++ b/apps/web/src/interactionSounds.ts @@ -0,0 +1,57 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; + +export type InteractionSoundCue = "bloom" | "success"; + +interface ThreadSoundState { + readonly completedTurn: string | null; + readonly hasPendingUserInput: 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), + hasPendingUserInput: thread.hasPendingUserInput, + }, + ]), + ); +} + +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"); + } + if (prior && thread.hasPendingUserInput && !prior.hasPendingUserInput) { + cues.push("bloom"); + } + } + + return cues; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 69739ce4570..99cc8621db1 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"; @@ -47,7 +48,17 @@ import { primaryServerConfigEventAtom, primaryServerWelcomeAtom, } from "../state/server"; -import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; +import { + readProject, + setActiveEnvironmentId, + useActiveEnvironmentId, + useThreadShells, +} from "../state/entities"; +import { + captureThreadSoundState, + deriveInteractionSoundCues, + type ThreadSoundStateByKey, +} from "../interactionSounds"; import { createKeybindingsUpdateToastController, type KeybindingsUpdateToastController, @@ -134,6 +145,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {appShell} @@ -141,6 +153,23 @@ function RootRouteView() { ); } +function InteractionSoundCoordinator() { + const threads = useThreadShells(); + const previousStateRef = useRef(null); + + useEffect(() => { + const previous = previousStateRef.current; + if (previous !== null) { + for (const cue of deriveInteractionSoundCues(previous, threads)) { + play(cue); + } + } + previousStateRef.current = captureThreadSoundState(threads); + }, [threads]); + + return null; +} + function DocumentTitleSync() { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed67594e846..a2985ff7ed9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -557,6 +557,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) @@ -5993,6 +5996,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} @@ -16131,6 +16137,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:" From 8387bdaa141c0aaec40b308a7db55e0460ef5a6a Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 18:59:00 -0400 Subject: [PATCH 2/3] Hide completion sounds behind toggle --- .../settings/DesktopClientSettings.test.ts | 1 + .../components/settings/SettingsPanels.tsx | 31 +++++++++++++++++++ apps/web/src/interactionSounds.test.ts | 11 +++++++ apps/web/src/interactionSounds.ts | 7 +++-- apps/web/src/routes/__root.tsx | 8 +++-- packages/contracts/src/settings.test.ts | 12 +++++++ packages/contracts/src/settings.ts | 2 ++ 7 files changed, 66 insertions(+), 6 deletions(-) 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/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" + /> + } + /> + { ).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", diff --git a/apps/web/src/interactionSounds.ts b/apps/web/src/interactionSounds.ts index 07efabdbf7d..01923c0b45a 100644 --- a/apps/web/src/interactionSounds.ts +++ b/apps/web/src/interactionSounds.ts @@ -4,7 +4,7 @@ export type InteractionSoundCue = "bloom" | "success"; interface ThreadSoundState { readonly completedTurn: string | null; - readonly hasPendingUserInput: boolean; + readonly hasPendingUserAction: boolean; } export type ThreadSoundStateByKey = ReadonlyMap; @@ -29,7 +29,7 @@ export function captureThreadSoundState( threadKey(thread), { completedTurn: completedTurn(thread), - hasPendingUserInput: thread.hasPendingUserInput, + hasPendingUserAction: thread.hasPendingUserInput || thread.hasPendingApprovals, }, ]), ); @@ -48,7 +48,8 @@ export function deriveInteractionSoundCues( if (prior && nextCompletedTurn !== null && prior.completedTurn !== nextCompletedTurn) { cues.push("success"); } - if (prior && thread.hasPendingUserInput && !prior.hasPendingUserInput) { + const hasPendingUserAction = thread.hasPendingUserInput || thread.hasPendingApprovals; + if (prior && hasPendingUserAction && !prior.hasPendingUserAction) { cues.push("bloom"); } } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 99cc8621db1..c73de2b1359 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -28,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, @@ -155,17 +155,19 @@ function RootRouteView() { function InteractionSoundCoordinator() { const threads = useThreadShells(); + const completionSoundEnabled = useClientSettings((settings) => settings.enableCompletionSounds); + const settingsHydrated = useClientSettingsHydrated(); const previousStateRef = useRef(null); useEffect(() => { const previous = previousStateRef.current; - if (previous !== null) { + if (settingsHydrated && completionSoundEnabled && previous !== null) { for (const cue of deriveInteractionSoundCues(previous, threads)) { play(cue); } } previousStateRef.current = captureThreadSoundState(threads); - }, [threads]); + }, [completionSoundEnabled, settingsHydrated, threads]); return 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({ From f38723fc5fc502f70814b58a6b0f434d0dbd700a Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 19:59:15 -0400 Subject: [PATCH 3/3] Preserve sound baselines while settings hydrate Avoid advancing known thread sound state during settings hydration so turn completion and input cues are not dropped before the preference is ready. Co-authored-by: Cursor --- apps/web/src/interactionSounds.test.ts | 42 +++++++++++++++++++++++++- apps/web/src/interactionSounds.ts | 23 ++++++++++++++ apps/web/src/routes/__root.tsx | 11 ++++++- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/apps/web/src/interactionSounds.test.ts b/apps/web/src/interactionSounds.test.ts index 504bc31026b..36edb11ece0 100644 --- a/apps/web/src/interactionSounds.test.ts +++ b/apps/web/src/interactionSounds.test.ts @@ -1,7 +1,11 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { captureThreadSoundState, deriveInteractionSoundCues } from "./interactionSounds"; +import { + captureThreadSoundState, + captureThreadSoundStateWhileSettingsHydrating, + deriveInteractionSoundCues, +} from "./interactionSounds"; function makeThread(overrides: Partial = {}): EnvironmentThreadShell { return { @@ -104,4 +108,40 @@ describe("interaction sounds", () => { 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 index 01923c0b45a..eedf1f42dc9 100644 --- a/apps/web/src/interactionSounds.ts +++ b/apps/web/src/interactionSounds.ts @@ -35,6 +35,29 @@ export function captureThreadSoundState( ); } +/** + * 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, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c73de2b1359..38579dc7c83 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -56,6 +56,7 @@ import { } from "../state/entities"; import { captureThreadSoundState, + captureThreadSoundStateWhileSettingsHydrating, deriveInteractionSoundCues, type ThreadSoundStateByKey, } from "../interactionSounds"; @@ -160,8 +161,16 @@ function InteractionSoundCoordinator() { const previousStateRef = useRef(null); useEffect(() => { + if (!settingsHydrated) { + previousStateRef.current = captureThreadSoundStateWhileSettingsHydrating( + previousStateRef.current, + threads, + ); + return; + } + const previous = previousStateRef.current; - if (settingsHydrated && completionSoundEnabled && previous !== null) { + if (completionSoundEnabled && previous !== null) { for (const cue of deriveInteractionSoundCues(previous, threads)) { play(cue); }