diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index 1c17d58215ea..df3fd72a4397 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -46,6 +46,9 @@ const clientSettings: ClientSettings = {
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
legacySidebarEnabled: false,
+ threadCompletionNotifications: true,
+ threadCompletionNotificationSound: false,
+ threadCompletionNotificationVolume: 70,
timestampFormat: "24-hour",
wordWrap: true,
};
diff --git a/apps/web/src/components/ThreadCompletionNotifications.tsx b/apps/web/src/components/ThreadCompletionNotifications.tsx
new file mode 100644
index 000000000000..f69322a89cfb
--- /dev/null
+++ b/apps/web/src/components/ThreadCompletionNotifications.tsx
@@ -0,0 +1,106 @@
+import { useEffect, useMemo, useRef } from "react";
+import { useRouter } from "@tanstack/react-router";
+import { projectKey } from "@t3tools/client-runtime/state/entities";
+import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { useClientSettings } from "~/hooks/useSettings";
+import { showNotification, useNotificationPermission } from "~/notificationPermission";
+import { playNotificationChime, primeNotificationChime } from "~/notificationChime";
+import { useProjects, useThreadShells } from "~/state/entities";
+import { buildThreadRouteParams } from "~/threadRoutes";
+import {
+ deriveThreadCompletionNotifications,
+ NO_OBSERVED_THREADS,
+ type ObservedThreads,
+ type ThreadCompletionNotification,
+} from "~/threadCompletionNotifications.logic";
+
+/**
+ * Raises an OS notification when a thread finishes its turn.
+ *
+ * Belongs in the app shell rather than the chat route: a turn that finishes
+ * while the user is in Settings should still reach them, and remounting would
+ * re-seed the watcher and swallow it.
+ */
+export function ThreadCompletionNotifications() {
+ const enabled = useClientSettings((settings) => settings.threadCompletionNotifications);
+ const { permission } = useNotificationPermission();
+
+ // Nothing below this line subscribes to thread updates until the user has
+ // both opted in and granted permission.
+ if (!enabled || permission !== "granted") return null;
+ return ;
+}
+
+function ThreadCompletionWatcher() {
+ const soundEnabled = useClientSettings((settings) => settings.threadCompletionNotificationSound);
+ const volume = useClientSettings((settings) => settings.threadCompletionNotificationVolume);
+ const threads = useThreadShells();
+ const projects = useProjects();
+ const router = useRouter();
+ const observed = useRef(NO_OBSERVED_THREADS);
+
+ const projectTitles = useMemo(() => {
+ const titles = new Map();
+ for (const project of projects) {
+ titles.set(projectKey(scopeProjectRef(project.environmentId, project.id)), project.title);
+ }
+ return titles;
+ }, [projects]);
+
+ // A reload leaves the audio context locked until the page sees a gesture,
+ // and the user has no reason to open Settings again to unlock it.
+ useEffect(() => {
+ if (!soundEnabled) return;
+ const unlock = () => primeNotificationChime();
+ const options = { capture: true, once: true } as const;
+ window.addEventListener("pointerdown", unlock, options);
+ window.addEventListener("keydown", unlock, options);
+ return () => {
+ window.removeEventListener("pointerdown", unlock, options);
+ window.removeEventListener("keydown", unlock, options);
+ };
+ }, [soundEnabled]);
+
+ useEffect(() => {
+ const result = deriveThreadCompletionNotifications({ threads, observed: observed.current });
+ observed.current = result.observed;
+
+ let delivered = 0;
+ for (const notification of result.notifications) {
+ if (show(notification, projectTitles, router)) delivered += 1;
+ }
+
+ // One chime for the batch: two threads settling in the same snapshot
+ // should not stack two sounds.
+ if (soundEnabled && delivered > 0) {
+ playNotificationChime(volume);
+ }
+ }, [projectTitles, router, soundEnabled, threads, volume]);
+
+ return null;
+}
+
+function show(
+ notification: ThreadCompletionNotification,
+ projectTitles: ReadonlyMap,
+ router: ReturnType,
+): boolean {
+ const threadRef = scopeThreadRef(notification.environmentId, notification.threadId);
+ const projectTitle = projectTitles.get(
+ projectKey(scopeProjectRef(notification.environmentId, notification.projectId)),
+ );
+
+ return showNotification({
+ title: notification.title,
+ body: projectTitle ? `${projectTitle} ยท Turn finished` : "Turn finished",
+ // Turn-scoped: replacing a thread's earlier card would re-render it
+ // without re-alerting, which is the one thing this must not do.
+ tag: `t3code-thread-complete:${notification.turnId}`,
+ onClick: () => {
+ void router.navigate({
+ to: "/$environmentId/$threadId",
+ params: buildThreadRouteParams(threadRef),
+ });
+ },
+ });
+}
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index e3f00c1e0e23..352b8ce66fde 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -23,15 +23,18 @@ import {
MAX_CODE_FONT_SIZE,
MAX_GLASS_OPACITY,
MAX_INTERFACE_FONT_SIZE,
+ MAX_NOTIFICATION_VOLUME,
MAX_PROMPT_FONT_SIZE,
MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
MAX_TERMINAL_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_GLASS_OPACITY,
MIN_INTERFACE_FONT_SIZE,
+ MIN_NOTIFICATION_VOLUME,
MIN_PROMPT_FONT_SIZE,
MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
MIN_TERMINAL_FONT_SIZE,
+ type UnifiedSettings,
} from "@t3tools/contracts/settings";
import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
import { createModelSelection } from "@t3tools/shared/model";
@@ -64,6 +67,12 @@ import {
import { useLocalStorage } from "../../hooks/useLocalStorage";
import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
import { useThreadActions } from "../../hooks/useThreadActions";
+import {
+ type NotificationPermissionState,
+ showNotification,
+ useNotificationPermission,
+} from "../../notificationPermission";
+import { playNotificationChime, primeNotificationChime } from "../../notificationChime";
import { useDesktopUpdateState } from "../../state/desktopUpdate";
import {
getCustomModelOptionsByInstance,
@@ -1763,6 +1772,199 @@ function LegacyFeaturesSection() {
);
}
+const NOTIFICATION_PERMISSION_STATUS: Partial> = {
+ default: "Notification permission was reset. Use Test to ask for it again.",
+ denied:
+ "Notifications are blocked for this site. Allow them in your browser's site settings to turn this on.",
+ unsupported: "This browser cannot show system notifications.",
+};
+
+function ThreadCompletionNotificationVolumeRow({
+ volume,
+ updateSettings,
+}: {
+ volume: number;
+ updateSettings: ReturnType;
+}) {
+ const previewTimerRef = useRef(null);
+ useEffect(
+ () => () => {
+ if (previewTimerRef.current !== null) window.clearTimeout(previewTimerRef.current);
+ },
+ [],
+ );
+
+ const volumeRatio =
+ (volume - MIN_NOTIFICATION_VOLUME) / (MAX_NOTIFICATION_VOLUME - MIN_NOTIFICATION_VOLUME);
+ const volumeSliderStyle = {
+ "--settings-slider-progress": `${volumeRatio * 100}%`,
+ "--settings-slider-fill-offset": `${0.5 - volumeRatio}rem`,
+ } as CSSProperties;
+
+ return (
+
+
+ {
+ const nextVolume = Number(event.currentTarget.value);
+ if (
+ !Number.isInteger(nextVolume) ||
+ nextVolume < MIN_NOTIFICATION_VOLUME ||
+ nextVolume > MAX_NOTIFICATION_VOLUME
+ ) {
+ return;
+ }
+
+ primeNotificationChime();
+ updateSettings({ threadCompletionNotificationVolume: nextVolume });
+ if (previewTimerRef.current !== null) {
+ window.clearTimeout(previewTimerRef.current);
+ }
+ previewTimerRef.current = window.setTimeout(() => {
+ previewTimerRef.current = null;
+ playNotificationChime(nextVolume);
+ }, 400);
+ }}
+ step={10}
+ style={volumeSliderStyle}
+ type="range"
+ value={volume}
+ />
+
+ }
+ />
+ );
+}
+
+function ThreadCompletionNotificationRows({
+ settings,
+ updateSettings,
+}: {
+ settings: UnifiedSettings;
+ updateSettings: ReturnType;
+}) {
+ const { permission, request } = useNotificationPermission();
+ const enabled = settings.threadCompletionNotifications;
+
+ // The stored preference is what the switch reflects, so a permission the
+ // user revokes later still leaves them a switch they can turn off.
+ const enable = useCallback(() => {
+ if (permission === "denied" || permission === "unsupported") return;
+ // Synchronously, while the click is still the current task: an audio
+ // context created after awaiting the permission prompt starts suspended
+ // and stays that way.
+ if (settings.threadCompletionNotificationSound) {
+ primeNotificationChime();
+ }
+ void request().then((next) => {
+ if (next === "granted") {
+ updateSettings({ threadCompletionNotifications: true });
+ }
+ });
+ }, [permission, request, settings.threadCompletionNotificationSound, updateSettings]);
+
+ const testNotification = useCallback(() => {
+ const soundEnabled = settings.threadCompletionNotificationSound;
+ if (soundEnabled) {
+ primeNotificationChime();
+ }
+
+ void (async () => {
+ const nextPermission = permission === "granted" ? permission : await request();
+ if (nextPermission !== "granted") return;
+
+ const delivered = showNotification({
+ title: "Test notification",
+ body: "Notifications are working.",
+ tag: "t3code-thread-complete:test",
+ });
+ if (delivered && soundEnabled) {
+ playNotificationChime(settings.threadCompletionNotificationVolume);
+ }
+ })();
+ }, [
+ permission,
+ request,
+ settings.threadCompletionNotificationSound,
+ settings.threadCompletionNotificationVolume,
+ ]);
+
+ return (
+ <>
+
+ {enabled ? (
+
+ ) : null}
+ {
+ if (checked) {
+ enable();
+ return;
+ }
+ updateSettings({ threadCompletionNotifications: false });
+ }}
+ aria-label="Thread completion notifications"
+ />
+
+ }
+ />
+
+ {enabled ? (
+ {
+ const next = Boolean(checked);
+ if (next) {
+ primeNotificationChime();
+ }
+ updateSettings({ threadCompletionNotificationSound: next });
+ }}
+ aria-label="Thread completion notification sound"
+ />
+ }
+ />
+ ) : null}
+
+ {enabled && settings.threadCompletionNotificationSound ? (
+
+ ) : null}
+ >
+ );
+}
+
export function GeneralSettingsPanel() {
const settings = usePrimarySettings();
const updateSettings = useUpdatePrimarySettings();
@@ -2256,6 +2458,8 @@ export function GeneralSettingsPanel() {
}
/>
+
+
{isElectron ? (
{
+ it("leaves real headroom above the default", () => {
+ expect(chimeGainForVolume(100)).toBeGreaterThan(
+ chimeGainForVolume(DEFAULT_NOTIFICATION_VOLUME) * 4,
+ );
+ });
+
+ it("increases monotonically across the supported range", () => {
+ for (let volume = 20; volume <= 100; volume += 10) {
+ expect(chimeGainForVolume(volume)).toBeGreaterThan(chimeGainForVolume(volume - 10));
+ }
+ });
+
+ it("clamps out-of-range inputs", () => {
+ expect(chimeGainForVolume(-100)).toBe(chimeGainForVolume(0));
+ expect(chimeGainForVolume(200)).toBe(chimeGainForVolume(100));
+ });
+});
diff --git a/apps/web/src/notificationChime.ts b/apps/web/src/notificationChime.ts
new file mode 100644
index 000000000000..33f5d63adfe7
--- /dev/null
+++ b/apps/web/src/notificationChime.ts
@@ -0,0 +1,88 @@
+/**
+ * The sound played with a thread-completion notification.
+ *
+ * Synthesized rather than bundled: desktop browsers ignore a notification's
+ * own sound options, so the app has to make one, and two sine notes cost no
+ * asset and no decode step.
+ */
+
+/** B5 then E6 โ a short rising pair, distinct from system alert sounds. Triangle
+ * rather than sine: the added harmonics read louder at the same amplitude. */
+const CHIME_NOTES: ReadonlyArray<{ frequency: number; offsetSeconds: number }> = [
+ { frequency: 987.77, offsetSeconds: 0 },
+ { frequency: 1318.51, offsetSeconds: 0.11 },
+];
+const NOTE_DURATION_SECONDS = 0.16;
+const MAX_CHIME_GAIN = 0.8;
+/**
+ * Decibels lost per point below full volume. Loudness is logarithmic, so a
+ * linear amplitude ramp bunches the whole useful range at the top; 0.5 dB a
+ * point spreads it evenly and leaves 100 genuinely loud.
+ */
+const DECIBELS_PER_VOLUME_POINT = 0.5;
+
+type AudioContextConstructor = typeof AudioContext;
+
+let sharedContext: AudioContext | null = null;
+
+export function chimeGainForVolume(volume: number): number {
+ const belowFull = 100 - Math.min(100, Math.max(0, volume));
+ return MAX_CHIME_GAIN * 10 ** ((-belowFull * DECIBELS_PER_VOLUME_POINT) / 20);
+}
+
+function ensureContext(): AudioContext | null {
+ if (sharedContext) return sharedContext;
+ const Constructor =
+ window.AudioContext ??
+ (window as { webkitAudioContext?: AudioContextConstructor }).webkitAudioContext;
+ if (!Constructor) return null;
+ try {
+ sharedContext = new Constructor();
+ } catch {
+ return null;
+ }
+ return sharedContext;
+}
+
+/**
+ * Creates and resumes the context. Call from a user gesture, synchronously:
+ * browsers start a context suspended, and notes scheduled on a suspended
+ * context are dropped without an error.
+ */
+export function primeNotificationChime(): void {
+ const context = ensureContext();
+ if (context?.state !== "suspended") return;
+ void context.resume().catch(() => {});
+}
+
+export function playNotificationChime(volume: number): void {
+ const context = ensureContext();
+ // Scheduling onto a suspended context queues notes against a clock that is
+ // not advancing: they would all fire at once whenever it resumes. Drop the
+ // chime instead and let the next gesture prime it.
+ if (context?.state !== "running") {
+ primeNotificationChime();
+ return;
+ }
+
+ const startedAt = context.currentTime + 0.01;
+ for (const note of CHIME_NOTES) {
+ const noteStartedAt = startedAt + note.offsetSeconds;
+ const noteEndedAt = noteStartedAt + NOTE_DURATION_SECONDS;
+
+ const oscillator = context.createOscillator();
+ oscillator.type = "triangle";
+ oscillator.frequency.value = note.frequency;
+
+ // Ramped, not switched: a square-edged gain change clicks.
+ const gain = context.createGain();
+ gain.gain.setValueAtTime(0, noteStartedAt);
+ gain.gain.linearRampToValueAtTime(chimeGainForVolume(volume), noteStartedAt + 0.01);
+ gain.gain.exponentialRampToValueAtTime(0.0001, noteEndedAt);
+
+ oscillator.connect(gain).connect(context.destination);
+ oscillator.addEventListener("ended", () => gain.disconnect(), { once: true });
+ oscillator.start(noteStartedAt);
+ oscillator.stop(noteEndedAt);
+ }
+}
diff --git a/apps/web/src/notificationPermission.ts b/apps/web/src/notificationPermission.ts
new file mode 100644
index 000000000000..d6d196b7206d
--- /dev/null
+++ b/apps/web/src/notificationPermission.ts
@@ -0,0 +1,92 @@
+import { useCallback, useSyncExternalStore } from "react";
+
+export type NotificationPermissionState = "unsupported" | NotificationPermission;
+
+export function readNotificationPermission(): NotificationPermissionState {
+ return typeof Notification === "undefined" ? "unsupported" : Notification.permission;
+}
+
+export function showNotification(input: {
+ title: string;
+ body: string;
+ tag: string;
+ onClick?: () => void;
+}): boolean {
+ if (readNotificationPermission() !== "granted") return false;
+
+ try {
+ const shown = new Notification(input.title, {
+ body: input.body,
+ tag: input.tag,
+ icon: "/apple-touch-icon.png",
+ });
+ shown.addEventListener(
+ "click",
+ () => {
+ window.focus();
+ shown.close();
+ input.onClick?.();
+ },
+ { once: true },
+ );
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Permission is process-wide, so it is held in one store rather than per hook
+ * instance: granting it from Settings has to reach the watcher mounted in the
+ * app shell, and it is also changed outside the page, in browser site
+ * settings, where returning to the tab is the only notice of it.
+ */
+const listeners = new Set<() => void>();
+let permissionSnapshot = readNotificationPermission();
+
+function syncPermission(): NotificationPermissionState {
+ const next = readNotificationPermission();
+ if (next !== permissionSnapshot) {
+ permissionSnapshot = next;
+ for (const listener of listeners) listener();
+ }
+ return permissionSnapshot;
+}
+
+function subscribe(listener: () => void): () => void {
+ if (listeners.size === 0) {
+ window.addEventListener("focus", syncPermission);
+ document.addEventListener("visibilitychange", syncPermission);
+ }
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0) {
+ window.removeEventListener("focus", syncPermission);
+ document.removeEventListener("visibilitychange", syncPermission);
+ }
+ };
+}
+
+function getPermissionSnapshot(): NotificationPermissionState {
+ return permissionSnapshot;
+}
+
+export function useNotificationPermission(): {
+ readonly permission: NotificationPermissionState;
+ readonly request: () => Promise;
+} {
+ const permission = useSyncExternalStore(subscribe, getPermissionSnapshot, getPermissionSnapshot);
+
+ // Must be called from a user gesture: browsers reject a prompt raised outside
+ // one, and Safari does so without resolving the promise.
+ const request = useCallback(async () => {
+ if (typeof Notification === "undefined") return "unsupported" as const;
+ if (Notification.permission === "default") {
+ await Notification.requestPermission().catch(() => Notification.permission);
+ }
+ return syncPermission();
+ }, []);
+
+ return { permission, request };
+}
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index c4f65564efc7..7781adf97cc7 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -19,6 +19,7 @@ import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDi
import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
+import { ThreadCompletionNotifications } from "../components/ThreadCompletionNotifications";
import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
import { Button } from "../components/ui/button";
@@ -141,6 +142,7 @@ function RootRouteView() {
{primaryEnvironmentAuthenticated ? : null}
{primaryEnvironmentAuthenticated ? : null}
+
{appShell}
{/* Above the router: a theme draft is judged by walking the app, so the
editor has to survive navigation away from settings. */}
diff --git a/apps/web/src/threadCompletionNotifications.logic.test.ts b/apps/web/src/threadCompletionNotifications.logic.test.ts
new file mode 100644
index 000000000000..4098b5787aa9
--- /dev/null
+++ b/apps/web/src/threadCompletionNotifications.logic.test.ts
@@ -0,0 +1,162 @@
+import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ deriveThreadCompletionNotifications,
+ NO_OBSERVED_THREADS,
+ type ObservedThreads,
+ type ThreadCompletionCandidate,
+} from "./threadCompletionNotifications.logic";
+
+const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
+
+function thread(id: string, overrides: Partial = {}) {
+ return {
+ environmentId: ENVIRONMENT_ID,
+ id: ThreadId.make(id),
+ projectId: ProjectId.make("project-1"),
+ title: `Thread ${id}`,
+ archivedAt: null,
+ latestTurn: {
+ turnId: TurnId.make(`turn-${id}`),
+ state: "completed",
+ startedAt: "2026-01-01T00:04:00.000Z",
+ completedAt: "2026-01-01T00:05:00.000Z",
+ },
+ session: { status: "idle", activeTurnId: null },
+ ...overrides,
+ } satisfies ThreadCompletionCandidate;
+}
+
+function running(id: string) {
+ return thread(id, {
+ latestTurn: {
+ turnId: TurnId.make(`turn-${id}`),
+ state: "running",
+ startedAt: "2026-01-01T00:04:00.000Z",
+ completedAt: null,
+ },
+ session: { status: "running", activeTurnId: TurnId.make(`turn-${id}`) },
+ });
+}
+
+function derive(threads: ReadonlyArray, observed: ObservedThreads) {
+ return deriveThreadCompletionNotifications({ threads, observed });
+}
+
+describe("deriveThreadCompletionNotifications", () => {
+ it("stays silent on threads it is seeing for the first time", () => {
+ const result = derive([thread("a"), thread("b")], NO_OBSERVED_THREADS);
+
+ expect(result.notifications).toEqual([]);
+ expect(result.observed.size).toBe(2);
+ });
+
+ it("announces a turn that finishes while it is watching", () => {
+ const seen = derive([running("a")], NO_OBSERVED_THREADS).observed;
+
+ const result = derive([thread("a")], seen);
+
+ expect(result.notifications).toEqual([
+ {
+ environmentId: ENVIRONMENT_ID,
+ threadId: ThreadId.make("a"),
+ projectId: ProjectId.make("project-1"),
+ turnId: TurnId.make("turn-a"),
+ title: "Thread a",
+ },
+ ]);
+ });
+
+ it("announces a thread once per turn, not once per snapshot", () => {
+ const finished = derive([thread("a")], derive([running("a")], NO_OBSERVED_THREADS).observed);
+ expect(finished.notifications).toHaveLength(1);
+
+ expect(derive([thread("a")], finished.observed).notifications).toEqual([]);
+
+ const nextTurn = derive(
+ [
+ thread("a", {
+ latestTurn: {
+ turnId: TurnId.make("turn-a2"),
+ state: "completed",
+ startedAt: "2026-01-01T00:06:00.000Z",
+ completedAt: "2026-01-01T00:07:00.000Z",
+ },
+ }),
+ ],
+ finished.observed,
+ );
+ expect(nextTurn.notifications).toHaveLength(1);
+ });
+
+ it("stays silent when a thread reappears after dropping out of the snapshot", () => {
+ const finished = derive([thread("a")], derive([running("a")], NO_OBSERVED_THREADS).observed);
+ expect(finished.notifications).toHaveLength(1);
+
+ const disconnected = derive([], finished.observed);
+ expect(disconnected.observed.size).toBe(0);
+
+ expect(derive([thread("a")], disconnected.observed).notifications).toEqual([]);
+ });
+
+ it("holds a settled turn back while delegated subagents are still working", () => {
+ const seen = derive([running("a")], NO_OBSERVED_THREADS).observed;
+
+ const working = derive([thread("a", { backgroundLiveness: "working" })], seen);
+ expect(working.notifications).toEqual([]);
+
+ const done = derive([thread("a", { backgroundLiveness: null })], working.observed);
+ expect(done.notifications).toHaveLength(1);
+ });
+
+ it("announces a thread whose only live work is a watch loop", () => {
+ const seen = derive([running("a")], NO_OBSERVED_THREADS).observed;
+
+ const result = derive([thread("a", { backgroundLiveness: "monitoring" })], seen);
+
+ expect(result.notifications).toHaveLength(1);
+ });
+
+ it("stays silent for turns that were interrupted or failed", () => {
+ for (const state of ["interrupted", "error"] as const) {
+ const seen = derive([running("a")], NO_OBSERVED_THREADS).observed;
+
+ const result = derive(
+ [
+ thread("a", {
+ latestTurn: {
+ turnId: TurnId.make("turn-a"),
+ state,
+ startedAt: "2026-01-01T00:04:00.000Z",
+ completedAt: "2026-01-01T00:05:00.000Z",
+ },
+ }),
+ ],
+ seen,
+ );
+
+ expect(result.notifications).toEqual([]);
+ }
+ });
+
+ it("ignores archived threads entirely", () => {
+ const seen = derive([running("a")], NO_OBSERVED_THREADS).observed;
+
+ const result = derive([thread("a", { archivedAt: "2026-01-01T00:05:00.000Z" })], seen);
+
+ expect(result.notifications).toEqual([]);
+ expect(result.observed.size).toBe(0);
+ });
+
+ it("waits for the session to stop running before announcing", () => {
+ const seen = derive([running("a")], NO_OBSERVED_THREADS).observed;
+
+ const stillRunning = derive(
+ [thread("a", { session: { status: "running", activeTurnId: TurnId.make("turn-a") } })],
+ seen,
+ );
+
+ expect(stillRunning.notifications).toEqual([]);
+ });
+});
diff --git a/apps/web/src/threadCompletionNotifications.logic.ts b/apps/web/src/threadCompletionNotifications.logic.ts
new file mode 100644
index 000000000000..0016fadeaaee
--- /dev/null
+++ b/apps/web/src/threadCompletionNotifications.logic.ts
@@ -0,0 +1,105 @@
+import { threadKey } from "@t3tools/client-runtime/state/entities";
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import type {
+ EnvironmentId,
+ OrchestrationLatestTurn,
+ OrchestrationSession,
+ ProjectId,
+ ThreadId,
+ TurnId,
+} from "@t3tools/contracts";
+
+import { isLatestTurnSettled } from "./session-logic";
+
+/** The thread fields a completion decision reads. */
+export interface ThreadCompletionCandidate {
+ readonly environmentId: EnvironmentId;
+ readonly id: ThreadId;
+ readonly projectId: ProjectId;
+ readonly title: string;
+ readonly archivedAt: string | null;
+ readonly latestTurn: Pick<
+ OrchestrationLatestTurn,
+ "turnId" | "state" | "startedAt" | "completedAt"
+ > | null;
+ readonly session: Pick | null;
+ readonly backgroundLiveness?: "working" | "monitoring" | null | undefined;
+}
+
+export interface ThreadCompletionNotification {
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+ readonly projectId: ProjectId;
+ readonly turnId: TurnId;
+ readonly title: string;
+}
+
+interface ObservedThread {
+ readonly turnId: TurnId | null;
+ readonly finished: boolean;
+}
+
+export type ObservedThreads = ReadonlyMap;
+
+export const NO_OBSERVED_THREADS: ObservedThreads = new Map();
+
+/**
+ * Whether the thread's latest turn ran to completion and nothing is left
+ * running behind it.
+ *
+ * `backgroundLiveness === "working"` keeps a thread pending: the primary agent
+ * settling while delegated subagents still run is not a stopping point. Turns
+ * that ended in `interrupted` or `error` carry a `completedAt` too, and neither
+ * is something to announce as finished work.
+ */
+export function isCompletedTurn(thread: ThreadCompletionCandidate): boolean {
+ if (thread.backgroundLiveness === "working") return false;
+ const latestTurn = thread.latestTurn;
+ if (latestTurn?.state !== "completed") return false;
+ return isLatestTurnSettled(latestTurn, thread.session);
+}
+
+/**
+ * Completions observed since the last call, paired with the snapshot to pass
+ * back in next time.
+ *
+ * Announces a transition rather than a state, so it needs no clock of its own:
+ * a thread is news only once this client has seen it unfinished (or on an
+ * earlier turn) and then finished. A thread seen for the first time โ on load,
+ * on reconnect, or when an environment joins late โ is recorded silently,
+ * which is what keeps a page load from announcing the user's whole history.
+ */
+export function deriveThreadCompletionNotifications(input: {
+ readonly threads: ReadonlyArray;
+ readonly observed: ObservedThreads;
+}): {
+ readonly notifications: ReadonlyArray;
+ // Rebuilt from the current threads, so threads that go away drop out with it.
+ readonly observed: ObservedThreads;
+} {
+ const observed = new Map();
+ const notifications: ThreadCompletionNotification[] = [];
+
+ for (const thread of input.threads) {
+ if (thread.archivedAt !== null) continue;
+
+ const key = threadKey(scopeThreadRef(thread.environmentId, thread.id));
+ const turnId = thread.latestTurn?.turnId ?? null;
+ const finished = isCompletedTurn(thread);
+ observed.set(key, { turnId, finished });
+
+ const previous = input.observed.get(key);
+ if (previous === undefined || !finished || turnId === null) continue;
+ if (previous.finished && previous.turnId === turnId) continue;
+
+ notifications.push({
+ environmentId: thread.environmentId,
+ threadId: thread.id,
+ projectId: thread.projectId,
+ turnId,
+ title: thread.title,
+ });
+ }
+
+ return { notifications, observed };
+}
diff --git a/docs/README.md b/docs/README.md
index f1698a66e179..e5e6a5e3070c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -6,6 +6,7 @@
- [Permission modes](./user/permission-modes.md)
- [Keyboard shortcuts](./user/keybindings.md)
- [Organizing threads](./user/thread-sidebar.md)
+- [Completion notifications](./user/notifications.md)
- [Review usage](./user/usage.md)
- [Customize a project icon](./user/project-settings.md)
- [Mobile appearance](./user/mobile-appearance.md)
diff --git a/docs/user/notifications.md b/docs/user/notifications.md
new file mode 100644
index 000000000000..13975d385bbe
--- /dev/null
+++ b/docs/user/notifications.md
@@ -0,0 +1,21 @@
+# Completion notifications
+
+Turn on **Completion notifications** in Settings under General to get a system notification when a
+thread finishes its turn. Enabling it asks your browser for notification permission; if you have
+already blocked notifications for T3 Code, allow them again in your browser's site settings.
+
+Clicking a notification focuses T3 Code and opens the thread that finished.
+Use **Test** to check notification permission and preview the current sound before waiting for a
+turn to finish.
+
+**Notification sound** appears once notifications are on and plays a short chime with each one.
+When sound is on, use **Notification volume** to set the chime level; moving the slider previews the
+new volume.
+
+Only turns that run to completion are announced. A turn you stop yourself, or one that ends in an
+error, is not. A thread that hands work to subagents is announced when the whole run finishes, not
+when the main agent pauses to wait for them.
+
+Notifications are delivered by the app itself, so they arrive while T3 Code is open in a tab or in
+the desktop app, including when it is in the background. A closed tab receives nothing. On iOS and
+Android, the mobile app delivers its own push notifications instead.
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 143087b35430..599a2113173f 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -128,6 +128,25 @@ export type FontFamilyPreference = typeof FontFamilyPreference.Type;
export const DEFAULT_BROWSER_VIEWPORT: PreviewViewportSetting = FILL_PREVIEW_VIEWPORT;
export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true;
+/**
+ * Native OS notification when a thread's turn finishes. Off by default: the
+ * first enable is what asks the browser for notification permission, so it
+ * has to be a deliberate click rather than something a fresh install does on
+ * the user's behalf.
+ */
+export const DEFAULT_THREAD_COMPLETION_NOTIFICATIONS = false;
+export const DEFAULT_THREAD_COMPLETION_NOTIFICATION_SOUND = true;
+export const MIN_NOTIFICATION_VOLUME = 10;
+export const MAX_NOTIFICATION_VOLUME = 100;
+export const NotificationVolume = Schema.Int.check(
+ Schema.isBetween({
+ minimum: MIN_NOTIFICATION_VOLUME,
+ maximum: MAX_NOTIFICATION_VOLUME,
+ }),
+);
+export type NotificationVolume = typeof NotificationVolume.Type;
+export const DEFAULT_NOTIFICATION_VOLUME: NotificationVolume = 70;
+
export const ClientSettingsSchema = Schema.Struct({
browserDefaultViewport: PreviewViewportSetting.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)),
@@ -235,6 +254,15 @@ export const ClientSettingsSchema = Schema.Struct({
sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)),
),
+ threadCompletionNotifications: Schema.Boolean.pipe(
+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_THREAD_COMPLETION_NOTIFICATIONS)),
+ ),
+ threadCompletionNotificationSound: Schema.Boolean.pipe(
+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_THREAD_COMPLETION_NOTIFICATION_SOUND)),
+ ),
+ threadCompletionNotificationVolume: NotificationVolume.pipe(
+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_NOTIFICATION_VOLUME)),
+ ),
timestampFormat: TimestampFormat.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
),
@@ -858,6 +886,9 @@ export const ClientSettingsPatch = Schema.Struct({
sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder),
sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),
+ threadCompletionNotifications: Schema.optionalKey(Schema.Boolean),
+ threadCompletionNotificationSound: Schema.optionalKey(Schema.Boolean),
+ threadCompletionNotificationVolume: Schema.optionalKey(NotificationVolume),
timestampFormat: Schema.optionalKey(TimestampFormat),
wordWrap: Schema.optionalKey(Schema.Boolean),
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2c79aea36a0e..8465aa5d4a76 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5184,10 +5184,12 @@ packages:
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
+ deprecated: this version has critical issues, please update to the latest version
'@xmldom/xmldom@0.9.10':
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
+ deprecated: this version has critical issues, please update to the latest version
'@yuuang/ffi-rs-android-arm64@1.3.2':
resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==}