Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const clientSettings: ClientSettings = {
confirmThreadDelete: false,
dismissedProviderUpdateNotificationKeys: [],
diffIgnoreWhitespace: true,
enableCompletionSounds: false,
favorites: [],
providerModelPreferences: {},
sidebarProjectGroupingMode: "repository_path",
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"cuelume": "^0.1.0",
"effect": "catalog:",
"jose": "catalog:",
"lexical": "^0.41.0",
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
? ["Diff whitespace changes"]
: []),
...(settings.enableCompletionSounds !== DEFAULT_UNIFIED_SETTINGS.enableCompletionSounds
? ["Completion sound"]
: []),
...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar
? ["Auto-open task panel"]
: []),
Expand Down Expand Up @@ -440,6 +443,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.defaultThreadEnvMode,
settings.newWorktreesStartFromOrigin,
settings.diffIgnoreWhitespace,
settings.enableCompletionSounds,
settings.automaticGitFetchInterval,
settings.enableAssistantStreaming,
settings.enableProviderUpdateChecks,
Expand All @@ -465,6 +469,7 @@ export function useSettingsRestore(onRestored?: () => void) {
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
enableCompletionSounds: DEFAULT_UNIFIED_SETTINGS.enableCompletionSounds,
sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
Expand Down Expand Up @@ -625,6 +630,32 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
title="Completion sound"
description="Play a sound when a turn completes."
resetAction={
settings.enableCompletionSounds !== DEFAULT_UNIFIED_SETTINGS.enableCompletionSounds ? (
<SettingResetButton
label="completion sound"
onClick={() =>
updateSettings({
enableCompletionSounds: DEFAULT_UNIFIED_SETTINGS.enableCompletionSounds,
})
}
/>
) : null
}
control={
<Switch
checked={settings.enableCompletionSounds}
onCheckedChange={(checked) =>
updateSettings({ enableCompletionSounds: Boolean(checked) })
}
aria-label="Play a sound when a turn completes"
/>
}
/>

<SettingsRow
title="Hide whitespace changes"
description="Set whether the diff panel ignores whitespace-only edits by default."
Expand Down
147 changes: 147 additions & 0 deletions apps/web/src/interactionSounds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { TurnId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import {
captureThreadSoundState,
captureThreadSoundStateWhileSettingsHydrating,
deriveInteractionSoundCues,
} from "./interactionSounds";

function makeThread(overrides: Partial<EnvironmentThreadShell> = {}): EnvironmentThreadShell {
return {
environmentId: "environment-1",
id: "thread-1",
projectId: "project-1",
title: "Thread",
modelSelection: null,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
latestTurn: null,
createdAt: "2026-07-11T12:00:00.000Z",
updatedAt: "2026-07-11T12:00:00.000Z",
archivedAt: null,
session: null,
latestUserMessageAt: null,
hasPendingApprovals: false,
hasPendingUserInput: false,
hasActionableProposedPlan: false,
...overrides,
} as EnvironmentThreadShell;
}

describe("interaction sounds", () => {
it("plays success when a turn becomes completed", () => {
const running = makeThread({
latestTurn: {
turnId: TurnId.make("turn-1"),
state: "running",
requestedAt: "2026-07-11T12:00:00.000Z",
startedAt: "2026-07-11T12:00:01.000Z",
completedAt: null,
assistantMessageId: null,
},
});
const completed = makeThread({
latestTurn: {
...running.latestTurn!,
state: "completed",
completedAt: "2026-07-11T12:00:05.000Z",
},
});

expect(deriveInteractionSoundCues(captureThreadSoundState([running]), [completed])).toEqual([
"success",
]);
});

it("plays bloom when a thread starts requesting user input", () => {
const thread = makeThread();

expect(
deriveInteractionSoundCues(captureThreadSoundState([thread]), [
makeThread({ hasPendingUserInput: true }),
]),
).toEqual(["bloom"]);
});

it("plays bloom when a thread starts requesting approval", () => {
const thread = makeThread();

expect(
deriveInteractionSoundCues(captureThreadSoundState([thread]), [
makeThread({ hasPendingApprovals: true }),
]),
).toEqual(["bloom"]);
});

it("does not replay cues for unchanged state", () => {
const thread = makeThread({
hasPendingUserInput: true,
hasPendingApprovals: true,
latestTurn: {
turnId: TurnId.make("turn-1"),
state: "completed",
requestedAt: "2026-07-11T12:00:00.000Z",
startedAt: "2026-07-11T12:00:01.000Z",
completedAt: "2026-07-11T12:00:05.000Z",
assistantMessageId: null,
},
});

expect(deriveInteractionSoundCues(captureThreadSoundState([thread]), [thread])).toEqual([]);
});

it("does not play cues while existing threads are first hydrated", () => {
const thread = makeThread({
hasPendingUserInput: true,
latestTurn: {
turnId: TurnId.make("turn-1"),
state: "completed",
requestedAt: "2026-07-11T12:00:00.000Z",
startedAt: "2026-07-11T12:00:01.000Z",
completedAt: "2026-07-11T12:00:05.000Z",
assistantMessageId: null,
},
});

expect(deriveInteractionSoundCues(new Map(), [thread])).toEqual([]);
});

it("preserves pre-hydration thread state so cues can play after settings hydrate", () => {
const running = makeThread({
latestTurn: {
turnId: TurnId.make("turn-1"),
state: "running",
requestedAt: "2026-07-11T12:00:00.000Z",
startedAt: "2026-07-11T12:00:01.000Z",
completedAt: null,
assistantMessageId: null,
},
});
const completed = makeThread({
latestTurn: {
...running.latestTurn!,
state: "completed",
completedAt: "2026-07-11T12:00:05.000Z",
},
});

const seeded = captureThreadSoundStateWhileSettingsHydrating(null, [running]);
const frozen = captureThreadSoundStateWhileSettingsHydrating(seeded, [completed]);

expect(deriveInteractionSoundCues(frozen, [completed])).toEqual(["success"]);
});

it("admits newly seen threads while settings are hydrating", () => {
const seeded = captureThreadSoundStateWhileSettingsHydrating(null, []);
const withThread = captureThreadSoundStateWhileSettingsHydrating(seeded, [
makeThread({ hasPendingUserInput: true }),
]);

expect(
deriveInteractionSoundCues(withThread, [makeThread({ hasPendingUserInput: true })]),
).toEqual([]);
});
});
81 changes: 81 additions & 0 deletions apps/web/src/interactionSounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";

export type InteractionSoundCue = "bloom" | "success";

interface ThreadSoundState {
readonly completedTurn: string | null;
readonly hasPendingUserAction: boolean;
}

export type ThreadSoundStateByKey = ReadonlyMap<string, ThreadSoundState>;

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}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interrupted finished turns skip sound

Medium Severity

completedTurn only treats latestTurn.state === "completed" as finished. Shell snapshots often end turns as interrupted with a non-null completedAt (session teardown races and interrupt handling), which resolveThreadAwarenessPhase already counts as completed. Those finishes never emit the success cue.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9626d08. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track completed turns without mutable timestamps

When a provider turn.completed is processed, ProviderRuntimeIngestion emits thread.session-set while CheckpointReactor can later emit thread.turn-diff-completed for the same turn; the projector/reducer update latestTurn.completedAt from those events. If thread.session-set settles the turn first, this timestamp-based key changes again when the checkpoint event arrives, so deriveInteractionSoundCues treats the same completed turn as a new completion and plays a second success cue. Use stable turn identity, or separately remember notified turn ids, so checkpoint timestamp corrections do not replay completion sounds.

Useful? React with 👍 / 👎.

}

