Skip to content
Merged
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 @@ -14,6 +14,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts";

const clientSettings: ClientSettings = {
autoOpenPlanSidebar: false,
completionSound: "none",
confirmThreadArchive: true,
confirmThreadDelete: false,
dismissedProviderUpdateNotificationKeys: [],
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/AppRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { RouterProvider } from "@tanstack/react-router";
import { describe, expect, it } from "vite-plus/test";

import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
import { CompletionSoundObserver } from "./components/CompletionSoundObserver";
import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
import type { AppRouter } from "./router";
Expand All @@ -16,9 +17,10 @@ describe("AppRoot", () => {
const children = Children.toArray(
(root as ReactElement<{ readonly children: ReactNode }>).props.children,
);
expect(children).toHaveLength(3);
expect(children).toHaveLength(4);
expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider);
expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts);
expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost);
expect(isValidElement(children[1]) && children[1].type).toBe(CompletionSoundObserver);
expect(isValidElement(children[2]) && children[2].type).toBe(PreviewAutomationHosts);
expect(isValidElement(children[3]) && children[3].type).toBe(ElectronBrowserHost);
});
});
2 changes: 2 additions & 0 deletions apps/web/src/AppRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { RouterProvider } from "@tanstack/react-router";

import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
import { CompletionSoundObserver } from "./components/CompletionSoundObserver";
import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
import type { AppRouter } from "./router";
Expand All @@ -14,6 +15,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {
return (
<AppAtomRegistryProvider>
<RouterProvider router={router} />
<CompletionSoundObserver />
<PreviewAutomationHosts />
<ElectronBrowserHost />
</AppAtomRegistryProvider>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/clientPersistenceStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe("clientPersistenceStorage", () => {
await import("./clientPersistenceStorage");
const settings = {
...DEFAULT_CLIENT_SETTINGS,
completionSound: "none" as const,
timestampFormat: "24-hour" as const,
};

Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/components/CompletionSoundObserver.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { useEffect, useMemo, useRef } from "react";

import { useClientSettings } from "../hooks/useSettings";
import { useThreadShells } from "../state/entities";
import { playCompletionSound } from "../lib/completionSound";
import {
reconcileCompletionSoundSnapshots,
type CompletionSoundThreadSnapshot,
} from "../lib/completionSound.logic";

export function CompletionSoundObserver() {
const threadShells = useThreadShells();
const completionSound = useClientSettings((settings) => settings.completionSound);
const snapshotsByThreadKey = useMemo(() => {
const next = new Map<string, CompletionSoundThreadSnapshot>();
for (const thread of threadShells) {
next.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), {
turnId: thread.latestTurn?.turnId ?? null,
state: thread.latestTurn?.state ?? null,
sessionStatus: thread.session?.status ?? null,
});
}
return next;
}, [threadShells]);
const previousSnapshotsByThreadKeyRef = useRef<ReadonlyMap<
string,
CompletionSoundThreadSnapshot
> | null>(null);

useEffect(() => {
const previousSnapshotsByThreadKey = previousSnapshotsByThreadKeyRef.current;
if (previousSnapshotsByThreadKey !== null) {
const completedThreadKeys = reconcileCompletionSoundSnapshots(
previousSnapshotsByThreadKey,
snapshotsByThreadKey,
);
if (completedThreadKeys.length > 0) {
playCompletionSound(completionSound);
}
}
previousSnapshotsByThreadKeyRef.current = snapshotsByThreadKey;
}, [completionSound, snapshotsByThreadKey]);

return null;
}
50 changes: 50 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import {
DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
DEFAULT_UNIFIED_SETTINGS,
type CompletionSound,
type EnvironmentIdentificationMode,
MAX_GLASS_OPACITY,
MIN_GLASS_OPACITY,
Expand Down Expand Up @@ -167,6 +168,11 @@ const TIMESTAMP_FORMAT_LABELS = {
"24-hour": "24-hour",
} as const;

const COMPLETION_SOUND_LABELS: Record<CompletionSound, string> = {
none: "No sound",
chime: "Chime",
};

const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record<BackgroundActivityProfile, string> = {
balanced: "Balanced",
performance: "Performance",
Expand Down Expand Up @@ -578,6 +584,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
? ["Time format"]
: []),
...(settings.completionSound !== DEFAULT_UNIFIED_SETTINGS.completionSound
? ["Completion sound"]
: []),
...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount
? ["Visible threads"]
: []),
Expand Down Expand Up @@ -622,6 +631,7 @@ export function useSettingsRestore(onRestored?: () => void) {
isTextGenerationModelDirty,
isBackgroundActivityDirty,
settings.autoOpenPlanSidebar,
settings.completionSound,
settings.confirmThreadArchive,
settings.confirmThreadDelete,
settings.addProjectBaseDirectory,
Expand Down Expand Up @@ -653,6 +663,7 @@ export function useSettingsRestore(onRestored?: () => void) {
setTheme("system");
updateSettings({
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
completionSound: DEFAULT_UNIFIED_SETTINGS.completionSound,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
Expand Down Expand Up @@ -1301,6 +1312,45 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
{...searchableSetting("completion-sound")}
description="Choose whether T3 Code plays a sound when a response finishes."
resetAction={
settings.completionSound !== DEFAULT_UNIFIED_SETTINGS.completionSound ? (
<SettingResetButton
label="completion sound"
onClick={() =>
updateSettings({
completionSound: DEFAULT_UNIFIED_SETTINGS.completionSound,
})
}
/>
) : null
}
control={
<Select
value={settings.completionSound}
onValueChange={(value) => {
if (value === "none" || value === "chime") {
updateSettings({ completionSound: value });
}
}}
>
<SelectTrigger className="w-full sm:w-40" aria-label="Completion sound">
<SelectValue>{COMPLETION_SOUND_LABELS[settings.completionSound]}</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem hideIndicator value="none">
{COMPLETION_SOUND_LABELS.none}
</SelectItem>
<SelectItem hideIndicator value="chime">
{COMPLETION_SOUND_LABELS.chime}
</SelectItem>
</SelectPopup>
</Select>
}
/>

<SettingsRow
{...searchableSetting("provider-update-checks")}
description="Check installed provider CLIs for newer available versions."
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/settings/settingsSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ describe("searchSettings", () => {

it("serves anchor props to panels from the catalog", () => {
expect(searchableSetting("word-wrap")).toEqual({ id: "word-wrap", title: "Word wrap" });
expect(searchableSetting("completion-sound")).toEqual({
id: "completion-sound",
title: "Completion sound",
});
expect(searchableSetting("archive")).toEqual({ id: "archive", title: "Archived threads" });
});

Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Assistant output",
to: "/settings/general",
},
{
id: "completion-sound",
title: "Completion sound",
to: "/settings/general",
},
{
id: "provider-update-checks",
title: "Provider update checks",
Expand Down
104 changes: 104 additions & 0 deletions apps/web/src/lib/completionSound.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { TurnId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import {
reconcileCompletionSoundSnapshots,
shouldPlayCompletionSound,
type CompletionSoundThreadSnapshot,
} from "./completionSound.logic";

const turnId = TurnId.make("turn-1");

describe("shouldPlayCompletionSound", () => {
it("plays when the same turn changes from running to completed", () => {
expect(
shouldPlayCompletionSound(
{ turnId, state: "running", sessionStatus: "running" },
{ turnId, state: "completed", sessionStatus: "ready" },
),
).toBe(true);
});

it("plays when a running latest turn is cleared after the session becomes ready", () => {
expect(
shouldPlayCompletionSound(
{ turnId, state: "running", sessionStatus: "running" },
{ turnId: null, state: null, sessionStatus: "ready" },
),
).toBe(true);
});

it("does not play for initial completed state", () => {
expect(
shouldPlayCompletionSound(undefined, {
turnId,
state: "completed",
sessionStatus: "ready",
}),
).toBe(false);
});

it("does not play when switching to an already completed turn", () => {
expect(
shouldPlayCompletionSound(
{ turnId: TurnId.make("turn-previous"), state: "running", sessionStatus: "running" },
{ turnId, state: "completed", sessionStatus: "ready" },
),
).toBe(false);
});

it("does not play for non-completed terminal states", () => {
expect(
shouldPlayCompletionSound(
{ turnId, state: "running", sessionStatus: "running" },
{ turnId, state: "error", sessionStatus: "error" },
),
).toBe(false);
expect(
shouldPlayCompletionSound(
{ turnId, state: "running", sessionStatus: "running" },
{ turnId, state: "interrupted", sessionStatus: "interrupted" },
),
).toBe(false);
});

it("does not play when a running latest turn is cleared after an error", () => {
expect(
shouldPlayCompletionSound(
{ turnId, state: "running", sessionStatus: "running" },
{ turnId: null, state: null, sessionStatus: "error" },
),
).toBe(false);
});
});

describe("reconcileCompletionSoundSnapshots", () => {
it("returns thread keys that transition from running to completed", () => {
const previous = new Map<string, CompletionSoundThreadSnapshot>([
["environment-a:thread-1", { turnId, state: "running", sessionStatus: "running" }],
[
"environment-a:thread-2",
{ turnId: TurnId.make("turn-2"), state: "running", sessionStatus: "running" },
],
]);
const current = new Map<string, CompletionSoundThreadSnapshot>([
["environment-a:thread-1", { turnId, state: "completed", sessionStatus: "ready" }],
[
"environment-a:thread-2",
{ turnId: TurnId.make("turn-2"), state: "running", sessionStatus: "running" },
],
]);

expect(reconcileCompletionSoundSnapshots(previous, current)).toEqual([
"environment-a:thread-1",
]);
});

it("does not report threads that first appear completed", () => {
const current = new Map<string, CompletionSoundThreadSnapshot>([
["environment-a:thread-1", { turnId, state: "completed", sessionStatus: "ready" }],
]);

expect(reconcileCompletionSoundSnapshots(new Map(), current)).toEqual([]);
});
});
55 changes: 55 additions & 0 deletions apps/web/src/lib/completionSound.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type {
OrchestrationLatestTurnState,
OrchestrationSessionStatus,
TurnId,
} from "@t3tools/contracts";

export interface CompletionSoundThreadSnapshot {
turnId: TurnId | null;
state: OrchestrationLatestTurnState | null;
sessionStatus: OrchestrationSessionStatus | null;
}

export function shouldPlayCompletionSound(
previous: CompletionSoundThreadSnapshot | undefined,
current: CompletionSoundThreadSnapshot,
): boolean {
return Boolean(
previous &&
previous.state === "running" &&
previous.turnId !== null &&
(runningTurnCompleted(previous, current) || runningTurnClearedAfterCompletion(current)),
);
}

function runningTurnCompleted(
previous: CompletionSoundThreadSnapshot,
current: CompletionSoundThreadSnapshot,
): boolean {
return current.turnId === previous.turnId && current.state === "completed";
}

function runningTurnClearedAfterCompletion(current: CompletionSoundThreadSnapshot): boolean {
return (
current.turnId === null &&
current.state === null &&
(current.sessionStatus === null ||
current.sessionStatus === "idle" ||
current.sessionStatus === "ready")
);
}

export function reconcileCompletionSoundSnapshots(
previousByThreadKey: ReadonlyMap<string, CompletionSoundThreadSnapshot>,
currentByThreadKey: ReadonlyMap<string, CompletionSoundThreadSnapshot>,
): ReadonlyArray<string> {
const completedThreadKeys: string[] = [];

for (const [threadKey, current] of currentByThreadKey) {
if (shouldPlayCompletionSound(previousByThreadKey.get(threadKey), current)) {
completedThreadKeys.push(threadKey);
}
}

return completedThreadKeys;
}
Loading
Loading