diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e5018e0bf..596fb8155 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -22,6 +22,7 @@ const AppSettingsSchema = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), + enableNotifications: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), diff --git a/apps/web/src/hooks/useNotification.ts b/apps/web/src/hooks/useNotification.ts new file mode 100644 index 000000000..521856c1a --- /dev/null +++ b/apps/web/src/hooks/useNotification.ts @@ -0,0 +1,30 @@ +import { useCallback, useEffect, useState } from "react"; + +import { + getNotificationPermission, + requestNotificationPermission, +} from "../lib/nativeNotifications"; + +export function useNotification() { + const [permission, setPermission] = useState(getNotificationPermission()); + + const refresh = useCallback(() => { + setPermission(getNotificationPermission()); + }, []); + + const requestPermission = useCallback(async () => { + const next = await requestNotificationPermission(); + setPermission(next); + }, []); + + useEffect(() => { + refresh(); + window.addEventListener("focus", refresh); + + return () => { + window.removeEventListener("focus", refresh); + }; + }, [refresh]); + + return { permission, requestPermission, refresh }; +} diff --git a/apps/web/src/lib/nativeNotifications.test.ts b/apps/web/src/lib/nativeNotifications.test.ts new file mode 100644 index 000000000..9896dfd05 --- /dev/null +++ b/apps/web/src/lib/nativeNotifications.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + canShowNativeNotification, + getNotificationPermission, + requestNotificationPermission, + showNativeNotification, +} from "./nativeNotifications"; + +type TestWindow = Window & typeof globalThis & { desktopBridge?: unknown; nativeApi?: unknown }; + +const getTestWindow = (): TestWindow => { + const testGlobal = globalThis as typeof globalThis & { window?: TestWindow }; + if (!testGlobal.window) { + testGlobal.window = {} as TestWindow; + } + return testGlobal.window; +}; + +const createNotificationMock = () => { + const ctorSpy = vi.fn(); + + class MockNotification { + static permission: NotificationPermission = "default"; + static requestPermission = vi.fn(async () => "default" as NotificationPermission); + + constructor(title: string, options?: NotificationOptions) { + ctorSpy({ title, options }); + } + } + + return { MockNotification, ctorSpy }; +}; + +beforeEach(() => { + vi.resetModules(); + const win = getTestWindow(); + delete win.desktopBridge; + delete win.nativeApi; +}); + +afterEach(() => { + delete (globalThis as { Notification?: unknown }).Notification; +}); + +describe("nativeNotifications", () => { + it("returns unsupported permission when Notification is unavailable", () => { + delete (globalThis as { Notification?: unknown }).Notification; + expect(getNotificationPermission()).toBe("unsupported"); + }); + + it("returns permission when Notification is available", () => { + const { MockNotification } = createNotificationMock(); + MockNotification.permission = "granted"; + (globalThis as { Notification?: unknown }).Notification = MockNotification; + + expect(getNotificationPermission()).toBe("granted"); + }); + + it("requests permission when supported", async () => { + const { MockNotification } = createNotificationMock(); + MockNotification.requestPermission = vi.fn(async () => "granted"); + (globalThis as { Notification?: unknown }).Notification = MockNotification; + + await expect(requestNotificationPermission()).resolves.toBe("granted"); + expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1); + }); + + it("falls back to current permission when request throws", async () => { + const { MockNotification } = createNotificationMock(); + MockNotification.permission = "denied"; + MockNotification.requestPermission = vi.fn(async () => { + throw new Error("no"); + }); + (globalThis as { Notification?: unknown }).Notification = MockNotification; + + await expect(requestNotificationPermission()).resolves.toBe("denied"); + }); + + it("canShowNativeNotification respects permission in web context", () => { + const { MockNotification } = createNotificationMock(); + MockNotification.permission = "denied"; + (globalThis as { Notification?: unknown }).Notification = MockNotification; + + expect(canShowNativeNotification()).toBe(false); + MockNotification.permission = "granted"; + expect(canShowNativeNotification()).toBe(true); + }); + + it("canShowNativeNotification is allowed in desktop context when supported", () => { + const { MockNotification } = createNotificationMock(); + MockNotification.permission = "denied"; + (globalThis as { Notification?: unknown }).Notification = MockNotification; + (getTestWindow() as unknown as Record).desktopBridge = {}; + + expect(canShowNativeNotification()).toBe(true); + }); + + it("showNativeNotification returns false when permission is not granted", () => { + const { MockNotification, ctorSpy } = createNotificationMock(); + MockNotification.permission = "denied"; + (globalThis as { Notification?: unknown }).Notification = MockNotification; + + expect(showNativeNotification({ title: "Test" })).toBe(false); + expect(ctorSpy).not.toHaveBeenCalled(); + }); + + it("showNativeNotification sends a notification when allowed", () => { + const { MockNotification, ctorSpy } = createNotificationMock(); + MockNotification.permission = "granted"; + (globalThis as { Notification?: unknown }).Notification = MockNotification; + + expect( + showNativeNotification({ + title: "Test", + body: "Hello", + tag: "tag-1", + }), + ).toBe(true); + expect(ctorSpy).toHaveBeenCalledTimes(1); + }); + + it("showNativeNotification sends a notification in desktop mode", () => { + const { MockNotification, ctorSpy } = createNotificationMock(); + MockNotification.permission = "denied"; + (globalThis as { Notification?: unknown }).Notification = MockNotification; + (getTestWindow() as unknown as Record).nativeApi = {}; + + expect(showNativeNotification({ title: "Test" })).toBe(true); + expect(ctorSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/lib/nativeNotifications.ts b/apps/web/src/lib/nativeNotifications.ts new file mode 100644 index 000000000..1c26e98eb --- /dev/null +++ b/apps/web/src/lib/nativeNotifications.ts @@ -0,0 +1,57 @@ +export function isAppBackgrounded(): boolean { + if (typeof document === "undefined") return false; + if (document.visibilityState !== "visible") return true; + if (typeof document.hasFocus === "function") { + return !document.hasFocus(); + } + return false; +} + +export function canShowNativeNotification(): boolean { + if (typeof Notification === "undefined") return false; + if ( + typeof window !== "undefined" && + (window.desktopBridge !== undefined || window.nativeApi !== undefined) + ) { + return true; + } + return Notification.permission === "granted"; +} + +export function getNotificationPermission(): NotificationPermission | "unsupported" { + if (typeof Notification === "undefined") return "unsupported"; + return Notification.permission; +} + +export async function requestNotificationPermission(): Promise< + NotificationPermission | "unsupported" +> { + if (typeof Notification === "undefined") return "unsupported"; + try { + return await Notification.requestPermission(); + } catch { + return Notification.permission; + } +} + +export function showNativeNotification(input: { + title: string; + body?: string; + tag?: string; +}): boolean { + if (!canShowNativeNotification()) return false; + try { + const options: NotificationOptions = {}; + if (input.body !== undefined) { + options.body = input.body; + } + if (input.tag !== undefined) { + options.tag = input.tag; + } + const notification = new Notification(input.title, options); + void notification; + return true; + } catch { + return false; + } +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 34f9c4b82..710fab72d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,8 @@ -import { ThreadId } from "@t3tools/contracts"; +import { + ThreadId, + type OrchestrationReadModel, + type OrchestrationSessionStatus, +} from "@t3tools/contracts"; import { Outlet, createRootRouteWithContext, @@ -16,6 +20,7 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; +import { useAppSettings } from "../appSettings"; import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; @@ -24,6 +29,7 @@ import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; +import { isAppBackgrounded, showNativeNotification } from "../lib/nativeNotifications"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -136,11 +142,16 @@ function EventRouter() { const removeOrphanedTerminalStates = useTerminalStateStore( (store) => store.removeOrphanedTerminalStates, ); + const { settings } = useAppSettings(); const queryClient = useQueryClient(); const navigate = useNavigate(); const pathname = useRouterState({ select: (state) => state.location.pathname }); const pathnameRef = useRef(pathname); const handledBootstrapThreadIdRef = useRef(null); + const lastSessionByThreadRef = useRef( + new Map(), + ); + const lastNotifiedTurnByThreadRef = useRef(new Map()); pathnameRef.current = pathname; @@ -153,10 +164,66 @@ function EventRouter() { let pending = false; let needsProviderInvalidation = false; + const maybeNotifyForTurnCompletion = (snapshot: OrchestrationReadModel) => { + // Only notify when the app is backgrounded and the user has enabled notifications. + const shouldNotify = isAppBackgrounded() && settings.enableNotifications; + const seenThreadIds = new Set(); + for (const thread of snapshot.threads) { + seenThreadIds.add(thread.id); + const session = thread.session; + const previous = lastSessionByThreadRef.current.get(thread.id); + + // A completed/failed turn transitions from running with an activeTurnId + // to a session with no active turn and status ready/error. + if ( + shouldNotify && + session && + previous && + previous.status === "running" && + previous.activeTurnId && + session.activeTurnId === null && + (session.status === "ready" || session.status === "error") + ) { + const lastNotifiedTurnId = lastNotifiedTurnByThreadRef.current.get(thread.id); + + if (lastNotifiedTurnId !== previous.activeTurnId) { + const title = session.status === "error" ? "Task failed" : "Task completed"; + const detail = + session.status === "error" && session.lastError ? session.lastError : thread.title; + const body = detail.length > 180 ? `${detail.slice(0, 177)}...` : detail; + const tag = `t3code:${thread.id}:${previous.activeTurnId}:${session.status}`; + + if (showNativeNotification({ title, body, tag })) { + lastNotifiedTurnByThreadRef.current.set(thread.id, previous.activeTurnId); + } + } + } + + if (session) { + // Persist latest session state so we can detect transitions next time. + lastSessionByThreadRef.current.set(thread.id, { + status: session.status, + activeTurnId: session.activeTurnId ?? null, + }); + } else { + lastSessionByThreadRef.current.delete(thread.id); + } + } + + // Drop state for threads that no longer exist in the snapshot. + for (const threadId of lastSessionByThreadRef.current.keys()) { + if (!seenThreadIds.has(threadId)) { + lastSessionByThreadRef.current.delete(threadId); + lastNotifiedTurnByThreadRef.current.delete(threadId); + } + } + }; + const flushSnapshotSync = async (): Promise => { const snapshot = await api.orchestration.getSnapshot(); if (disposed) return; latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); + maybeNotifyForTurnCompletion(snapshot); syncServerReadModel(snapshot); clearPromotedDraftThreads(new Set(snapshot.threads.map((t) => t.id))); const draftThreadIds = Object.keys( @@ -315,6 +382,7 @@ function EventRouter() { queryClient, removeOrphanedTerminalStates, setProjectExpanded, + settings.enableNotifications, syncServerReadModel, ]); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index c0e9a7ef9..7904e6f63 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -7,8 +7,10 @@ import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; +import { useNotification } from "../hooks/useNotification"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { ensureNativeApi } from "../nativeApi"; +import { showNativeNotification } from "../lib/nativeNotifications"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Switch } from "../components/ui/switch"; @@ -93,6 +95,7 @@ function SettingsRouteView() { const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); + const { permission: notificationPermission, requestPermission } = useNotification(); const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; @@ -486,6 +489,95 @@ function SettingsRouteView() { ) : null} +
+
+

Notifications

+

+ Allow T3 Code to show OS notifications when a task completes. +

+
+ +
+
+

Enable notifications

+

+ Show OS notifications for completed or failed tasks. +

+
+ + updateSettings({ + enableNotifications: Boolean(checked), + }) + } + aria-label="Enable notifications" + /> +
+ +
+
+

Permission status

+

+ {isElectron + ? "Desktop app permissions are managed by your OS." + : notificationPermission === "unsupported" + ? "Notifications are not supported by this browser." + : notificationPermission === "granted" + ? "Allowed" + : notificationPermission === "denied" + ? "Blocked" + : "Not yet requested"} +

+
+ {isElectron ? null : ( + + )} +
+ +
+ +
+ + {!isElectron && notificationPermission === "denied" ? ( +

+ Enable notifications in your browser site settings to allow OS alerts. +

+ ) : null} + {isElectron || notificationPermission === "granted" ? ( +

+ If notifications still do not appear, check OS notification settings for your + browser or desktop app. +

+ ) : null} +
+

Keybindings