export function captureThreadSoundState(
threads: ReadonlyArray<EnvironmentThreadShell>,
): ThreadSoundStateByKey {
return new Map(
threads.map((thread) => [
threadKey(thread),
{
completedTurn: completedTurn(thread),
hasPendingUserAction: thread.hasPendingUserInput || thread.hasPendingApprovals,
},
]),
);
}

/**
* While client settings are still hydrating, keep a sound baseline without
* advancing known thread state. Newly seen threads are admitted so later
* transitions can still produce cues once hydration completes.
*/
export function captureThreadSoundStateWhileSettingsHydrating(
previous: ThreadSoundStateByKey | null,
threads: ReadonlyArray<EnvironmentThreadShell>,
): 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;
Comment thread
cursor[bot] marked this conversation as resolved.
}

export function deriveInteractionSoundCues(
previous: ThreadSoundStateByKey,
threads: ReadonlyArray<EnvironmentThreadShell>,
): InteractionSoundCue[] {
const cues: InteractionSoundCue[] = [];

for (const thread of threads) {
const prior = previous.get(threadKey(thread));
const nextCompletedTurn = completedTurn(thread);

if (prior && nextCompletedTurn !== null && prior.completedTurn !== nextCompletedTurn) {
cues.push("success");
}
const hasPendingUserAction = thread.hasPendingUserInput || thread.hasPendingApprovals;
if (prior && hasPendingUserAction && !prior.hasPendingUserAction) {
cues.push("bloom");
}
}

return cues;
}
44 changes: 42 additions & 2 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
useNavigate,
} from "@tanstack/react-router";
import { useEffect, useEffectEvent, useRef, useState } from "react";
import { play } from "cuelume";

