From 2706b76191ff211a0bde7250132e9d723d229f1b Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 14:04:59 -0400 Subject: [PATCH 01/14] 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 7fbcd6defb27..0cc85504a0dc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "@tanstack/react-router": "^1.160.2", "class-variance-authority": "^0.7.1", "culori": "^4.0.2", + "cuelume": "^0.1.0", "effect": "catalog:", "heic-to": "^1.5.2", "jose": "catalog:", diff --git a/apps/web/src/interactionSounds.test.ts b/apps/web/src/interactionSounds.test.ts new file mode 100644 index 000000000000..6a114221d89a --- /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 000000000000..07efabdbf7d3 --- /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 f42a49304fd7..235cc6043f15 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -10,6 +10,7 @@ import { } from "@tanstack/react-router"; import { CheckIcon, CopyIcon } from "lucide-react"; import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { play } from "cuelume"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; @@ -58,7 +59,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, @@ -190,6 +201,7 @@ function RootRouteView() { ) : null} {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {appShell} {/* Above the router: a theme draft is judged by walking the app, so the @@ -262,6 +274,23 @@ function FontAppearanceSync() { return null; } +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 95403e871a9b..cecfa332d297 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -614,6 +614,9 @@ importers: culori: specifier: ^4.0.2 version: 4.0.2 + cuelume: + specifier: ^0.1.0 + version: 0.1.0 effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) @@ -6338,6 +6341,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} @@ -16487,6 +16493,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 ee1bd25547f6..e8f5a1afa0e5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -79,6 +79,7 @@ minimumReleaseAgeExclude: - "@effect/sql-sqlite-bun@4.0.0-beta.103" - "@effect/vitest@4.0.0-beta.103" - alchemy@2.0.0-beta.65 + - cuelume@0.1.0 - effect@4.0.0-beta.103 - "@legendapp/list@3.3.5" - "@expo/cli@57.0.20" From 17b83a892c5683675da4f610cefc80dac2f48a72 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 18:59:00 -0400 Subject: [PATCH 02/14] 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 6bf0e87c9324..20e814ce4449 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -32,6 +32,7 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, diffLayout: "stacked", environmentIdentificationMode: "artwork", + enableCompletionSounds: false, favorites: [], fontFamilyCode: "", fontFamilyComposer: "", diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index bac8f2937328..f0b617ad2666 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -548,6 +548,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled ? ["Context window indicator"] : []), + ...(settings.enableCompletionSounds !== DEFAULT_UNIFIED_SETTINGS.enableCompletionSounds + ? ["Completion sound"] + : []), ...(settings.enableLegacyTokenStreaming !== DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming ? ["Stream token by token"] @@ -621,6 +624,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.fontSizeTerminal, settings.glassOpacity, settings.panelAnimationDurationMs, + settings.enableCompletionSounds, settings.enableLegacyTokenStreaming, settings.enableProviderUpdateChecks, settings.continueThreadsAfterServerUpdate, @@ -712,6 +716,7 @@ export function useSettingsRestore(onRestored?: () => void) { environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, + enableCompletionSounds: DEFAULT_UNIFIED_SETTINGS.enableCompletionSounds, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, @@ -2231,6 +2236,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 07efabdbf7d3..01923c0b45a1 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 235cc6043f15..f19ba59f03de 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -38,7 +38,7 @@ import { import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { applyAppearanceContrast } from "~/appearanceContrast"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useClientSettingsHydrated } from "../hooks/useSettings"; import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal"; import { deriveLogicalProjectKeyFromSettings, @@ -276,17 +276,19 @@ function FontAppearanceSync() { 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 f01d24c3ac8c..5df4df364062 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -371,6 +371,18 @@ describe("ServerSettings thread settlement", () => { }); }); +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 text generation to Luna at low reasoning effort", () => { expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7e25c8444dbb..727ed373fd77 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -279,6 +279,7 @@ export const ClientSettingsSchema = Schema.Struct({ glassOpacity: GlassOpacity.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), ), + enableCompletionSounds: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), fontSizeInterface: InterfaceFontSize.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_INTERFACE_FONT_SIZE)), ), @@ -1237,6 +1238,7 @@ export const ClientSettingsPatch = Schema.Struct({ environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), onboardingCompletedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), + enableCompletionSounds: Schema.optionalKey(Schema.Boolean), fontSizeInterface: Schema.optionalKey(InterfaceFontSize), fontSizePrompt: Schema.optionalKey(PromptFontSize), fontSizeCode: Schema.optionalKey(CodeFontSize), From 8e17a355ec0fa53cd7fa5a1e72b54a5f353c429f Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 19:59:15 -0400 Subject: [PATCH 03/14] 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 504bc31026bd..36edb11ece0e 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 01923c0b45a1..eedf1f42dc98 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 f19ba59f03de..b2a6706544a7 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -67,6 +67,7 @@ import { } from "../state/entities"; import { captureThreadSoundState, + captureThreadSoundStateWhileSettingsHydrating, deriveInteractionSoundCues, type ThreadSoundStateByKey, } from "../interactionSounds"; @@ -281,8 +282,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); } From 07b5ee8c97ca6d2b8cb5321271e47426c29708a6 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 12:55:12 -0400 Subject: [PATCH 04/14] fix(web): restore normal turn completion sounds --- .../Layers/ProjectionSnapshotQuery.test.ts | 10 ++ .../Layers/ProjectionSnapshotQuery.ts | 7 + apps/web/package.json | 2 +- apps/web/src/interactionSounds.test.ts | 138 +++++++++++++++++- apps/web/src/interactionSounds.ts | 64 +++++++- apps/web/src/routes/__root.tsx | 43 +++++- apps/web/src/state/shell.ts | 18 +++ packages/contracts/src/orchestration.ts | 1 + pnpm-lock.yaml | 10 +- pnpm-workspace.yaml | 2 +- 10 files changed, 275 insertions(+), 20 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index e262bce34aaf..7d30cc2cef80 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -341,6 +341,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { latestTurn: { turnId: asTurnId("turn-1"), state: "completed", + initiatingUserMessageId: null, requestedAt: "2026-02-24T00:00:08.000Z", startedAt: "2026-02-24T00:00:08.000Z", completedAt: "2026-02-24T00:00:08.000Z", @@ -471,6 +472,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { latestTurn: { turnId: asTurnId("turn-1"), state: "completed", + initiatingUserMessageId: null, requestedAt: "2026-02-24T00:00:08.000Z", startedAt: "2026-02-24T00:00:08.000Z", completedAt: "2026-02-24T00:00:08.000Z", @@ -1649,6 +1651,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { assert.equal(threadShell._tag, "Some"); if (threadShell._tag === "Some") { assert.equal(threadShell.value.latestTurn?.turnId, asTurnId("turn-running")); + assert.equal( + threadShell.value.latestTurn?.initiatingUserMessageId, + asMessageId("message-user-2"), + ); assert.equal(threadShell.value.latestTurn?.state, "running"); assert.equal(threadShell.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z"); } @@ -1657,6 +1663,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { assert.equal(threadDetail._tag, "Some"); if (threadDetail._tag === "Some") { assert.equal(threadDetail.value.latestTurn?.turnId, asTurnId("turn-running")); + assert.equal( + threadDetail.value.latestTurn?.initiatingUserMessageId, + asMessageId("message-user-2"), + ); assert.equal(threadDetail.value.latestTurn?.state, "running"); assert.equal(threadDetail.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z"); } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5f82a26e2a36..1d6bf5d38738 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -142,6 +142,7 @@ const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( const ProjectionLatestTurnDbRowSchema = Schema.Struct({ threadId: ProjectionThread.fields.threadId, turnId: TurnId, + pendingMessageId: Schema.NullOr(MessageId), state: Schema.String, requestedAt: IsoDateTime, startedAt: Schema.NullOr(IsoDateTime), @@ -322,6 +323,7 @@ function mapLatestTurn( ): OrchestrationLatestTurn { return { turnId: row.turnId, + initiatingUserMessageId: row.pendingMessageId, state: row.state === "error" ? "error" @@ -772,6 +774,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { SELECT turns.thread_id AS "threadId", turns.turn_id AS "turnId", + turns.pending_message_id AS "pendingMessageId", turns.state, turns.requested_at AS "requestedAt", turns.started_at AS "startedAt", @@ -796,6 +799,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { SELECT turns.thread_id AS "threadId", turns.turn_id AS "turnId", + turns.pending_message_id AS "pendingMessageId", turns.state, turns.requested_at AS "requestedAt", turns.started_at AS "startedAt", @@ -822,6 +826,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { SELECT turns.thread_id AS "threadId", turns.turn_id AS "turnId", + turns.pending_message_id AS "pendingMessageId", turns.state, turns.requested_at AS "requestedAt", turns.started_at AS "startedAt", @@ -1391,6 +1396,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { SELECT turns.thread_id AS "threadId", turns.turn_id AS "turnId", + turns.pending_message_id AS "pendingMessageId", turns.state, turns.requested_at AS "requestedAt", turns.started_at AS "startedAt", @@ -2002,6 +2008,7 @@ pending_approval_requests AS ( } latestTurnByThread.set(row.threadId, { turnId: row.turnId, + initiatingUserMessageId: row.pendingMessageId, state: row.state === "error" ? "error" diff --git a/apps/web/package.json b/apps/web/package.json index 0cc85504a0dc..b70aeeb934d5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,7 +33,7 @@ "@tanstack/react-router": "^1.160.2", "class-variance-authority": "^0.7.1", "culori": "^4.0.2", - "cuelume": "^0.1.0", + "cuelume": "^0.2.1", "effect": "catalog:", "heic-to": "^1.5.2", "jose": "catalog:", diff --git a/apps/web/src/interactionSounds.test.ts b/apps/web/src/interactionSounds.test.ts index 36edb11ece0e..365f63dabafa 100644 --- a/apps/web/src/interactionSounds.test.ts +++ b/apps/web/src/interactionSounds.test.ts @@ -1,10 +1,12 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { TurnId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { captureThreadSoundState, captureThreadSoundStateWhileSettingsHydrating, deriveInteractionSoundCues, + selectLiveThreadShells, + shouldPlayInteractionSound, } from "./interactionSounds"; function makeThread(overrides: Partial = {}): EnvironmentThreadShell { @@ -32,18 +34,45 @@ function makeThread(overrides: Partial = {}): Environmen } describe("interaction sounds", () => { - it("plays success when a turn becomes completed", () => { + it("plays success when a turn is associated with its initiating user message", () => { const running = makeThread({ latestTurn: { turnId: TurnId.make("turn-1"), + initiatingUserMessageId: MessageId.make("message-1"), state: "running", - requestedAt: "2026-07-11T12:00:00.000Z", - startedAt: "2026-07-11T12:00:01.000Z", + requestedAt: "2026-07-11T12:00:02.000Z", + startedAt: "2026-07-11T12:00:03.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("supports older shells where a normal user message precedes provider startup", () => { + const running = makeThread({ + latestUserMessageAt: "2026-07-11T12:00:00.000Z", + latestTurn: { + turnId: TurnId.make("legacy-turn"), + state: "running", + requestedAt: "2026-07-11T12:00:02.000Z", + startedAt: "2026-07-11T12:00:03.000Z", completedAt: null, assistantMessageId: null, }, }); const completed = makeThread({ + latestUserMessageAt: running.latestUserMessageAt, latestTurn: { ...running.latestTurn!, state: "completed", @@ -56,6 +85,58 @@ describe("interaction sounds", () => { ]); }); + it("does not let a later steering message associate a background subagent turn", () => { + const backgroundRunning = makeThread({ + latestUserMessageAt: "2026-07-11T12:00:00.000Z", + latestTurn: { + turnId: TurnId.make("subagent-turn"), + initiatingUserMessageId: null, + state: "running", + requestedAt: "2026-07-11T12:05:00.000Z", + startedAt: "2026-07-11T12:05:00.000Z", + completedAt: null, + assistantMessageId: null, + }, + }); + const completedAfterSteering = makeThread({ + latestUserMessageAt: "2026-07-11T12:06:00.000Z", + latestTurn: { + ...backgroundRunning.latestTurn!, + state: "completed", + completedAt: "2026-07-11T12:06:05.000Z", + }, + }); + + expect( + deriveInteractionSoundCues(captureThreadSoundState([backgroundRunning]), [ + completedAfterSteering, + ]), + ).toEqual([]); + }); + + it("does not associate an old user message with later background work", () => { + const beforeBackgroundWork = makeThread({ + latestUserMessageAt: "2026-07-11T12:00:00.000Z", + }); + const completedBackgroundTurn = makeThread({ + latestUserMessageAt: beforeBackgroundWork.latestUserMessageAt, + latestTurn: { + turnId: TurnId.make("background-turn"), + state: "completed", + requestedAt: "2026-07-11T12:05:00.000Z", + startedAt: "2026-07-11T12:05:00.000Z", + completedAt: "2026-07-11T12:05:05.000Z", + assistantMessageId: null, + }, + }); + + expect( + deriveInteractionSoundCues(captureThreadSoundState([beforeBackgroundWork]), [ + completedBackgroundTurn, + ]), + ).toEqual([]); + }); + it("plays bloom when a thread starts requesting user input", () => { const thread = makeThread(); @@ -78,6 +159,7 @@ describe("interaction sounds", () => { it("does not replay cues for unchanged state", () => { const thread = makeThread({ + latestUserMessageAt: "2026-07-11T12:00:00.000Z", hasPendingUserInput: true, hasPendingApprovals: true, latestTurn: { @@ -93,6 +175,31 @@ describe("interaction sounds", () => { 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:01.000Z", + startedAt: "2026-07-11T12:00:02.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, @@ -111,16 +218,18 @@ describe("interaction sounds", () => { 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", + requestedAt: "2026-07-11T12:00:01.000Z", startedAt: "2026-07-11T12:00:01.000Z", completedAt: null, assistantMessageId: null, }, }); const completed = makeThread({ + latestUserMessageAt: running.latestUserMessageAt, latestTurn: { ...running.latestTurn!, state: "completed", @@ -144,4 +253,23 @@ describe("interaction sounds", () => { deriveInteractionSoundCues(withThread, [makeThread({ hasPendingUserInput: true })]), ).toEqual([]); }); + + it("keeps input-request cues enabled when completion sounds are disabled", () => { + expect(shouldPlayInteractionSound("success", false)).toBe(false); + expect(shouldPlayInteractionSound("bloom", false)).toBe(true); + }); + + it("excludes cached thread shells until their environment is live", () => { + const cached = makeThread({ environmentId: EnvironmentId.make("cached-environment") }); + const live = makeThread({ + environmentId: EnvironmentId.make("live-environment"), + id: ThreadId.make("thread-2"), + }); + + expect( + selectLiveThreadShells([cached, live], new Set([live.environmentId])).map( + (thread) => thread.id, + ), + ).toEqual(["thread-2"]); + }); }); diff --git a/apps/web/src/interactionSounds.ts b/apps/web/src/interactionSounds.ts index eedf1f42dc98..ca759f7deb9a 100644 --- a/apps/web/src/interactionSounds.ts +++ b/apps/web/src/interactionSounds.ts @@ -4,11 +4,26 @@ export type InteractionSoundCue = "bloom" | "success"; interface ThreadSoundState { readonly completedTurn: string | null; + readonly userInitiatedTurn: string | null; readonly hasPendingUserAction: boolean; } export type ThreadSoundStateByKey = ReadonlyMap; +export function shouldPlayInteractionSound( + cue: InteractionSoundCue, + completionSoundEnabled: boolean, +): boolean { + return cue !== "success" || completionSoundEnabled; +} + +export function selectLiveThreadShells( + threads: ReadonlyArray, + liveEnvironmentIds: ReadonlySet, +): ReadonlyArray { + return threads.filter((thread) => liveEnvironmentIds.has(thread.environmentId)); +} + function threadKey(thread: EnvironmentThreadShell): string { return `${thread.environmentId}:${thread.id}`; } @@ -18,7 +33,45 @@ function completedTurn(thread: EnvironmentThreadShell): string | null { if (latestTurn?.state !== "completed" || latestTurn.completedAt === null) { return null; } - return `${latestTurn.turnId}:${latestTurn.completedAt}`; + return latestTurn.turnId; +} + +const USER_TURN_START_WINDOW_MS = 2 * 60 * 1_000; + +function userInitiatedTurn(thread: EnvironmentThreadShell): string | null { + const latestTurn = thread.latestTurn; + if (latestTurn === null) { + return null; + } + + // Current servers expose the exact message/turn association from the + // projection. A null association explicitly identifies synthetic provider + // work, while undefined means the shell came from an older server. + if (latestTurn.initiatingUserMessageId !== undefined) { + return latestTurn.initiatingUserMessageId === null ? null : latestTurn.turnId; + } + + if (thread.latestUserMessageAt === null) { + return null; + } + + const requestedAt = Date.parse(latestTurn.requestedAt); + const latestUserMessageAt = Date.parse(thread.latestUserMessageAt); + if (!Number.isFinite(requestedAt) || !Number.isFinite(latestUserMessageAt)) { + return null; + } + + // A normal prompt is recorded before provider startup, while synthetic + // background turns have no nearby initiating message. Keep the same bounded + // adoption window used for queued turn starts so an old prompt cannot claim + // unrelated background work. A later steering message is also excluded + // because it falls after requestedAt. + const startupDelay = requestedAt - latestUserMessageAt; + if (startupDelay < 0 || startupDelay > USER_TURN_START_WINDOW_MS) { + return null; + } + + return latestTurn.turnId; } export function captureThreadSoundState( @@ -29,6 +82,7 @@ export function captureThreadSoundState( threadKey(thread), { completedTurn: completedTurn(thread), + userInitiatedTurn: userInitiatedTurn(thread), hasPendingUserAction: thread.hasPendingUserInput || thread.hasPendingApprovals, }, ]), @@ -67,8 +121,14 @@ export function deriveInteractionSoundCues( for (const thread of threads) { const prior = previous.get(threadKey(thread)); const nextCompletedTurn = completedTurn(thread); + const nextUserInitiatedTurn = userInitiatedTurn(thread); - if (prior && nextCompletedTurn !== null && prior.completedTurn !== nextCompletedTurn) { + if ( + prior && + nextCompletedTurn !== null && + prior.completedTurn !== nextCompletedTurn && + nextUserInitiatedTurn === nextCompletedTurn + ) { cues.push("success"); } const hasPendingUserAction = thread.hasPendingUserInput || thread.hasPendingApprovals; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index b2a6706544a7..5e28444f64c9 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -50,7 +50,7 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme"; import { configureClientTracing } from "../observability/clientTracing"; import { resolveInitialServerAuthGateState } from "../environments/primary"; import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; -import { shellEnvironment } from "../state/shell"; +import { liveEnvironmentIdsAtom, shellEnvironment } from "../state/shell"; import { useAtomValue } from "@effect/atom-react"; import { useAtomCommand } from "../state/use-atom-command"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; @@ -69,6 +69,8 @@ import { captureThreadSoundState, captureThreadSoundStateWhileSettingsHydrating, deriveInteractionSoundCues, + selectLiveThreadShells, + shouldPlayInteractionSound, type ThreadSoundStateByKey, } from "../interactionSounds"; import { @@ -277,27 +279,56 @@ function FontAppearanceSync() { function InteractionSoundCoordinator() { const threads = useThreadShells(); + const liveEnvironmentIds = useAtomValue(liveEnvironmentIdsAtom); const completionSoundEnabled = useClientSettings((settings) => settings.enableCompletionSounds); const settingsHydrated = useClientSettingsHydrated(); const previousStateRef = useRef(null); + const liveThreads = useMemo( + () => selectLiveThreadShells(threads, liveEnvironmentIds), + [liveEnvironmentIds, threads], + ); + + useEffect(() => { + const cleanup = () => { + document.removeEventListener("pointerdown", prime, true); + document.removeEventListener("keydown", prime, true); + }; + const prime = (event: Event) => { + if (!event.isTrusted) { + return; + } + // Cuelume owns a lazy AudioContext. Touch it from the first real user + // gesture at an effectively inaudible level so later background cues + // are not rejected by browser autoplay policy. + play("press", { volume: 0.0001 }); + cleanup(); + }; + + document.addEventListener("pointerdown", prime, true); + document.addEventListener("keydown", prime, true); + return cleanup; + }, []); useEffect(() => { if (!settingsHydrated) { previousStateRef.current = captureThreadSoundStateWhileSettingsHydrating( previousStateRef.current, - threads, + liveThreads, ); return; } const previous = previousStateRef.current; - if (completionSoundEnabled && previous !== null) { - for (const cue of deriveInteractionSoundCues(previous, threads)) { + if (previous !== null) { + for (const cue of deriveInteractionSoundCues(previous, liveThreads)) { + if (!shouldPlayInteractionSound(cue, completionSoundEnabled)) { + continue; + } play(cue); } } - previousStateRef.current = captureThreadSoundState(threads); - }, [completionSoundEnabled, settingsHydrated, threads]); + previousStateRef.current = captureThreadSoundState(liveThreads); + }, [completionSoundEnabled, liveThreads, settingsHydrated]); return null; } diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index b1719819da9d..e55d8751681c 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -21,6 +21,24 @@ export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntim export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom); +let previousLiveEnvironmentIds: ReadonlySet = new Set(); +export const liveEnvironmentIdsAtom = Atom.make((get): ReadonlySet => { + const next = new Set(); + for (const environmentId of get(environmentCatalog.catalogValueAtom).entries.keys()) { + if (get(environmentShell.stateValueAtom(environmentId)).status === "live") { + next.add(environmentId); + } + } + if ( + next.size === previousLiveEnvironmentIds.size && + [...next].every((environmentId) => previousLiveEnvironmentIds.has(environmentId)) + ) { + return previousLiveEnvironmentIds; + } + previousLiveEnvironmentIds = next; + return previousLiveEnvironmentIds; +}).pipe(Atom.withLabel("web-live-environment-ids")); + export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { const catalog = AsyncResult.value(get(environmentCatalog.catalogAtom)); if (Option.isNone(catalog)) { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index da4eac53d9e6..5a89703ee261 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -459,6 +459,7 @@ export type OrchestrationLatestTurnState = typeof OrchestrationLatestTurnState.T export const OrchestrationLatestTurn = Schema.Struct({ turnId: TurnId, + initiatingUserMessageId: Schema.optional(Schema.NullOr(MessageId)), state: OrchestrationLatestTurnState, requestedAt: IsoDateTime, startedAt: Schema.NullOr(IsoDateTime), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cecfa332d297..12799bcc31c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -615,8 +615,8 @@ importers: specifier: ^4.0.2 version: 4.0.2 cuelume: - specifier: ^0.1.0 - version: 0.1.0 + specifier: ^0.2.1 + version: 0.2.1 effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) @@ -6341,8 +6341,8 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - cuelume@0.1.0: - resolution: {integrity: sha512-J2RRt92Gh1a0ztnjwLEyLfGnDIiWz14vC9aTmg7ZgxRkEk8vR+QxFOz+v1tqntFvl06n5G4si8QCQgHfWjnneA==} + cuelume@0.2.1: + resolution: {integrity: sha512-UFN5GtPRhnkL55r5rz7oalgy72XTtV50QqOec3+wHer0O1hlwOteej99dlRxeFX2ViLUD/CpCu47v5XCTa2tEQ==} culori@4.0.2: resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} @@ -16493,7 +16493,7 @@ snapshots: csstype@3.2.3: {} - cuelume@0.1.0: {} + cuelume@0.2.1: {} culori@4.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e8f5a1afa0e5..94f1159150a9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -79,7 +79,7 @@ minimumReleaseAgeExclude: - "@effect/sql-sqlite-bun@4.0.0-beta.103" - "@effect/vitest@4.0.0-beta.103" - alchemy@2.0.0-beta.65 - - cuelume@0.1.0 + - cuelume@0.2.1 - effect@4.0.0-beta.103 - "@legendapp/list@3.3.5" - "@expo/cli@57.0.20" From 5695996df37da2afde93a1d8d43d4268756bd154 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 13:42:18 -0400 Subject: [PATCH 05/14] feat(mobile): add interaction sounds --- .../assets/interaction-sounds/bloom.wav | Bin 0 -> 75014 bytes .../assets/interaction-sounds/success.wav | Bin 0 -> 66194 bytes apps/mobile/src/App.tsx | 2 + .../InteractionSoundCoordinator.tsx | 68 +++++++++++ .../interactionSoundPlayback.test.ts | 36 ++++++ .../interactionSoundPlayback.ts | 10 ++ .../features/settings/SettingsRouteScreen.tsx | 12 ++ .../src/persistence/mobile-preferences.ts | 5 + apps/mobile/src/state/shell.ts | 20 ++++ apps/web/src/routes/__root.tsx | 2 +- docs/README.md | 1 + docs/user/interaction-sounds.md | 23 ++++ packages/client-runtime/package.json | 4 + .../src/interactionSounds.test.ts | 4 +- .../client-runtime}/src/interactionSounds.ts | 2 +- scripts/generate-interaction-sound-assets.mjs | 110 ++++++++++++++++++ 16 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/assets/interaction-sounds/bloom.wav create mode 100644 apps/mobile/assets/interaction-sounds/success.wav create mode 100644 apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx create mode 100644 apps/mobile/src/features/interaction-sounds/interactionSoundPlayback.test.ts create mode 100644 apps/mobile/src/features/interaction-sounds/interactionSoundPlayback.ts create mode 100644 docs/user/interaction-sounds.md rename {apps/web => packages/client-runtime}/src/interactionSounds.test.ts (98%) rename {apps/web => packages/client-runtime}/src/interactionSounds.ts (98%) create mode 100644 scripts/generate-interaction-sound-assets.mjs diff --git a/apps/mobile/assets/interaction-sounds/bloom.wav b/apps/mobile/assets/interaction-sounds/bloom.wav new file mode 100644 index 0000000000000000000000000000000000000000..251a32d096f52706589f96ae45400a02a87c4b24 GIT binary patch literal 75014 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 + diff --git a/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx b/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx new file mode 100644 index 000000000000..bc0f64dd8fb4 --- /dev/null +++ b/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx @@ -0,0 +1,68 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + captureThreadSoundState, + captureThreadSoundStateWhileSettingsHydrating, + deriveInteractionSoundCues, + selectLiveThreadShells, + shouldPlayInteractionSound, + type InteractionSoundCue, + type ThreadSoundStateByKey, +} from "@t3tools/client-runtime/interaction-sounds"; +import { useAudioPlayer } from "expo-audio"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useEffect, useMemo, useRef } from "react"; + +import { mobilePreferencesAtom } from "../../state/preferences"; +import { liveEnvironmentIdsAtom } from "../../state/shell"; +import { useThreadShells } from "../../state/entities"; +import { replayInteractionSound } from "./interactionSoundPlayback"; + +const SUCCESS_SOUND = require("../../../assets/interaction-sounds/success.wav"); +const BLOOM_SOUND = require("../../../assets/interaction-sounds/bloom.wav"); + +type InteractionSoundPlayers = Readonly< + Record> +>; + +export function InteractionSoundCoordinator() { + const threads = useThreadShells(); + const liveEnvironmentIds = useAtomValue(liveEnvironmentIdsAtom); + const preferences = useAtomValue(mobilePreferencesAtom); + const successPlayer = useAudioPlayer(SUCCESS_SOUND); + const bloomPlayer = useAudioPlayer(BLOOM_SOUND); + const previousStateRef = useRef(null); + const liveThreads = useMemo( + () => selectLiveThreadShells(threads, liveEnvironmentIds), + [liveEnvironmentIds, threads], + ); + const players = useMemo( + () => ({ bloom: bloomPlayer, success: successPlayer }), + [bloomPlayer, successPlayer], + ); + + useEffect(() => { + if (!AsyncResult.isSuccess(preferences)) { + previousStateRef.current = captureThreadSoundStateWhileSettingsHydrating( + previousStateRef.current, + liveThreads, + ); + return; + } + + const previous = previousStateRef.current; + if (previous !== null) { + const completionSoundEnabled = preferences.value.completionSoundEnabled !== false; + for (const cue of deriveInteractionSoundCues(previous, liveThreads)) { + if (!shouldPlayInteractionSound(cue, completionSoundEnabled)) { + continue; + } + void replayInteractionSound(players[cue]).catch((error: unknown) => { + console.warn(`[interaction-sounds] Could not play ${cue} cue.`, error); + }); + } + } + previousStateRef.current = captureThreadSoundState(liveThreads); + }, [liveThreads, players, preferences]); + + return null; +} diff --git a/apps/mobile/src/features/interaction-sounds/interactionSoundPlayback.test.ts b/apps/mobile/src/features/interaction-sounds/interactionSoundPlayback.test.ts new file mode 100644 index 000000000000..16cc41ef065a --- /dev/null +++ b/apps/mobile/src/features/interaction-sounds/interactionSoundPlayback.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { replayInteractionSound } from "./interactionSoundPlayback"; + +describe("mobile interaction sounds", () => { + it("starts a fresh player without seeking", async () => { + const player = { + currentTime: 0, + play: vi.fn(), + seekTo: vi.fn(() => Promise.resolve()), + }; + + await replayInteractionSound(player); + + expect(player.seekTo).not.toHaveBeenCalled(); + expect(player.play).toHaveBeenCalledTimes(1); + }); + + it("rewinds a previously played cue before replaying it", async () => { + const calls: string[] = []; + const player = { + currentTime: 0.4, + play: vi.fn(() => { + calls.push("play"); + }), + seekTo: vi.fn(async () => { + calls.push("seek"); + }), + }; + + await replayInteractionSound(player); + + expect(player.seekTo).toHaveBeenCalledWith(0); + expect(calls).toEqual(["seek", "play"]); + }); +}); diff --git a/apps/mobile/src/features/interaction-sounds/interactionSoundPlayback.ts b/apps/mobile/src/features/interaction-sounds/interactionSoundPlayback.ts new file mode 100644 index 000000000000..205ae67e3f4c --- /dev/null +++ b/apps/mobile/src/features/interaction-sounds/interactionSoundPlayback.ts @@ -0,0 +1,10 @@ +import type { AudioPlayer } from "expo-audio"; + +type InteractionSoundPlayer = Pick; + +export async function replayInteractionSound(player: InteractionSoundPlayer): Promise { + if (player.currentTime > 0) { + await player.seekTo(0); + } + player.play(); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 3bd25a20c8da..6542573a7a65 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -542,11 +542,23 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const completionSoundEnabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.completionSoundEnabled !== false + : true; + return ( + savePreferences({ completionSoundEnabled: value })} + /> ); } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index f14a73f15cb2..1d3b6946c233 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -21,6 +21,7 @@ export interface Preferences { readonly lightThemeId?: MobileThemeId; readonly darkThemeId?: MobileThemeId; readonly themeMode?: MobileThemeMode; + readonly completionSoundEnabled?: boolean; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; readonly markdownFontSize?: number; @@ -90,6 +91,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { lightThemeId?: MobileThemeId; darkThemeId?: MobileThemeId; themeMode?: MobileThemeMode; + completionSoundEnabled?: boolean; baseFontSize?: number; terminalFontSize?: number | null; markdownFontSize?: number; @@ -133,6 +135,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.themeMode = parsed.themeMode; } + if (typeof parsed.completionSoundEnabled === "boolean") { + preferences.completionSoundEnabled = parsed.completionSoundEnabled; + } if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { preferences.terminalFontSize = parsed.terminalFontSize; diff --git a/apps/mobile/src/state/shell.ts b/apps/mobile/src/state/shell.ts index e879dd25e292..5ba62be5223f 100644 --- a/apps/mobile/src/state/shell.ts +++ b/apps/mobile/src/state/shell.ts @@ -4,6 +4,8 @@ import { createEnvironmentSnapshotAtom, createShellEnvironmentAtoms, } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -15,3 +17,21 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, shellStateValueAtom: environmentShell.stateValueAtom, }); + +let previousLiveEnvironmentIds: ReadonlySet = new Set(); +export const liveEnvironmentIdsAtom = Atom.make((get): ReadonlySet => { + const next = new Set(); + for (const environmentId of get(environmentCatalog.catalogValueAtom).entries.keys()) { + if (get(environmentShell.stateValueAtom(environmentId)).status === "live") { + next.add(environmentId); + } + } + if ( + next.size === previousLiveEnvironmentIds.size && + [...next].every((environmentId) => previousLiveEnvironmentIds.has(environmentId)) + ) { + return previousLiveEnvironmentIds; + } + previousLiveEnvironmentIds = next; + return previousLiveEnvironmentIds; +}).pipe(Atom.withLabel("mobile-live-environment-ids")); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 5e28444f64c9..500cad33a01c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -72,7 +72,7 @@ import { selectLiveThreadShells, shouldPlayInteractionSound, type ThreadSoundStateByKey, -} from "../interactionSounds"; +} from "@t3tools/client-runtime/interaction-sounds"; import { createKeybindingsUpdateToastController, type KeybindingsUpdateToastController, diff --git a/docs/README.md b/docs/README.md index 4e6f82bfb826..67d58cbf3f37 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ - [Source control](./user/source-control.md) - [Project settings](./user/project-settings.md) - [Appearance and themes](./user/appearance.md) +- [Interaction sounds](./user/interaction-sounds.md) - [Keyboard shortcuts](./user/keybindings.md) - [Import browser sessions](./user/browser-import.md) - [Usage and limits](./user/usage.md) diff --git a/docs/user/interaction-sounds.md b/docs/user/interaction-sounds.md new file mode 100644 index 000000000000..3b35cedbc660 --- /dev/null +++ b/docs/user/interaction-sounds.md @@ -0,0 +1,23 @@ +# Interaction Sounds + +T3 Code plays short interaction sounds for important thread transitions on web, desktop, and +mobile: + +- **Completion:** a success cue plays when a turn started by you completes, even if another thread + is open. +- **Input required:** a bloom cue plays when a thread begins waiting for your input or approval. + +Sounds do not play for cached startup state, unchanged state, or background provider work that was +not started by a user message. + +## Completion Sound Setting + +Completion sounds are enabled by default. + +- On web and desktop, open **Settings** → **General** → **Completion sound**. +- On mobile, open **Settings** → **General** → **Completion Sound**. + +Turning this setting off disables only the completion cue. The input-required cue remains enabled so +T3 Code can still alert you when an agent is blocked on your response. + +The mobile setting is stored on that device. Changing it does not change the web or desktop setting. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index fdab38c9cc62..3cc3357963f2 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -75,6 +75,10 @@ "types": "./src/operations/projects.ts", "default": "./src/operations/projects.ts" }, + "./interaction-sounds": { + "types": "./src/interactionSounds.ts", + "default": "./src/interactionSounds.ts" + }, "./platform": { "types": "./src/platform/index.ts", "default": "./src/platform/index.ts" diff --git a/apps/web/src/interactionSounds.test.ts b/packages/client-runtime/src/interactionSounds.test.ts similarity index 98% rename from apps/web/src/interactionSounds.test.ts rename to packages/client-runtime/src/interactionSounds.test.ts index 365f63dabafa..61588b07808a 100644 --- a/apps/web/src/interactionSounds.test.ts +++ b/packages/client-runtime/src/interactionSounds.test.ts @@ -1,13 +1,13 @@ -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import type { EnvironmentThreadShell } from "./state/shell.ts"; import { captureThreadSoundState, captureThreadSoundStateWhileSettingsHydrating, deriveInteractionSoundCues, selectLiveThreadShells, shouldPlayInteractionSound, -} from "./interactionSounds"; +} from "./interactionSounds.ts"; function makeThread(overrides: Partial = {}): EnvironmentThreadShell { return { diff --git a/apps/web/src/interactionSounds.ts b/packages/client-runtime/src/interactionSounds.ts similarity index 98% rename from apps/web/src/interactionSounds.ts rename to packages/client-runtime/src/interactionSounds.ts index ca759f7deb9a..f4d1aaf72fb6 100644 --- a/apps/web/src/interactionSounds.ts +++ b/packages/client-runtime/src/interactionSounds.ts @@ -1,4 +1,4 @@ -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentThreadShell } from "./state/shell.ts"; export type InteractionSoundCue = "bloom" | "success"; diff --git a/scripts/generate-interaction-sound-assets.mjs b/scripts/generate-interaction-sound-assets.mjs new file mode 100644 index 000000000000..742e7efd65fe --- /dev/null +++ b/scripts/generate-interaction-sound-assets.mjs @@ -0,0 +1,110 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const SAMPLE_RATE = 44_100; +const TARGET_PEAK = 0.42; +const scriptDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const outputDirectory = NodePath.resolve( + scriptDirectory, + "../apps/mobile/assets/interaction-sounds", +); + +const recipes = { + bloom: { + duration: 0.85, + masterGain: 0.5, + layers: [ + { frequency: 528, attack: 0.06, decay: 0.32, peak: 0.06 }, + { frequency: 528, detune: 12, attack: 0.06, decay: 0.34, peak: 0.05 }, + ], + shimmer: { delay: 0.15, feedback: 0.2, wet: 0.12 }, + }, + success: { + duration: 0.75, + masterGain: 0.5, + layers: [ + { frequency: 880, attack: 0.004, decay: 0.09, peak: 0.06 }, + { frequency: 1108.73, offset: 0.06, attack: 0.004, decay: 0.1, peak: 0.06 }, + { frequency: 1318.51, offset: 0.12, attack: 0.004, decay: 0.18, peak: 0.07 }, + ], + shimmer: { delay: 0.1, feedback: 0.22, wet: 0.16 }, + }, +}; + +function envelope(layer, elapsed) { + if (elapsed < 0) return 0; + if (elapsed < layer.attack) return elapsed / layer.attack; + const decayElapsed = elapsed - layer.attack; + if (decayElapsed >= layer.decay) return 0; + return Math.exp((-7 * decayElapsed) / layer.decay); +} + +function renderRecipe(recipe) { + const sampleCount = Math.ceil(recipe.duration * SAMPLE_RATE); + const dry = new Float64Array(sampleCount); + + for (const layer of recipe.layers) { + const offset = layer.offset ?? 0; + const frequency = layer.frequency * 2 ** ((layer.detune ?? 0) / 1200); + for (let index = 0; index < sampleCount; index += 1) { + const time = index / SAMPLE_RATE; + const elapsed = time - offset; + const amplitude = envelope(layer, elapsed); + if (amplitude === 0) continue; + dry[index] += + Math.sin(2 * Math.PI * frequency * elapsed) * amplitude * layer.peak * recipe.masterGain; + } + } + + const output = Float64Array.from(dry); + const delaySamples = Math.round(recipe.shimmer.delay * SAMPLE_RATE); + let repeat = 1; + let repeatGain = recipe.shimmer.wet; + while (repeatGain >= 0.001) { + const offset = delaySamples * repeat; + for (let index = 0; index + offset < sampleCount; index += 1) { + output[index + offset] += dry[index] * repeatGain; + } + repeat += 1; + repeatGain *= recipe.shimmer.feedback; + } + + let peak = 0; + for (const sample of output) peak = Math.max(peak, Math.abs(sample)); + const scale = peak === 0 ? 1 : TARGET_PEAK / peak; + return Int16Array.from(output, (sample) => + Math.round(Math.max(-1, Math.min(1, sample * scale)) * 0x7fff), + ); +} + +function encodeWav(samples) { + const bytesPerSample = 2; + const dataSize = samples.length * bytesPerSample; + const buffer = Buffer.alloc(44 + dataSize); + buffer.write("RIFF", 0); + buffer.writeUInt32LE(36 + dataSize, 4); + buffer.write("WAVE", 8); + buffer.write("fmt ", 12); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(1, 22); + buffer.writeUInt32LE(SAMPLE_RATE, 24); + buffer.writeUInt32LE(SAMPLE_RATE * bytesPerSample, 28); + buffer.writeUInt16LE(bytesPerSample, 32); + buffer.writeUInt16LE(16, 34); + buffer.write("data", 36); + buffer.writeUInt32LE(dataSize, 40); + for (let index = 0; index < samples.length; index += 1) { + buffer.writeInt16LE(samples[index], 44 + index * bytesPerSample); + } + return buffer; +} + +NodeFS.mkdirSync(outputDirectory, { recursive: true }); +for (const [name, recipe] of Object.entries(recipes)) { + NodeFS.writeFileSync( + NodePath.resolve(outputDirectory, `${name}.wav`), + encodeWav(renderRecipe(recipe)), + ); +} From 33ac4817d120d1668d6748204f8791072cd97a78 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 13:52:08 -0400 Subject: [PATCH 06/14] fix(client): preserve interaction sound transitions --- .../InteractionSoundCoordinator.tsx | 8 +- apps/web/src/routes/__root.tsx | 8 +- .../src/interactionSounds.test.ts | 73 +++++++++++++++++++ .../client-runtime/src/interactionSounds.ts | 30 +++++++- 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx b/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx index bc0f64dd8fb4..1e3fc4af053e 100644 --- a/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx +++ b/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { captureThreadSoundState, + captureThreadSoundStatePreservingUnobserved, captureThreadSoundStateWhileSettingsHydrating, deriveInteractionSoundCues, selectLiveThreadShells, @@ -61,8 +62,11 @@ export function InteractionSoundCoordinator() { }); } } - previousStateRef.current = captureThreadSoundState(liveThreads); - }, [liveThreads, players, preferences]); + previousStateRef.current = + previous === null + ? captureThreadSoundState(liveThreads) + : captureThreadSoundStatePreservingUnobserved(previous, liveThreads, threads); + }, [liveThreads, players, preferences, threads]); return null; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 500cad33a01c..da6168502810 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -67,6 +67,7 @@ import { } from "../state/entities"; import { captureThreadSoundState, + captureThreadSoundStatePreservingUnobserved, captureThreadSoundStateWhileSettingsHydrating, deriveInteractionSoundCues, selectLiveThreadShells, @@ -327,8 +328,11 @@ function InteractionSoundCoordinator() { play(cue); } } - previousStateRef.current = captureThreadSoundState(liveThreads); - }, [completionSoundEnabled, liveThreads, settingsHydrated]); + previousStateRef.current = + previous === null + ? captureThreadSoundState(liveThreads) + : captureThreadSoundStatePreservingUnobserved(previous, liveThreads, threads); + }, [completionSoundEnabled, liveThreads, settingsHydrated, threads]); return null; } diff --git a/packages/client-runtime/src/interactionSounds.test.ts b/packages/client-runtime/src/interactionSounds.test.ts index 61588b07808a..9dfee7342d02 100644 --- a/packages/client-runtime/src/interactionSounds.test.ts +++ b/packages/client-runtime/src/interactionSounds.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentThreadShell } from "./state/shell.ts"; import { captureThreadSoundState, + captureThreadSoundStatePreservingUnobserved, captureThreadSoundStateWhileSettingsHydrating, deriveInteractionSoundCues, selectLiveThreadShells, @@ -157,6 +158,24 @@ describe("interaction sounds", () => { ).toEqual(["bloom"]); }); + it("plays bloom when pending input changes directly to pending approval", () => { + const pendingInput = makeThread({ hasPendingUserInput: true }); + const pendingApproval = makeThread({ hasPendingApprovals: true }); + + expect( + deriveInteractionSoundCues(captureThreadSoundState([pendingInput]), [pendingApproval]), + ).toEqual(["bloom"]); + }); + + it("plays bloom when pending approval changes directly to pending input", () => { + const pendingApproval = makeThread({ hasPendingApprovals: true }); + const pendingInput = makeThread({ hasPendingUserInput: true }); + + expect( + deriveInteractionSoundCues(captureThreadSoundState([pendingApproval]), [pendingInput]), + ).toEqual(["bloom"]); + }); + it("does not replay cues for unchanged state", () => { const thread = makeThread({ latestUserMessageAt: "2026-07-11T12:00:00.000Z", @@ -243,6 +262,60 @@ describe("interaction sounds", () => { expect(deriveInteractionSoundCues(frozen, [completed])).toEqual(["success"]); }); + it("preserves a thread baseline while its environment is synchronizing", () => { + const running = makeThread({ + latestTurn: { + turnId: TurnId.make("turn-1"), + initiatingUserMessageId: MessageId.make("message-1"), + state: "running", + requestedAt: "2026-07-11T12:00:01.000Z", + startedAt: "2026-07-11T12:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + }); + const completedDuringSync = makeThread({ + latestTurn: { + ...running.latestTurn!, + state: "completed", + completedAt: "2026-07-11T12:00:05.000Z", + }, + }); + const beforeSync = captureThreadSoundState([running]); + const whileSynchronizing = captureThreadSoundStatePreservingUnobserved( + beforeSync, + [], + [completedDuringSync], + ); + + expect(deriveInteractionSoundCues(whileSynchronizing, [completedDuringSync])).toEqual([ + "success", + ]); + }); + + it("detects a user-input request received while its environment is synchronizing", () => { + const idle = makeThread(); + const pendingInputDuringSync = makeThread({ hasPendingUserInput: true }); + const beforeSync = captureThreadSoundState([idle]); + const whileSynchronizing = captureThreadSoundStatePreservingUnobserved( + beforeSync, + [], + [pendingInputDuringSync], + ); + + expect(deriveInteractionSoundCues(whileSynchronizing, [pendingInputDuringSync])).toEqual([ + "bloom", + ]); + }); + + it("drops retained baselines for threads that no longer exist", () => { + const thread = makeThread({ hasPendingUserInput: true }); + const beforeRemoval = captureThreadSoundState([thread]); + const afterRemoval = captureThreadSoundStatePreservingUnobserved(beforeRemoval, [], []); + + expect(afterRemoval.size).toBe(0); + }); + it("admits newly seen threads while settings are hydrating", () => { const seeded = captureThreadSoundStateWhileSettingsHydrating(null, []); const withThread = captureThreadSoundStateWhileSettingsHydrating(seeded, [ diff --git a/packages/client-runtime/src/interactionSounds.ts b/packages/client-runtime/src/interactionSounds.ts index f4d1aaf72fb6..c92fa57125d7 100644 --- a/packages/client-runtime/src/interactionSounds.ts +++ b/packages/client-runtime/src/interactionSounds.ts @@ -5,7 +5,8 @@ export type InteractionSoundCue = "bloom" | "success"; interface ThreadSoundState { readonly completedTurn: string | null; readonly userInitiatedTurn: string | null; - readonly hasPendingUserAction: boolean; + readonly hasPendingUserInput: boolean; + readonly hasPendingApprovals: boolean; } export type ThreadSoundStateByKey = ReadonlyMap; @@ -83,12 +84,30 @@ export function captureThreadSoundState( { completedTurn: completedTurn(thread), userInitiatedTurn: userInitiatedTurn(thread), - hasPendingUserAction: thread.hasPendingUserInput || thread.hasPendingApprovals, + hasPendingUserInput: thread.hasPendingUserInput, + hasPendingApprovals: thread.hasPendingApprovals, }, ]), ); } +/** + * Update state for currently live threads while retaining the last trustworthy + * baseline for threads that still exist but are temporarily synchronizing. + */ +export function captureThreadSoundStatePreservingUnobserved( + previous: ThreadSoundStateByKey, + liveThreads: ReadonlyArray, + threads: ReadonlyArray, +): ThreadSoundStateByKey { + const existingThreadKeys = new Set(threads.map(threadKey)); + const next = new Map([...previous].filter(([key]) => existingThreadKeys.has(key))); + for (const [key, state] of captureThreadSoundState(liveThreads)) { + next.set(key, state); + } + return next; +} + /** * While client settings are still hydrating, keep a sound baseline without * advancing known thread state. Newly seen threads are admitted so later @@ -131,8 +150,11 @@ export function deriveInteractionSoundCues( ) { cues.push("success"); } - const hasPendingUserAction = thread.hasPendingUserInput || thread.hasPendingApprovals; - if (prior && hasPendingUserAction && !prior.hasPendingUserAction) { + if ( + prior && + ((thread.hasPendingUserInput && !prior.hasPendingUserInput) || + (thread.hasPendingApprovals && !prior.hasPendingApprovals)) + ) { cues.push("bloom"); } } From 8b2c017376fec014c2a1c6675893a7e8fd2b0176 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 15:49:23 -0400 Subject: [PATCH 07/14] fix(mobile): remove completion sound echo --- .../assets/interaction-sounds/success.wav | Bin 66194 -> 66194 bytes scripts/generate-interaction-sound-assets.mjs | 21 +++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/mobile/assets/interaction-sounds/success.wav b/apps/mobile/assets/interaction-sounds/success.wav index ea780b606c6ebaadea2ee3aca9a938bb49f7884b..72d214295c3b08dbcf65c34455839a4ca22de592 100644 GIT binary patch literal 66194 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 0.001) { - const offset = delaySamples * repeat; - for (let index = 0; index + offset < sampleCount; index += 1) { - output[index + offset] += dry[index] * repeatGain; + if (recipe.shimmer) { + const delaySamples = Math.round(recipe.shimmer.delay * SAMPLE_RATE); + let repeat = 1; + let repeatGain = recipe.shimmer.wet; + while (repeatGain >= 0.001) { + const offset = delaySamples * repeat; + for (let index = 0; index + offset < sampleCount; index += 1) { + output[index + offset] += dry[index] * repeatGain; + } + repeat += 1; + repeatGain *= recipe.shimmer.feedback; } - repeat += 1; - repeatGain *= recipe.shimmer.feedback; } let peak = 0; From fb0c4d52ca62cd6a025cc7928cc45dc2b8855f7b Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 10 Aug 2026 15:30:15 -0400 Subject: [PATCH 08/14] fix(sounds): preserve incremental thread cues --- .../InteractionSoundCoordinator.tsx | 96 +++++++++---- apps/mobile/src/state/entities.ts | 4 + .../components/settings/SettingsPanels.tsx | 2 +- .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/routes/__root.tsx | 94 ++++++++----- docs/user/interaction-sounds.md | 5 +- .../src/interactionSounds.test.ts | 128 +++++++++++------- .../client-runtime/src/interactionSounds.ts | 92 ++++++------- 8 files changed, 265 insertions(+), 161 deletions(-) diff --git a/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx b/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx index 1e3fc4af053e..49cd895be241 100644 --- a/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx +++ b/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx @@ -1,21 +1,19 @@ import { useAtomValue } from "@effect/atom-react"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { - captureThreadSoundState, - captureThreadSoundStatePreservingUnobserved, - captureThreadSoundStateWhileSettingsHydrating, - deriveInteractionSoundCues, - selectLiveThreadShells, + observeThreadSoundState, shouldPlayInteractionSound, type InteractionSoundCue, type ThreadSoundStateByKey, } from "@t3tools/client-runtime/interaction-sounds"; +import type { ScopedThreadRef } from "@t3tools/contracts"; import { useAudioPlayer } from "expo-audio"; import { AsyncResult } from "effect/unstable/reactivity"; import { useEffect, useMemo, useRef } from "react"; import { mobilePreferencesAtom } from "../../state/preferences"; import { liveEnvironmentIdsAtom } from "../../state/shell"; -import { useThreadShells } from "../../state/entities"; +import { useThreadRefs, useThreadShell } from "../../state/entities"; import { replayInteractionSound } from "./interactionSoundPlayback"; const SUCCESS_SOUND = require("../../../assets/interaction-sounds/success.wav"); @@ -26,47 +24,85 @@ type InteractionSoundPlayers = Readonly< >; export function InteractionSoundCoordinator() { - const threads = useThreadShells(); + const threadRefs = useThreadRefs(); const liveEnvironmentIds = useAtomValue(liveEnvironmentIdsAtom); const preferences = useAtomValue(mobilePreferencesAtom); const successPlayer = useAudioPlayer(SUCCESS_SOUND); const bloomPlayer = useAudioPlayer(BLOOM_SOUND); - const previousStateRef = useRef(null); - const liveThreads = useMemo( - () => selectLiveThreadShells(threads, liveEnvironmentIds), - [liveEnvironmentIds, threads], - ); + const previouslyLiveEnvironmentIdsRef = useRef(new Set()); const players = useMemo( () => ({ bloom: bloomPlayer, success: successPlayer }), [bloomPlayer, successPlayer], ); + const settingsHydrated = AsyncResult.isSuccess(preferences); + const completionSoundEnabled = settingsHydrated + ? preferences.value.completionSoundEnabled !== false + : true; useEffect(() => { - if (!AsyncResult.isSuccess(preferences)) { - previousStateRef.current = captureThreadSoundStateWhileSettingsHydrating( - previousStateRef.current, - liveThreads, - ); - return; + for (const environmentId of liveEnvironmentIds) { + previouslyLiveEnvironmentIdsRef.current.add(environmentId); } + }, [liveEnvironmentIds]); + + return threadRefs.map((threadRef) => ( + + )); +} - const previous = previousStateRef.current; - if (previous !== null) { - const completionSoundEnabled = preferences.value.completionSoundEnabled !== false; - for (const cue of deriveInteractionSoundCues(previous, liveThreads)) { - if (!shouldPlayInteractionSound(cue, completionSoundEnabled)) { - continue; - } +function InteractionSoundThreadCoordinator({ + threadRef, + environmentLive, + environmentPreviouslyLive, + completionSoundEnabled, + settingsHydrated, + players, +}: { + readonly threadRef: ScopedThreadRef; + readonly environmentLive: boolean; + readonly environmentPreviouslyLive: boolean; + readonly completionSoundEnabled: boolean; + readonly settingsHydrated: boolean; + readonly players: InteractionSoundPlayers; +}) { + const thread = useThreadShell(threadRef); + const previousStateRef = useRef(null); + + useEffect(() => { + if (thread === null) { + return; + } + const observation = observeThreadSoundState(previousStateRef.current, thread, { + environmentLive, + environmentPreviouslyLive, + settingsHydrated, + }); + previousStateRef.current = observation.state; + for (const cue of observation.cues) { + if (shouldPlayInteractionSound(cue, completionSoundEnabled)) { void replayInteractionSound(players[cue]).catch((error: unknown) => { console.warn(`[interaction-sounds] Could not play ${cue} cue.`, error); }); } } - previousStateRef.current = - previous === null - ? captureThreadSoundState(liveThreads) - : captureThreadSoundStatePreservingUnobserved(previous, liveThreads, threads); - }, [liveThreads, players, preferences, threads]); + }, [ + completionSoundEnabled, + environmentLive, + environmentPreviouslyLive, + players, + settingsHydrated, + thread, + ]); return null; } diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 8199dee34866..52200bb5ec3d 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -33,6 +33,10 @@ export function useThreadShells(): ReadonlyArray { return useAtomValue(environmentThreadShells.threadShellsAtom); } +export function useThreadRefs(): ReadonlyArray { + return useAtomValue(environmentThreadShells.threadRefsAtom); +} + export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null { return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref)); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f0b617ad2666..4f3f7bf49f63 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2237,7 +2237,7 @@ export function GeneralSettingsPanel() { } /> settings.enableCompletionSounds); const settingsHydrated = useClientSettingsHydrated(); - const previousStateRef = useRef(null); - const liveThreads = useMemo( - () => selectLiveThreadShells(threads, liveEnvironmentIds), - [liveEnvironmentIds, threads], - ); + const previouslyLiveEnvironmentIdsRef = useRef(new Set()); useEffect(() => { const cleanup = () => { @@ -311,28 +308,63 @@ function InteractionSoundCoordinator() { }, []); useEffect(() => { - if (!settingsHydrated) { - previousStateRef.current = captureThreadSoundStateWhileSettingsHydrating( - previousStateRef.current, - liveThreads, - ); - return; + for (const environmentId of liveEnvironmentIds) { + previouslyLiveEnvironmentIdsRef.current.add(environmentId); } + }, [liveEnvironmentIds]); + + return threadRefs.map((threadRef) => ( + + )); +} + +function InteractionSoundThreadCoordinator({ + threadRef, + environmentLive, + environmentPreviouslyLive, + completionSoundEnabled, + settingsHydrated, +}: { + readonly threadRef: ScopedThreadRef; + readonly environmentLive: boolean; + readonly environmentPreviouslyLive: boolean; + readonly completionSoundEnabled: boolean; + readonly settingsHydrated: boolean; +}) { + const thread = useThreadShell(threadRef); + const previousStateRef = useRef(null); - const previous = previousStateRef.current; - if (previous !== null) { - for (const cue of deriveInteractionSoundCues(previous, liveThreads)) { - if (!shouldPlayInteractionSound(cue, completionSoundEnabled)) { - continue; - } + useEffect(() => { + if (thread === null) { + return; + } + const observation = observeThreadSoundState(previousStateRef.current, thread, { + environmentLive, + environmentPreviouslyLive, + settingsHydrated, + }); + previousStateRef.current = observation.state; + for (const cue of observation.cues) { + if (shouldPlayInteractionSound(cue, completionSoundEnabled)) { play(cue); } } - previousStateRef.current = - previous === null - ? captureThreadSoundState(liveThreads) - : captureThreadSoundStatePreservingUnobserved(previous, liveThreads, threads); - }, [completionSoundEnabled, liveThreads, settingsHydrated, threads]); + }, [ + completionSoundEnabled, + environmentLive, + environmentPreviouslyLive, + settingsHydrated, + thread, + ]); return null; } diff --git a/docs/user/interaction-sounds.md b/docs/user/interaction-sounds.md index 3b35cedbc660..be809650856f 100644 --- a/docs/user/interaction-sounds.md +++ b/docs/user/interaction-sounds.md @@ -7,8 +7,9 @@ mobile: is open. - **Input required:** a bloom cue plays when a thread begins waiting for your input or approval. -Sounds do not play for cached startup state, unchanged state, or background provider work that was -not started by a user message. +Completion sounds do not play for cached startup state, unchanged state, or background provider +work that was not started by a user message. Input-required sounds can still play for background +work so you know when an agent is blocked on your response. ## Completion Sound Setting diff --git a/packages/client-runtime/src/interactionSounds.test.ts b/packages/client-runtime/src/interactionSounds.test.ts index 9dfee7342d02..4468732f2177 100644 --- a/packages/client-runtime/src/interactionSounds.test.ts +++ b/packages/client-runtime/src/interactionSounds.test.ts @@ -1,12 +1,10 @@ -import { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import { MessageId, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentThreadShell } from "./state/shell.ts"; import { captureThreadSoundState, - captureThreadSoundStatePreservingUnobserved, - captureThreadSoundStateWhileSettingsHydrating, deriveInteractionSoundCues, - selectLiveThreadShells, + observeThreadSoundState, shouldPlayInteractionSound, } from "./interactionSounds.ts"; @@ -256,10 +254,23 @@ describe("interaction sounds", () => { }, }); - const seeded = captureThreadSoundStateWhileSettingsHydrating(null, [running]); - const frozen = captureThreadSoundStateWhileSettingsHydrating(seeded, [completed]); + const seeded = observeThreadSoundState(null, running, { + environmentLive: true, + environmentPreviouslyLive: false, + settingsHydrated: false, + }); + const frozen = observeThreadSoundState(seeded.state, completed, { + environmentLive: true, + environmentPreviouslyLive: true, + settingsHydrated: false, + }); + const hydrated = observeThreadSoundState(frozen.state, completed, { + environmentLive: true, + environmentPreviouslyLive: true, + settingsHydrated: true, + }); - expect(deriveInteractionSoundCues(frozen, [completed])).toEqual(["success"]); + expect(hydrated.cues).toEqual(["success"]); }); it("preserves a thread baseline while its environment is synchronizing", () => { @@ -282,67 +293,84 @@ describe("interaction sounds", () => { }, }); const beforeSync = captureThreadSoundState([running]); - const whileSynchronizing = captureThreadSoundStatePreservingUnobserved( - beforeSync, - [], - [completedDuringSync], - ); + const whileSynchronizing = observeThreadSoundState(beforeSync, completedDuringSync, { + environmentLive: false, + environmentPreviouslyLive: true, + settingsHydrated: true, + }); + const reconnected = observeThreadSoundState(whileSynchronizing.state, completedDuringSync, { + environmentLive: true, + environmentPreviouslyLive: true, + settingsHydrated: true, + }); - expect(deriveInteractionSoundCues(whileSynchronizing, [completedDuringSync])).toEqual([ - "success", - ]); + expect(reconnected.cues).toEqual(["success"]); }); it("detects a user-input request received while its environment is synchronizing", () => { const idle = makeThread(); const pendingInputDuringSync = makeThread({ hasPendingUserInput: true }); const beforeSync = captureThreadSoundState([idle]); - const whileSynchronizing = captureThreadSoundStatePreservingUnobserved( - beforeSync, - [], - [pendingInputDuringSync], - ); + const whileSynchronizing = observeThreadSoundState(beforeSync, pendingInputDuringSync, { + environmentLive: false, + environmentPreviouslyLive: true, + settingsHydrated: true, + }); + const reconnected = observeThreadSoundState(whileSynchronizing.state, pendingInputDuringSync, { + environmentLive: true, + environmentPreviouslyLive: true, + settingsHydrated: true, + }); - expect(deriveInteractionSoundCues(whileSynchronizing, [pendingInputDuringSync])).toEqual([ - "bloom", - ]); + expect(reconnected.cues).toEqual(["bloom"]); }); - it("drops retained baselines for threads that no longer exist", () => { - const thread = makeThread({ hasPendingUserInput: true }); - const beforeRemoval = captureThreadSoundState([thread]); - const afterRemoval = captureThreadSoundStatePreservingUnobserved(beforeRemoval, [], []); + it("compares a thread first discovered after reconnect with an idle baseline", () => { + const discovered = observeThreadSoundState(null, makeThread({ hasPendingUserInput: true }), { + environmentLive: true, + environmentPreviouslyLive: true, + settingsHydrated: true, + }); - expect(afterRemoval.size).toBe(0); + expect(discovered.cues).toEqual(["bloom"]); }); - it("admits newly seen threads while settings are hydrating", () => { - const seeded = captureThreadSoundStateWhileSettingsHydrating(null, []); - const withThread = captureThreadSoundStateWhileSettingsHydrating(seeded, [ - makeThread({ hasPendingUserInput: true }), - ]); + it("plays completion for a thread first discovered after reconnect", () => { + const discovered = observeThreadSoundState( + null, + makeThread({ + latestTurn: { + turnId: TurnId.make("remote-turn"), + initiatingUserMessageId: MessageId.make("remote-message"), + state: "completed", + requestedAt: "2026-07-11T12:00:01.000Z", + startedAt: "2026-07-11T12:00:02.000Z", + completedAt: "2026-07-11T12:00:05.000Z", + assistantMessageId: null, + }, + }), + { + environmentLive: true, + environmentPreviouslyLive: true, + settingsHydrated: true, + }, + ); - expect( - deriveInteractionSoundCues(withThread, [makeThread({ hasPendingUserInput: true })]), - ).toEqual([]); + expect(discovered.cues).toEqual(["success"]); + }); + + it("seeds a thread from the first live hydration without playing a cue", () => { + const discovered = observeThreadSoundState(null, makeThread({ hasPendingUserInput: true }), { + environmentLive: true, + environmentPreviouslyLive: false, + settingsHydrated: true, + }); + + expect(discovered.cues).toEqual([]); }); it("keeps input-request cues enabled when completion sounds are disabled", () => { expect(shouldPlayInteractionSound("success", false)).toBe(false); expect(shouldPlayInteractionSound("bloom", false)).toBe(true); }); - - it("excludes cached thread shells until their environment is live", () => { - const cached = makeThread({ environmentId: EnvironmentId.make("cached-environment") }); - const live = makeThread({ - environmentId: EnvironmentId.make("live-environment"), - id: ThreadId.make("thread-2"), - }); - - expect( - selectLiveThreadShells([cached, live], new Set([live.environmentId])).map( - (thread) => thread.id, - ), - ).toEqual(["thread-2"]); - }); }); diff --git a/packages/client-runtime/src/interactionSounds.ts b/packages/client-runtime/src/interactionSounds.ts index c92fa57125d7..a5a59bf79a71 100644 --- a/packages/client-runtime/src/interactionSounds.ts +++ b/packages/client-runtime/src/interactionSounds.ts @@ -18,13 +18,6 @@ export function shouldPlayInteractionSound( return cue !== "success" || completionSoundEnabled; } -export function selectLiveThreadShells( - threads: ReadonlyArray, - liveEnvironmentIds: ReadonlySet, -): ReadonlyArray { - return threads.filter((thread) => liveEnvironmentIds.has(thread.environmentId)); -} - function threadKey(thread: EnvironmentThreadShell): string { return `${thread.environmentId}:${thread.id}`; } @@ -91,46 +84,6 @@ export function captureThreadSoundState( ); } -/** - * Update state for currently live threads while retaining the last trustworthy - * baseline for threads that still exist but are temporarily synchronizing. - */ -export function captureThreadSoundStatePreservingUnobserved( - previous: ThreadSoundStateByKey, - liveThreads: ReadonlyArray, - threads: ReadonlyArray, -): ThreadSoundStateByKey { - const existingThreadKeys = new Set(threads.map(threadKey)); - const next = new Map([...previous].filter(([key]) => existingThreadKeys.has(key))); - for (const [key, state] of captureThreadSoundState(liveThreads)) { - next.set(key, state); - } - return next; -} - -/** - * 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, @@ -161,3 +114,48 @@ export function deriveInteractionSoundCues( return cues; } + +export interface ThreadSoundObservation { + readonly state: ThreadSoundStateByKey; + readonly cues: ReadonlyArray; +} + +/** + * Advance one thread's sound state. Coordinators subscribe to individual + * thread atoms so streaming updates only revisit the thread that changed. + */ +export function observeThreadSoundState( + previous: ThreadSoundStateByKey | null, + thread: EnvironmentThreadShell, + options: { + readonly environmentLive: boolean; + readonly environmentPreviouslyLive: boolean; + readonly settingsHydrated: boolean; + }, +): ThreadSoundObservation { + const current = [thread]; + const baseline = + previous ?? + (options.environmentPreviouslyLive + ? new Map([ + [ + threadKey(thread), + { + completedTurn: null, + userInitiatedTurn: null, + hasPendingUserInput: false, + hasPendingApprovals: false, + }, + ], + ]) + : captureThreadSoundState(current)); + + if (!options.environmentLive || !options.settingsHydrated) { + return { state: baseline, cues: [] }; + } + + return { + state: captureThreadSoundState(current), + cues: deriveInteractionSoundCues(baseline, current), + }; +} From 899672adddf419c43a0a501474b0c9055ccc9dfc Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 10 Aug 2026 16:33:30 -0400 Subject: [PATCH 09/14] fix(sounds): suppress stale startup cues --- .../src/interactionSounds.test.ts | 38 +++++++++++++++++++ .../client-runtime/src/interactionSounds.ts | 27 ++++++------- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/packages/client-runtime/src/interactionSounds.test.ts b/packages/client-runtime/src/interactionSounds.test.ts index 4468732f2177..b66e13e83d26 100644 --- a/packages/client-runtime/src/interactionSounds.test.ts +++ b/packages/client-runtime/src/interactionSounds.test.ts @@ -307,6 +307,44 @@ describe("interaction sounds", () => { expect(reconnected.cues).toEqual(["success"]); }); + it("refreshes cached startup state until the environment first becomes live", () => { + const staleRunning = makeThread({ + latestTurn: { + turnId: TurnId.make("turn-1"), + initiatingUserMessageId: MessageId.make("message-1"), + state: "running", + requestedAt: "2026-07-11T12:00:00.000Z", + startedAt: "2026-07-11T12:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + }); + const refreshedCompleted = makeThread({ + latestTurn: { + ...staleRunning.latestTurn!, + state: "completed", + completedAt: "2026-07-11T12:00:05.000Z", + }, + }); + const seeded = observeThreadSoundState(null, staleRunning, { + environmentLive: false, + environmentPreviouslyLive: false, + settingsHydrated: true, + }); + const refreshed = observeThreadSoundState(seeded.state, refreshedCompleted, { + environmentLive: false, + environmentPreviouslyLive: false, + settingsHydrated: true, + }); + const firstLive = observeThreadSoundState(refreshed.state, refreshedCompleted, { + environmentLive: true, + environmentPreviouslyLive: false, + settingsHydrated: true, + }); + + expect(firstLive.cues).toEqual([]); + }); + it("detects a user-input request received while its environment is synchronizing", () => { const idle = makeThread(); const pendingInputDuringSync = makeThread({ hasPendingUserInput: true }); diff --git a/packages/client-runtime/src/interactionSounds.ts b/packages/client-runtime/src/interactionSounds.ts index a5a59bf79a71..16c04977c295 100644 --- a/packages/client-runtime/src/interactionSounds.ts +++ b/packages/client-runtime/src/interactionSounds.ts @@ -134,21 +134,22 @@ export function observeThreadSoundState( }, ): ThreadSoundObservation { const current = [thread]; + if (!options.environmentPreviouslyLive) { + return { state: captureThreadSoundState(current), cues: [] }; + } const baseline = previous ?? - (options.environmentPreviouslyLive - ? new Map([ - [ - threadKey(thread), - { - completedTurn: null, - userInitiatedTurn: null, - hasPendingUserInput: false, - hasPendingApprovals: false, - }, - ], - ]) - : captureThreadSoundState(current)); + new Map([ + [ + threadKey(thread), + { + completedTurn: null, + userInitiatedTurn: null, + hasPendingUserInput: false, + hasPendingApprovals: false, + }, + ], + ]); if (!options.environmentLive || !options.settingsHydrated) { return { state: baseline, cues: [] }; From 5d2f9124f7bfa43ea6c20849037f93fdb540a908 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 10 Aug 2026 16:43:43 -0400 Subject: [PATCH 10/14] fix(sounds): retain per-thread live state --- .../InteractionSoundCoordinator.tsx | 7 +++- apps/web/package.json | 2 +- apps/web/src/routes/__root.tsx | 7 +++- .../src/interactionSounds.test.ts | 36 +++++++++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx b/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx index 49cd895be241..db21a5f335a9 100644 --- a/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx +++ b/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx @@ -77,16 +77,21 @@ function InteractionSoundThreadCoordinator({ }) { const thread = useThreadShell(threadRef); const previousStateRef = useRef(null); + const environmentObservedLiveRef = useRef(environmentPreviouslyLive); useEffect(() => { if (thread === null) { return; } + const environmentWasLive = environmentObservedLiveRef.current || environmentPreviouslyLive; const observation = observeThreadSoundState(previousStateRef.current, thread, { environmentLive, - environmentPreviouslyLive, + environmentPreviouslyLive: environmentWasLive, settingsHydrated, }); + if (environmentLive || environmentPreviouslyLive) { + environmentObservedLiveRef.current = true; + } previousStateRef.current = observation.state; for (const cue of observation.cues) { if (shouldPlayInteractionSound(cue, completionSoundEnabled)) { diff --git a/apps/web/package.json b/apps/web/package.json index b70aeeb934d5..7dff9f9b3fcc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,8 +32,8 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", "class-variance-authority": "^0.7.1", - "culori": "^4.0.2", "cuelume": "^0.2.1", + "culori": "^4.0.2", "effect": "catalog:", "heic-to": "^1.5.2", "jose": "catalog:", diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1b796d792bb0..44686b902c37 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -342,16 +342,21 @@ function InteractionSoundThreadCoordinator({ }) { const thread = useThreadShell(threadRef); const previousStateRef = useRef(null); + const environmentObservedLiveRef = useRef(environmentPreviouslyLive); useEffect(() => { if (thread === null) { return; } + const environmentWasLive = environmentObservedLiveRef.current || environmentPreviouslyLive; const observation = observeThreadSoundState(previousStateRef.current, thread, { environmentLive, - environmentPreviouslyLive, + environmentPreviouslyLive: environmentWasLive, settingsHydrated, }); + if (environmentLive || environmentPreviouslyLive) { + environmentObservedLiveRef.current = true; + } previousStateRef.current = observation.state; for (const cue of observation.cues) { if (shouldPlayInteractionSound(cue, completionSoundEnabled)) { diff --git a/packages/client-runtime/src/interactionSounds.test.ts b/packages/client-runtime/src/interactionSounds.test.ts index b66e13e83d26..b62111645fec 100644 --- a/packages/client-runtime/src/interactionSounds.test.ts +++ b/packages/client-runtime/src/interactionSounds.test.ts @@ -345,6 +345,42 @@ describe("interaction sounds", () => { expect(firstLive.cues).toEqual([]); }); + it("plays later cues after the first live snapshot seeds the baseline", () => { + const running = makeThread({ + latestTurn: { + turnId: TurnId.make("turn-1"), + initiatingUserMessageId: MessageId.make("message-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", + }, + }); + let environmentObservedLive = false; + const firstLive = observeThreadSoundState(null, running, { + environmentLive: true, + environmentPreviouslyLive: environmentObservedLive, + settingsHydrated: true, + }); + environmentObservedLive = true; + const laterUpdate = observeThreadSoundState(firstLive.state, completed, { + environmentLive: true, + environmentPreviouslyLive: environmentObservedLive, + settingsHydrated: true, + }); + + expect(firstLive.cues).toEqual([]); + expect(laterUpdate.cues).toEqual(["success"]); + }); + it("detects a user-input request received while its environment is synchronizing", () => { const idle = makeThread(); const pendingInputDuringSync = makeThread({ hasPendingUserInput: true }); From e9a917dbbf74bad2472323d9612d00918b28310b Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 25 Aug 2026 22:41:10 -0400 Subject: [PATCH 11/14] fix(sounds): disambiguate scoped thread state --- .../src/interactionSounds.test.ts | 20 ++++++++++++++++++- .../client-runtime/src/interactionSounds.ts | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/interactionSounds.test.ts b/packages/client-runtime/src/interactionSounds.test.ts index b62111645fec..b2048881959a 100644 --- a/packages/client-runtime/src/interactionSounds.test.ts +++ b/packages/client-runtime/src/interactionSounds.test.ts @@ -1,4 +1,4 @@ -import { MessageId, TurnId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentThreadShell } from "./state/shell.ts"; import { @@ -174,6 +174,24 @@ describe("interaction sounds", () => { ).toEqual(["bloom"]); }); + it("keeps thread state distinct when scoped IDs contain colons", () => { + const first = makeThread({ + environmentId: EnvironmentId.make("a:b"), + id: ThreadId.make("c"), + }); + const second = makeThread({ + environmentId: EnvironmentId.make("a"), + id: ThreadId.make("b:c"), + hasPendingUserInput: true, + }); + const previous = captureThreadSoundState([first, second]); + + expect(previous.size).toBe(2); + expect( + deriveInteractionSoundCues(previous, [{ ...first, hasPendingUserInput: true }, second]), + ).toEqual(["bloom"]); + }); + it("does not replay cues for unchanged state", () => { const thread = makeThread({ latestUserMessageAt: "2026-07-11T12:00:00.000Z", diff --git a/packages/client-runtime/src/interactionSounds.ts b/packages/client-runtime/src/interactionSounds.ts index 16c04977c295..8c18f11700fe 100644 --- a/packages/client-runtime/src/interactionSounds.ts +++ b/packages/client-runtime/src/interactionSounds.ts @@ -19,7 +19,7 @@ export function shouldPlayInteractionSound( } function threadKey(thread: EnvironmentThreadShell): string { - return `${thread.environmentId}:${thread.id}`; + return JSON.stringify([thread.environmentId, thread.id]); } function completedTurn(thread: EnvironmentThreadShell): string | null { From b32b5c547a0314d469656002fbc77faa56575a55 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 25 Aug 2026 22:47:51 -0400 Subject: [PATCH 12/14] fix(sounds): harden environment coordination --- apps/mobile/src/state/shell.ts | 25 +++--------- apps/web/src/routes/__root.tsx | 16 +++++++- apps/web/src/state/shell.ts | 23 +++-------- .../client-runtime/src/state/shell.test.ts | 38 ++++++++++++++++++- packages/client-runtime/src/state/shell.ts | 24 ++++++++++++ 5 files changed, 87 insertions(+), 39 deletions(-) diff --git a/apps/mobile/src/state/shell.ts b/apps/mobile/src/state/shell.ts index 5ba62be5223f..01b4f04463bc 100644 --- a/apps/mobile/src/state/shell.ts +++ b/apps/mobile/src/state/shell.ts @@ -2,10 +2,9 @@ import { createEnvironmentShellAtoms, createEnvironmentShellSummaryAtom, createEnvironmentSnapshotAtom, + createLiveEnvironmentIdsAtom, createShellEnvironmentAtoms, } from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -18,20 +17,8 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ shellStateValueAtom: environmentShell.stateValueAtom, }); -let previousLiveEnvironmentIds: ReadonlySet = new Set(); -export const liveEnvironmentIdsAtom = Atom.make((get): ReadonlySet => { - const next = new Set(); - for (const environmentId of get(environmentCatalog.catalogValueAtom).entries.keys()) { - if (get(environmentShell.stateValueAtom(environmentId)).status === "live") { - next.add(environmentId); - } - } - if ( - next.size === previousLiveEnvironmentIds.size && - [...next].every((environmentId) => previousLiveEnvironmentIds.has(environmentId)) - ) { - return previousLiveEnvironmentIds; - } - previousLiveEnvironmentIds = next; - return previousLiveEnvironmentIds; -}).pipe(Atom.withLabel("mobile-live-environment-ids")); +export const liveEnvironmentIdsAtom = createLiveEnvironmentIdsAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + shellStateValueAtom: environmentShell.stateValueAtom, + label: "mobile-live-environment-ids", +}); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 44686b902c37..8f942ec963ff 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -73,6 +73,7 @@ import { import { observeThreadSoundState, shouldPlayInteractionSound, + type InteractionSoundCue, type ThreadSoundStateByKey, } from "@t3tools/client-runtime/interaction-sounds"; import { @@ -298,7 +299,7 @@ function InteractionSoundCoordinator() { // Cuelume owns a lazy AudioContext. Touch it from the first real user // gesture at an effectively inaudible level so later background cues // are not rejected by browser autoplay policy. - play("press", { volume: 0.0001 }); + playInteractionSound("press", { volume: 0.0001 }); cleanup(); }; @@ -360,7 +361,7 @@ function InteractionSoundThreadCoordinator({ previousStateRef.current = observation.state; for (const cue of observation.cues) { if (shouldPlayInteractionSound(cue, completionSoundEnabled)) { - play(cue); + playInteractionSound(cue); } } }, [ @@ -374,6 +375,17 @@ function InteractionSoundThreadCoordinator({ return null; } +function playInteractionSound( + cue: InteractionSoundCue | "press", + options?: { readonly volume?: number }, +) { + try { + play(cue, options); + } catch (error) { + console.warn(`[interaction-sounds] Could not play ${cue} cue.`, error); + } +} + function DocumentTitleSync() { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index e55d8751681c..bba0c0406282 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -5,6 +5,7 @@ import { import { createEnvironmentShellAtoms, createEnvironmentSnapshotAtom, + createLiveEnvironmentIdsAtom, createShellEnvironmentAtoms, type EnvironmentShellState, } from "@t3tools/client-runtime/state/shell"; @@ -21,23 +22,11 @@ export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntim export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom); -let previousLiveEnvironmentIds: ReadonlySet = new Set(); -export const liveEnvironmentIdsAtom = Atom.make((get): ReadonlySet => { - const next = new Set(); - for (const environmentId of get(environmentCatalog.catalogValueAtom).entries.keys()) { - if (get(environmentShell.stateValueAtom(environmentId)).status === "live") { - next.add(environmentId); - } - } - if ( - next.size === previousLiveEnvironmentIds.size && - [...next].every((environmentId) => previousLiveEnvironmentIds.has(environmentId)) - ) { - return previousLiveEnvironmentIds; - } - previousLiveEnvironmentIds = next; - return previousLiveEnvironmentIds; -}).pipe(Atom.withLabel("web-live-environment-ids")); +export const liveEnvironmentIdsAtom = createLiveEnvironmentIdsAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + shellStateValueAtom: environmentShell.stateValueAtom, + label: "web-live-environment-ids", +}); export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { const catalog = AsyncResult.value(get(environmentCatalog.catalogAtom)); diff --git a/packages/client-runtime/src/state/shell.test.ts b/packages/client-runtime/src/state/shell.test.ts index f1326e0a5cbe..e339b7056855 100644 --- a/packages/client-runtime/src/state/shell.test.ts +++ b/packages/client-runtime/src/state/shell.test.ts @@ -6,7 +6,11 @@ import { Atom, AtomRegistry } from "effect/unstable/reactivity"; import { PrimaryConnectionTarget } from "../connection/model.ts"; import type { EnvironmentShellState } from "./shell.ts"; -import { createEnvironmentServerConfigsAtom, createEnvironmentShellSummaryAtom } from "./shell.ts"; +import { + createEnvironmentServerConfigsAtom, + createEnvironmentShellSummaryAtom, + createLiveEnvironmentIdsAtom, +} from "./shell.ts"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2"); @@ -77,6 +81,11 @@ function makeHarness() { catalogValueAtom, serverConfigValueAtom: configAtoms, }); + const liveEnvironmentIdsAtom = createLiveEnvironmentIdsAtom({ + catalogValueAtom, + shellStateValueAtom: shellStateAtoms, + label: "test-live-environment-ids", + }); return { registry: AtomRegistry.make(), @@ -84,6 +93,7 @@ function makeHarness() { configAtom: configAtoms, summaryAtom, serverConfigsAtom, + liveEnvironmentIdsAtom, }; } @@ -127,4 +137,30 @@ describe("environment shell projections", () => { harness.registry.set(harness.configAtom(ENVIRONMENT_ID), config); expect(harness.registry.get(harness.serverConfigsAtom)).toBe(withConfig); }); + + it("projects live environment IDs and preserves identity while membership is unchanged", () => { + const harness = makeHarness(); + const empty = harness.registry.get(harness.liveEnvironmentIdsAtom); + + harness.registry.set( + harness.shellStateAtom(OTHER_ENVIRONMENT_ID), + shellState({ + status: "live", + updatedAt: "2026-06-02T00:00:00.000Z", + }), + ); + const live = harness.registry.get(harness.liveEnvironmentIdsAtom); + + expect(live).not.toBe(empty); + expect(live).toEqual(new Set([OTHER_ENVIRONMENT_ID])); + + harness.registry.set( + harness.shellStateAtom(OTHER_ENVIRONMENT_ID), + shellState({ + status: "live", + updatedAt: "2026-06-03T00:00:00.000Z", + }), + ); + expect(harness.registry.get(harness.liveEnvironmentIdsAtom)).toBe(live); + }); }); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 95d90f9b36f2..4e05a3834c24 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -376,6 +376,30 @@ export function createEnvironmentShellSummaryAtom(input: { }).pipe(Atom.withLabel("environment-shell-summary")); } +export function createLiveEnvironmentIdsAtom(input: { + readonly catalogValueAtom: Atom.Atom; + readonly shellStateValueAtom: (environmentId: EnvironmentId) => Atom.Atom; + readonly label: string; +}) { + let previousLiveEnvironmentIds: ReadonlySet = new Set(); + return Atom.make((get): ReadonlySet => { + const next = new Set(); + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + if (get(input.shellStateValueAtom(environmentId)).status === "live") { + next.add(environmentId); + } + } + if ( + next.size === previousLiveEnvironmentIds.size && + [...next].every((environmentId) => previousLiveEnvironmentIds.has(environmentId)) + ) { + return previousLiveEnvironmentIds; + } + previousLiveEnvironmentIds = next; + return previousLiveEnvironmentIds; + }).pipe(Atom.withLabel(input.label)); +} + export function createEnvironmentServerConfigsAtom(input: { readonly catalogValueAtom: Atom.Atom; readonly serverConfigValueAtom: (environmentId: EnvironmentId) => Atom.Atom; From 99508f51586d36628184884ed7b37d9fc73a7eea Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 7 Sep 2026 08:57:26 -0400 Subject: [PATCH 13/14] fix(sounds): seed reappearing threads without historical cues --- .../src/interactionSounds.test.ts | 8 ++++---- .../client-runtime/src/interactionSounds.ts | 19 +++---------------- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/packages/client-runtime/src/interactionSounds.test.ts b/packages/client-runtime/src/interactionSounds.test.ts index b2048881959a..61b791bc76a4 100644 --- a/packages/client-runtime/src/interactionSounds.test.ts +++ b/packages/client-runtime/src/interactionSounds.test.ts @@ -417,17 +417,17 @@ describe("interaction sounds", () => { expect(reconnected.cues).toEqual(["bloom"]); }); - it("compares a thread first discovered after reconnect with an idle baseline", () => { + it("does not replay pending input when a historical thread reappears", () => { const discovered = observeThreadSoundState(null, makeThread({ hasPendingUserInput: true }), { environmentLive: true, environmentPreviouslyLive: true, settingsHydrated: true, }); - expect(discovered.cues).toEqual(["bloom"]); + expect(discovered.cues).toEqual([]); }); - it("plays completion for a thread first discovered after reconnect", () => { + it("does not replay completion when a historical thread reappears", () => { const discovered = observeThreadSoundState( null, makeThread({ @@ -448,7 +448,7 @@ describe("interaction sounds", () => { }, ); - expect(discovered.cues).toEqual(["success"]); + expect(discovered.cues).toEqual([]); }); it("seeds a thread from the first live hydration without playing a cue", () => { diff --git a/packages/client-runtime/src/interactionSounds.ts b/packages/client-runtime/src/interactionSounds.ts index 8c18f11700fe..1282f4c6bb54 100644 --- a/packages/client-runtime/src/interactionSounds.ts +++ b/packages/client-runtime/src/interactionSounds.ts @@ -134,29 +134,16 @@ export function observeThreadSoundState( }, ): ThreadSoundObservation { const current = [thread]; - if (!options.environmentPreviouslyLive) { + if (previous === null || !options.environmentPreviouslyLive) { return { state: captureThreadSoundState(current), cues: [] }; } - const baseline = - previous ?? - new Map([ - [ - threadKey(thread), - { - completedTurn: null, - userInitiatedTurn: null, - hasPendingUserInput: false, - hasPendingApprovals: false, - }, - ], - ]); if (!options.environmentLive || !options.settingsHydrated) { - return { state: baseline, cues: [] }; + return { state: previous, cues: [] }; } return { state: captureThreadSoundState(current), - cues: deriveInteractionSoundCues(baseline, current), + cues: deriveInteractionSoundCues(previous, current), }; } From 4e01bd56447f4ab1c180e5c3d6b77c12ec5d62a2 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 7 Sep 2026 09:03:59 -0400 Subject: [PATCH 14/14] fix(sounds): observe live environments immediately --- .../interaction-sounds/InteractionSoundCoordinator.tsx | 7 ++++--- apps/web/src/routes/__root.tsx | 7 ++++--- package.json | 1 + 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx b/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx index db21a5f335a9..bc5711f96526 100644 --- a/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx +++ b/apps/mobile/src/features/interaction-sounds/InteractionSoundCoordinator.tsx @@ -50,9 +50,10 @@ export function InteractionSoundCoordinator() { key={scopedThreadKey(threadRef)} threadRef={threadRef} environmentLive={liveEnvironmentIds.has(threadRef.environmentId)} - environmentPreviouslyLive={previouslyLiveEnvironmentIdsRef.current.has( - threadRef.environmentId, - )} + environmentPreviouslyLive={ + liveEnvironmentIds.has(threadRef.environmentId) || + previouslyLiveEnvironmentIdsRef.current.has(threadRef.environmentId) + } completionSoundEnabled={completionSoundEnabled} settingsHydrated={settingsHydrated} players={players} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8f942ec963ff..6e7601261594 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -319,9 +319,10 @@ function InteractionSoundCoordinator() { key={scopedThreadKey(threadRef)} threadRef={threadRef} environmentLive={liveEnvironmentIds.has(threadRef.environmentId)} - environmentPreviouslyLive={previouslyLiveEnvironmentIdsRef.current.has( - threadRef.environmentId, - )} + environmentPreviouslyLive={ + liveEnvironmentIds.has(threadRef.environmentId) || + previouslyLiveEnvironmentIdsRef.current.has(threadRef.environmentId) + } completionSoundEnabled={completionSoundEnabled} settingsHydrated={settingsHydrated} /> diff --git a/package.json b/package.json index 4e5aca36d135..bc4ec0582221 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "start:marketing": "vp run --filter @t3tools/marketing preview", "start:mock-update-server": "node scripts/mock-update-server.ts", "screenshots:mobile": "node scripts/mobile-showcase.ts", + "sounds:generate": "node scripts/generate-interaction-sound-assets.mjs", "icons:export": "node scripts/export-brand-icons.ts", "icons:check": "node scripts/export-brand-icons.ts --check", "build": "vp run --filter './apps/*' build",