From 000beddf70451d652cd540665b31d83390f29ee0 Mon Sep 17 00:00:00 2001 From: Yehya Allawand Date: Thu, 12 Mar 2026 15:33:13 +0100 Subject: [PATCH 1/2] Add OS Notifications for completed/failed tasks --- apps/web/src/appSettings.ts | 1 + apps/web/src/lib/nativeNotifications.test.ts | 132 +++++++++++++++++++ apps/web/src/lib/nativeNotifications.ts | 57 ++++++++ apps/web/src/routes/__root.tsx | 70 +++++++++- apps/web/src/routes/_chat.settings.tsx | 111 +++++++++++++++- 5 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/nativeNotifications.test.ts create mode 100644 apps/web/src/lib/nativeNotifications.ts diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 5ed218fb2..65ea38a22 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -21,6 +21,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/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 3d7a815f0..ef12fa68c 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 { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; +import { useAppSettings } from "../appSettings"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { preferredTerminalEditor } from "../terminal-links"; @@ -23,6 +28,7 @@ import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; +import { isAppBackgrounded, showNativeNotification } from "../lib/nativeNotifications"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -135,12 +141,17 @@ 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 lastConfigIssuesSignatureRef = useRef(null); 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); const draftThreadIds = Object.keys( useComposerDraftStore.getState().draftThreadsByThreadId, @@ -307,6 +374,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 93e074442..19c247f70 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; @@ -10,6 +10,11 @@ import { useTheme } from "../hooks/useTheme"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { ensureNativeApi } from "../nativeApi"; import { preferredTerminalEditor } from "../terminal-links"; +import { + getNotificationPermission, + requestNotificationPermission, + showNativeNotification, +} from "../lib/nativeNotifications"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Switch } from "../components/ui/switch"; @@ -94,11 +99,23 @@ function SettingsRouteView() { const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); + const [notificationPermission, setNotificationPermission] = useState(getNotificationPermission()); const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; + useEffect(() => { + const refreshPermission = () => { + setNotificationPermission(getNotificationPermission()); + }; + refreshPermission(); + window.addEventListener("focus", refreshPermission); + return () => { + window.removeEventListener("focus", refreshPermission); + }; + }, []); + const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; setOpenKeybindingsError(null); @@ -480,6 +497,98 @@ 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

From e969db53e46d1e01016ec8094c7020d4ffd3eb35 Mon Sep 17 00:00:00 2001 From: Yehya Allawand Date: Thu, 12 Mar 2026 21:11:25 +0100 Subject: [PATCH 2/2] Moved Notification Logic to named hook --- apps/web/src/components/PlanSidebar.tsx | 17 +++++++------- apps/web/src/hooks/useNotification.ts | 30 +++++++++++++++++++++++++ apps/web/src/routes/_chat.settings.tsx | 28 +++++------------------ apps/web/src/wsTransport.ts | 2 +- 4 files changed, 45 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/hooks/useNotification.ts diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 2d898b009..735900eac 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -86,6 +86,15 @@ const PlanSidebar = memo(function PlanSidebar({ }, 2000); }, [planMarkdown]); + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (copiedTimerRef.current != null) { + clearTimeout(copiedTimerRef.current); + } + }; + }, []); + const handleDownload = useCallback(() => { if (!planMarkdown) return; const filename = buildProposedPlanMarkdownFilename(planMarkdown); @@ -128,14 +137,6 @@ const PlanSidebar = memo(function PlanSidebar({ {/* Header */}
- // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (copiedTimerRef.current \!= null) { - clearTimeout(copiedTimerRef.current); - } - }; - }, []); { + 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/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 07fcafc76..7904e6f63 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,20 +1,16 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; 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 { preferredTerminalEditor } from "../terminal-links"; -import { - getNotificationPermission, - requestNotificationPermission, - showNativeNotification, -} from "../lib/nativeNotifications"; +import { showNativeNotification } from "../lib/nativeNotifications"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Switch } from "../components/ui/switch"; @@ -99,24 +95,13 @@ function SettingsRouteView() { const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); - const [notificationPermission, setNotificationPermission] = useState(getNotificationPermission()); + const { permission: notificationPermission, requestPermission } = useNotification(); const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; - useEffect(() => { - const refreshPermission = () => { - setNotificationPermission(getNotificationPermission()); - }; - refreshPermission(); - window.addEventListener("focus", refreshPermission); - return () => { - window.removeEventListener("focus", refreshPermission); - }; - }, []); - const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; setOpenKeybindingsError(null); @@ -553,10 +538,7 @@ function SettingsRouteView() { notificationPermission === "unsupported" || notificationPermission === "granted" } - onClick={async () => { - const nextPermission = await requestNotificationPermission(); - setNotificationPermission(nextPermission); - }} + onClick={requestPermission} > Request permission diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 4f22a22f1..c5b6c18ae 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -190,7 +190,7 @@ export class WsTransport { // Log WebSocket errors for debugging (close event will follow) console.warn("WebSocket connection error", { type: event.type, url: this.url }); }); - + } private handleMessage(raw: unknown) { const result = decodeWsResponse(raw); if (Result.isFailure(result)) {