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 ( + + + {volume}% + + { + 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==}