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 @@ -395,6 +395,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
? ["Diff whitespace changes"]
: []),
...(settings.enableCompletionSounds !== DEFAULT_UNIFIED_SETTINGS.enableCompletionSounds
? ["Completion sound"]
: []),
...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar
? ["Auto-open task panel"]
: []),
Expand Down Expand Up @@ -432,6 +435,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.defaultThreadEnvMode,
settings.newWorktreesStartFromOrigin,
settings.diffIgnoreWhitespace,
settings.enableCompletionSounds,
settings.automaticGitFetchInterval,
settings.enableAssistantStreaming,
settings.sidebarThreadPreviewCount,
Expand All @@ -456,6 +460,7 @@ export function useSettingsRestore(onRestored?: () => void) {
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
enableCompletionSounds: DEFAULT_UNIFIED_SETTINGS.enableCompletionSounds,
sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
Expand Down Expand Up @@ -615,6 +620,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}`;
}

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

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,
);
return;
}

const previous = previousStateRef.current;
if (completionSoundEnabled && previous !== null) {
for (const cue of deriveInteractionSoundCues(previous, threads)) {
play(cue);
}
}
previousStateRef.current = captureThreadSoundState(threads);
}, [completionSoundEnabled, settingsHydrated, threads]);
Comment thread
cursor[bot] marked this conversation as resolved.

return null;
}

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