From d2df20a43efaff0a29f6f3fa908eed0ae2dd31e5 Mon Sep 17 00:00:00 2001 From: Barna Karacsony Date: Mon, 3 Aug 2026 15:56:44 +0200 Subject: [PATCH 1/2] feat(desktop): play completion sound on Linux --- .../src/electron/ElectronShell.test.ts | 15 +++- apps/desktop/src/electron/ElectronShell.ts | 4 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.test.ts | 54 ++++++++++- apps/desktop/src/ipc/methods/window.ts | 15 ++++ apps/desktop/src/preload.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 2 + .../DesktopTurnCompletionSound.test.ts | 90 +++++++++++++++++++ .../desktop/DesktopTurnCompletionSound.tsx | 86 ++++++++++++++++++ apps/web/src/routes/__root.tsx | 2 + docs/user/install.md | 2 + packages/contracts/src/ipc.ts | 1 + 13 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/desktop/DesktopTurnCompletionSound.test.ts create mode 100644 apps/web/src/components/desktop/DesktopTurnCompletionSound.tsx diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index f5c85769cee..85cabb5fe14 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -2,13 +2,15 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vite-plus/test"; -const { openExternalMock, writeTextMock } = vi.hoisted(() => ({ +const { beepMock, openExternalMock, writeTextMock } = vi.hoisted(() => ({ + beepMock: vi.fn(), openExternalMock: vi.fn(), writeTextMock: vi.fn(), })); vi.mock("electron", () => ({ shell: { + beep: beepMock, openExternal: openExternalMock, }, clipboard: { @@ -20,10 +22,21 @@ import * as ElectronShell from "./ElectronShell.ts"; describe("ElectronShell", () => { beforeEach(() => { + beepMock.mockReset(); openExternalMock.mockReset(); writeTextMock.mockReset(); }); + it.effect("plays the native beep", () => + Effect.gen(function* () { + const electronShell = yield* ElectronShell.ElectronShell; + + yield* electronShell.beep; + + assert.equal(beepMock.mock.calls.length, 1); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("opens safe external URLs", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 316d3138bfa..2e582294229 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -25,6 +25,7 @@ export class ElectronShell extends Context.Service< { readonly openExternal: (rawUrl: unknown) => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; + readonly beep: Effect.Effect; } >()("@t3tools/desktop/electron/ElectronShell") {} @@ -44,6 +45,9 @@ export const make = ElectronShell.of({ Effect.sync(() => { Electron.clipboard.writeText(text); }), + beep: Effect.sync(() => { + Electron.shell.beep(); + }), }); export const layer = Layer.succeed(ElectronShell, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..76ba0f5098c 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -38,6 +38,7 @@ import { getWindowFullscreenState, openExternal, pickFolder, + playTurnCompletionSound, setTheme, showContextMenu, } from "./methods/window.ts"; @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(playTurnCompletionSound); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5988b1e42f9..7bcfcd2138a 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,6 +3,7 @@ export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const PLAY_TURN_COMPLETION_SOUND_CHANNEL = "desktop:play-turn-completion-sound"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 13e6e8d3956..3e2aad7fb84 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -5,10 +5,16 @@ import * as Option from "effect/Option"; import type * as Electron from "electron"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as ElectronShell from "../../electron/ElectronShell.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; -import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; +import { + getLocalEnvironmentBootstraps, + getWindowFullscreenState, + playTurnCompletionSound, +} from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "wsl.exe", @@ -146,3 +152,49 @@ describe("getWindowFullscreenState", () => { ); }); }); + +describe("playTurnCompletionSound", () => { + it.effect("plays the native beep on Linux", () => { + let beepCount = 0; + + return Effect.gen(function* () { + yield* playTurnCompletionSound.handler(undefined); + assert.equal(beepCount, 1); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "linux", + } as DesktopEnvironment.DesktopEnvironment["Service"]), + Layer.mock(ElectronShell.ElectronShell)({ + beep: Effect.sync(() => { + beepCount += 1; + }), + }), + ), + ), + ); + }); + + it.effect("does nothing on other desktop platforms", () => { + let beepCount = 0; + + return Effect.gen(function* () { + yield* playTurnCompletionSound.handler(undefined); + assert.equal(beepCount, 0); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "darwin", + } as DesktopEnvironment.DesktopEnvironment["Service"]), + Layer.mock(ElectronShell.ElectronShell)({ + beep: Effect.sync(() => { + beepCount += 1; + }), + }), + ), + ), + ); + }); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index a4e98aaabad..ae993763e72 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -268,3 +268,18 @@ export const openExternal = DesktopIpc.makeIpcMethod({ return yield* shell.openExternal(url); }), }); + +export const playTurnCompletionSound = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PLAY_TURN_COMPLETION_SOUND_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.playTurnCompletionSound")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (environment.platform !== "linux") { + return; + } + + const shell = yield* ElectronShell.ElectronShell; + yield* shell.beep; + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f01baeed90..7cc219baf0f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -105,6 +105,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + playTurnCompletionSound: () => ipcRenderer.invoke(IpcChannels.PLAY_TURN_COMPLETION_SOUND_CHANNEL), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3cd06cfcf32..aab7580b644 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -256,6 +256,7 @@ function makeTestLayer(input: { return true; }), copyText: () => Effect.void, + beep: Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, electronWindowLayer, @@ -350,6 +351,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), copyText: () => Effect.void, + beep: Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape), diff --git a/apps/web/src/components/desktop/DesktopTurnCompletionSound.test.ts b/apps/web/src/components/desktop/DesktopTurnCompletionSound.test.ts new file mode 100644 index 00000000000..1ec3698dc8d --- /dev/null +++ b/apps/web/src/components/desktop/DesktopTurnCompletionSound.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + createTurnCompletionTracker, + type TurnCompletionSnapshot, +} from "./DesktopTurnCompletionSound"; + +function snapshot(overrides: Partial = {}): TurnCompletionSnapshot { + return { + threadKey: "environment-1:thread-1", + turnId: "turn-1", + state: "running", + completedAt: null, + ...overrides, + }; +} + +describe("createTurnCompletionTracker", () => { + it("reports a turn that transitions from running to completed", () => { + const tracker = createTurnCompletionTracker(); + + expect(tracker.sync([snapshot()])).toBe(0); + expect( + tracker.sync([snapshot({ state: "completed", completedAt: "2026-08-03T08:00:00.000Z" })]), + ).toBe(1); + }); + + it("does not report the initial snapshot or repeat a completed turn", () => { + const tracker = createTurnCompletionTracker(); + const completed = snapshot({ + state: "completed", + completedAt: "2026-08-03T08:00:00.000Z", + }); + + expect(tracker.sync([completed])).toBe(0); + expect(tracker.sync([completed])).toBe(0); + }); + + it("reports a newer completed turn for an already observed thread", () => { + const tracker = createTurnCompletionTracker(); + + expect( + tracker.sync([snapshot({ state: "completed", completedAt: "2026-08-03T08:00:00.000Z" })]), + ).toBe(0); + expect( + tracker.sync([ + snapshot({ + turnId: "turn-2", + state: "completed", + completedAt: "2026-08-03T09:00:00.000Z", + }), + ]), + ).toBe(1); + }); + + it("ignores interrupted and errored turns", () => { + const tracker = createTurnCompletionTracker(); + + expect(tracker.sync([snapshot()])).toBe(0); + expect( + tracker.sync([snapshot({ state: "interrupted", completedAt: "2026-08-03T08:00:00.000Z" })]), + ).toBe(0); + expect( + tracker.sync([ + snapshot({ + turnId: "turn-2", + state: "error", + completedAt: "2026-08-03T09:00:00.000Z", + }), + ]), + ).toBe(0); + }); + + it("does not report a completed thread that appears after initialization", () => { + const tracker = createTurnCompletionTracker(); + + expect(tracker.sync([snapshot()])).toBe(0); + expect( + tracker.sync([ + snapshot(), + snapshot({ + threadKey: "environment-1:thread-2", + turnId: "turn-2", + state: "completed", + completedAt: "2026-08-03T09:00:00.000Z", + }), + ]), + ).toBe(0); + }); +}); diff --git a/apps/web/src/components/desktop/DesktopTurnCompletionSound.tsx b/apps/web/src/components/desktop/DesktopTurnCompletionSound.tsx new file mode 100644 index 00000000000..0d8d3f2b41a --- /dev/null +++ b/apps/web/src/components/desktop/DesktopTurnCompletionSound.tsx @@ -0,0 +1,86 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { OrchestrationLatestTurnState } from "@t3tools/contracts"; +import { useEffect, useRef } from "react"; + +import { useThreadShells } from "../../state/entities"; + +export interface TurnCompletionSnapshot { + readonly threadKey: string; + readonly turnId: string | null; + readonly state: OrchestrationLatestTurnState | null; + readonly completedAt: string | null; +} + +interface ObservedTurn { + readonly turnId: string | null; + readonly completedAt: string | null; +} + +export interface TurnCompletionTracker { + readonly sync: (snapshots: readonly TurnCompletionSnapshot[]) => number; +} + +export function createTurnCompletionTracker(): TurnCompletionTracker { + let initialized = false; + let observedTurns = new Map(); + + return { + sync: (snapshots) => { + const nextObservedTurns = new Map(); + let completedTurnCount = 0; + + for (const snapshot of snapshots) { + const currentTurn = { + turnId: snapshot.turnId, + completedAt: snapshot.completedAt, + }; + nextObservedTurns.set(snapshot.threadKey, currentTurn); + + const previousTurn = observedTurns.get(snapshot.threadKey); + if ( + initialized && + previousTurn !== undefined && + snapshot.state === "completed" && + snapshot.completedAt !== null && + (previousTurn.turnId !== snapshot.turnId || + previousTurn.completedAt !== snapshot.completedAt) + ) { + completedTurnCount += 1; + } + } + + observedTurns = nextObservedTurns; + initialized = true; + return completedTurnCount; + }, + }; +} + +function DesktopTurnCompletionObserver({ playSound }: { readonly playSound: () => Promise }) { + const threadShells = useThreadShells(); + const trackerRef = useRef(null); + trackerRef.current ??= createTurnCompletionTracker(); + + useEffect(() => { + const snapshots = threadShells.map((thread) => ({ + threadKey: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + turnId: thread.latestTurn?.turnId ?? null, + state: thread.latestTurn?.state ?? null, + completedAt: thread.latestTurn?.completedAt ?? null, + })); + const completedTurnCount = trackerRef.current?.sync(snapshots) ?? 0; + + for (let index = 0; index < completedTurnCount; index += 1) { + void playSound().catch(() => undefined); + } + }, [playSound, threadShells]); + + return null; +} + +export function DesktopTurnCompletionSound() { + const playSound = window.desktopBridge?.playTurnCompletionSound; + return typeof playSound === "function" ? ( + + ) : null; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114d..21eb6290c33 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -16,6 +16,7 @@ import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; +import { DesktopTurnCompletionSound } from "../components/desktop/DesktopTurnCompletionSound"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; @@ -132,6 +133,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} diff --git a/docs/user/install.md b/docs/user/install.md index fe0b418ca1e..a623e2aef05 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -41,6 +41,8 @@ Arch Linux: yay -S t3code-bin ``` +On Linux, the desktop app plays the system beep when an agent turn completes. + ## Providers T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a92eb972d67..e7b1f1223b6 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1007,6 +1007,7 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; + playTurnCompletionSound?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; From b93fd064451f700f99cc70a6d9f8bff3808e264a Mon Sep 17 00:00:00 2001 From: Barna Karacsony Date: Mon, 3 Aug 2026 18:59:58 +0200 Subject: [PATCH 2/2] fix(web): avoid duplicate completion sounds --- .../desktop/DesktopTurnCompletionSound.test.ts | 12 ++++++++++++ .../desktop/DesktopTurnCompletionSound.tsx | 7 +++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/desktop/DesktopTurnCompletionSound.test.ts b/apps/web/src/components/desktop/DesktopTurnCompletionSound.test.ts index 1ec3698dc8d..0e9ef60cdec 100644 --- a/apps/web/src/components/desktop/DesktopTurnCompletionSound.test.ts +++ b/apps/web/src/components/desktop/DesktopTurnCompletionSound.test.ts @@ -36,6 +36,18 @@ describe("createTurnCompletionTracker", () => { expect(tracker.sync([completed])).toBe(0); }); + it("does not repeat a completed turn when its completion timestamp changes", () => { + const tracker = createTurnCompletionTracker(); + + expect(tracker.sync([snapshot()])).toBe(0); + expect( + tracker.sync([snapshot({ state: "completed", completedAt: "2026-08-03T08:00:00.000Z" })]), + ).toBe(1); + expect( + tracker.sync([snapshot({ state: "completed", completedAt: "2026-08-03T08:00:01.000Z" })]), + ).toBe(0); + }); + it("reports a newer completed turn for an already observed thread", () => { const tracker = createTurnCompletionTracker(); diff --git a/apps/web/src/components/desktop/DesktopTurnCompletionSound.tsx b/apps/web/src/components/desktop/DesktopTurnCompletionSound.tsx index 0d8d3f2b41a..16adfe4f2f5 100644 --- a/apps/web/src/components/desktop/DesktopTurnCompletionSound.tsx +++ b/apps/web/src/components/desktop/DesktopTurnCompletionSound.tsx @@ -13,7 +13,7 @@ export interface TurnCompletionSnapshot { interface ObservedTurn { readonly turnId: string | null; - readonly completedAt: string | null; + readonly state: OrchestrationLatestTurnState | null; } export interface TurnCompletionTracker { @@ -32,7 +32,7 @@ export function createTurnCompletionTracker(): TurnCompletionTracker { for (const snapshot of snapshots) { const currentTurn = { turnId: snapshot.turnId, - completedAt: snapshot.completedAt, + state: snapshot.state, }; nextObservedTurns.set(snapshot.threadKey, currentTurn); @@ -42,8 +42,7 @@ export function createTurnCompletionTracker(): TurnCompletionTracker { previousTurn !== undefined && snapshot.state === "completed" && snapshot.completedAt !== null && - (previousTurn.turnId !== snapshot.turnId || - previousTurn.completedAt !== snapshot.completedAt) + (previousTurn.turnId !== snapshot.turnId || previousTurn.state !== "completed") ) { completedTurnCount += 1; }