import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding";
import { resolveServerBackedAppDisplayName } from "../branding.logic";
Expand All @@ -27,7 +28,7 @@ import {
toastManager,
} from "../components/ui/toast";
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
import { useClientSettings } from "../hooks/useSettings";
import { useClientSettings, useClientSettingsHydrated } from "../hooks/useSettings";
import {
deriveLogicalProjectKeyFromSettings,
derivePhysicalProjectKeyFromPath,
Expand All @@ -47,7 +48,18 @@ import {
primaryServerConfigEventAtom,
primaryServerWelcomeAtom,
} from "../state/server";
import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities";
import {
readProject,
setActiveEnvironmentId,
useActiveEnvironmentId,
useThreadShells,
} from "../state/entities";
import {
captureThreadSoundState,
captureThreadSoundStateWhileSettingsHydrating,
deriveInteractionSoundCues,
type ThreadSoundStateByKey,
} from "../interactionSounds";
import {
createKeybindingsUpdateToastController,
type KeybindingsUpdateToastController,
Expand Down Expand Up @@ -134,13 +146,41 @@ function RootRouteView() {
<SlowRpcRequestToastCoordinator />
<HostedStaticEnvironmentBootstrap />
{primaryEnvironmentAuthenticated ? <EventRouter /> : null}
<InteractionSoundCoordinator />
{primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
{appShell}
</AnchoredToastProvider>
</ToastProvider>
);
}

function InteractionSoundCoordinator() {
const threads = useThreadShells();
const completionSoundEnabled = useClientSettings((settings) => settings.enableCompletionSounds);
const settingsHydrated = useClientSettingsHydrated();
const previousStateRef = useRef<ThreadSoundStateByKey | null>(null);

useEffect(() => {
if (!settingsHydrated) {
previousStateRef.current = captureThreadSoundStateWhileSettingsHydrating(
previousStateRef.current,
threads,
);
Comment on lines +164 to +168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress sounds until shell snapshots are live

On warm starts the shell layer first exposes the cached snapshot (makeEnvironmentShellState initializes from cache.loadShell) and then replaces it with the HTTP snapshot while synchronizing (packages/client-runtime/src/state/shell.ts:57-70,204-206). This branch records that cached thread state as the sound baseline while only client settings are hydrating; if the cached latestTurn was running when the app closed and the authoritative snapshot says it completed, hydration later compares stale→live and plays a startup success for old work, potentially in batches. Seed or advance the baseline only after shell synchronization is live, or treat the first live snapshot as the baseline.

Useful? React with 👍 / 👎.

return;
}

const previous = previousStateRef.current;
if (completionSoundEnabled && previous !== null) {
for (const cue of deriveInteractionSoundCues(previous, threads)) {
play(cue);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Unlock audio from a user gesture before background cues

In browsers that require AudioContext.resume() to happen during a user activation, the first call to play() here runs later from a shell-state effect, not from the click/keydown that submitted the prompt. If no prior Cuelume sound has already unlocked the shared context, completion/input cues can be silently blocked and never heard; prime or resume the sound engine from an explicit user interaction before relying on background notifications.

Useful? React with 👍 / 👎.

}
}
previousStateRef.current = captureThreadSoundState(threads);
}, [completionSoundEnabled, settingsHydrated, threads]);
Comment thread
cursor[bot] marked this conversation as resolved.

return null;
}

function DocumentTitleSync() {
const primaryServerVersion =
useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
Expand Down
Loading
Loading