From 000beddf70451d652cd540665b31d83390f29ee0 Mon Sep 17 00:00:00 2001 From: Yehya Allawand Date: Thu, 12 Mar 2026 15:33:13 +0100 Subject: [PATCH 1/4] 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 5ed218fb25..65ea38a22d 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 0000000000..9896dfd05b --- /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 0000000000..1c26e98eb9 --- /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 3d7a815f09..ef12fa68cf 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 93e0744421..19c247f70a 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/4] 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 2d898b009e..735900eacb 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 07fcafc76d..7904e6f63d 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 4f22a22f1e..c5b6c18aeb 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)) { From 97ab90638a7e4b0497cfe279916af52577c6e923 Mon Sep 17 00:00:00 2001 From: Yehya Allawand Date: Mon, 16 Mar 2026 17:10:40 +0100 Subject: [PATCH 3/4] Added Granular Notification Levels --- apps/web/src/appSettings.ts | 14 +- apps/web/src/lib/nativeNotifications.test.ts | 286 +++++++++++++++++++ apps/web/src/lib/nativeNotifications.ts | 120 ++++++++ apps/web/src/routes/__root.tsx | 71 +++-- apps/web/src/routes/_chat.settings.tsx | 69 +++-- 5 files changed, 511 insertions(+), 49 deletions(-) diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 9d61b0d2e0..a1d57e05cb 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -14,6 +14,13 @@ const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record codex: new Set(getModelOptions("codex").map((option) => option.slug)), }; +export enum NotificationLevel { + Off = "off", + Important = "important", + Normal = "normal", + Verbose = "verbose", +} + const AppSettingsSchema = Schema.Struct({ codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( Schema.withConstructorDefault(() => Option.some("")), @@ -28,10 +35,15 @@ const AppSettingsSchema = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), - enableNotifications: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe( Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)), ), + notificationLevel: Schema.Literals([ + NotificationLevel.Off, + NotificationLevel.Important, + NotificationLevel.Normal, + NotificationLevel.Verbose, + ]).pipe(Schema.withConstructorDefault(() => Option.some(NotificationLevel.Normal))), 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 index 9896dfd05b..052c7b2e4c 100644 --- a/apps/web/src/lib/nativeNotifications.test.ts +++ b/apps/web/src/lib/nativeNotifications.test.ts @@ -1,9 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OrchestrationThread, OrchestrationThreadActivity } from "@t3tools/contracts"; + +import { NotificationLevel } from "../appSettings"; + import { canShowNativeNotification, getNotificationPermission, requestNotificationPermission, + resolveAttentionNotification, + resolveTurnCompletionNotification, showNativeNotification, } from "./nativeNotifications"; @@ -32,6 +38,48 @@ const createNotificationMock = () => { return { MockNotification, ctorSpy }; }; +const SESSION_DEFAULTS = { + threadId: "thread-1", + providerName: null, + runtimeMode: "full-access", + updatedAt: "2026-01-01T00:00:00Z", + lastError: null, + status: "ready", + activeTurnId: null, +} as const; + +function fakeThread( + overrides: Omit, "session"> & { + session?: Record | null; + }, +): OrchestrationThread { + const { session: sessionOverrides, ...rest } = overrides; + return { + id: "thread-1", + title: "My thread", + activities: [], + ...rest, + session: sessionOverrides + ? (Object.assign({}, SESSION_DEFAULTS, sessionOverrides) as OrchestrationThread["session"]) + : null, + } as OrchestrationThread; +} + +function fakeActivity( + overrides: Partial, +): OrchestrationThreadActivity { + return { + id: "act-1", + tone: "info", + kind: "task.progress", + summary: "Doing work", + payload: null, + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + ...overrides, + } as OrchestrationThreadActivity; +} + beforeEach(() => { vi.resetModules(); const win = getTestWindow(); @@ -130,3 +178,241 @@ describe("nativeNotifications", () => { expect(ctorSpy).toHaveBeenCalledTimes(1); }); }); + +describe("resolveTurnCompletionNotification", () => { + const previous = { status: "running" as const, activeTurnId: "turn-1" }; + + it("returns null when shouldNotify is false", () => { + const thread = fakeThread({ session: { status: "ready", activeTurnId: null } }); + expect( + resolveTurnCompletionNotification({ + shouldNotify: false, + level: NotificationLevel.Normal, + thread, + previous, + lastNotifiedTurnId: undefined, + }), + ).toBeNull(); + }); + + it("returns null when level is off", () => { + const thread = fakeThread({ session: { status: "ready", activeTurnId: null } }); + expect( + resolveTurnCompletionNotification({ + shouldNotify: true, + level: NotificationLevel.Off, + thread, + previous, + lastNotifiedTurnId: undefined, + }), + ).toBeNull(); + }); + + it("returns 'Task completed' for a successful turn at normal level", () => { + const thread = fakeThread({ session: { status: "ready", activeTurnId: null } }); + const result = resolveTurnCompletionNotification({ + shouldNotify: true, + level: NotificationLevel.Normal, + thread, + previous, + lastNotifiedTurnId: undefined, + }); + expect(result).not.toBeNull(); + expect(result!.title).toBe("Task completed"); + expect(result!.turnId).toBe("turn-1"); + }); + + it("returns 'Task failed' for an error turn", () => { + const thread = fakeThread({ + session: { status: "error", activeTurnId: null, lastError: "boom" }, + }); + const result = resolveTurnCompletionNotification({ + shouldNotify: true, + level: NotificationLevel.Normal, + thread, + previous, + lastNotifiedTurnId: undefined, + }); + expect(result).not.toBeNull(); + expect(result!.title).toBe("Task failed"); + expect(result!.body).toBe("boom"); + }); + + it("suppresses successful completion at important level", () => { + const thread = fakeThread({ session: { status: "ready", activeTurnId: null } }); + expect( + resolveTurnCompletionNotification({ + shouldNotify: true, + level: NotificationLevel.Important, + thread, + previous, + lastNotifiedTurnId: undefined, + }), + ).toBeNull(); + }); + + it("still fires for errors at important level", () => { + const thread = fakeThread({ + session: { status: "error", activeTurnId: null, lastError: "oops" }, + }); + const result = resolveTurnCompletionNotification({ + shouldNotify: true, + level: NotificationLevel.Important, + thread, + previous, + lastNotifiedTurnId: undefined, + }); + expect(result).not.toBeNull(); + expect(result!.title).toBe("Task failed"); + }); + + it("skips already-notified turn", () => { + const thread = fakeThread({ session: { status: "ready", activeTurnId: null } }); + expect( + resolveTurnCompletionNotification({ + shouldNotify: true, + level: NotificationLevel.Normal, + thread, + previous, + lastNotifiedTurnId: "turn-1", + }), + ).toBeNull(); + }); + + it("truncates body longer than 180 characters", () => { + const longTitle = "A".repeat(200); + const thread = fakeThread({ + title: longTitle, + session: { status: "ready", activeTurnId: null }, + }); + const result = resolveTurnCompletionNotification({ + shouldNotify: true, + level: NotificationLevel.Normal, + thread, + previous, + lastNotifiedTurnId: undefined, + }); + expect(result).not.toBeNull(); + expect(result!.body.length).toBe(180); + expect(result!.body.endsWith("...")).toBe(true); + }); +}); + +describe("resolveAttentionNotification", () => { + it("returns null when shouldNotify is false", () => { + const thread = fakeThread({ + activities: [fakeActivity({ kind: "approval.requested" })], + }); + expect( + resolveAttentionNotification({ + shouldNotify: false, + level: NotificationLevel.Normal, + thread, + lastNotifiedActivityId: undefined, + }), + ).toBeNull(); + }); + + it("returns null when level is off", () => { + const thread = fakeThread({ + activities: [fakeActivity({ kind: "approval.requested" })], + }); + expect( + resolveAttentionNotification({ + shouldNotify: true, + level: NotificationLevel.Off, + thread, + lastNotifiedActivityId: undefined, + }), + ).toBeNull(); + }); + + it("fires for approval.requested at normal level", () => { + const thread = fakeThread({ + activities: [fakeActivity({ id: "a1" as never, kind: "approval.requested" })], + }); + const result = resolveAttentionNotification({ + shouldNotify: true, + level: NotificationLevel.Normal, + thread, + lastNotifiedActivityId: undefined, + }); + expect(result).not.toBeNull(); + expect(result!.title).toBe("Approval required"); + expect(result!.activityId).toBe("a1"); + }); + + it("fires for user-input.requested at important level", () => { + const thread = fakeThread({ + activities: [fakeActivity({ id: "a2" as never, kind: "user-input.requested" })], + }); + const result = resolveAttentionNotification({ + shouldNotify: true, + level: NotificationLevel.Important, + thread, + lastNotifiedActivityId: undefined, + }); + expect(result).not.toBeNull(); + expect(result!.title).toBe("Input required"); + }); + + it("ignores task.progress at normal level", () => { + const thread = fakeThread({ + activities: [fakeActivity({ kind: "task.progress" })], + }); + expect( + resolveAttentionNotification({ + shouldNotify: true, + level: NotificationLevel.Normal, + thread, + lastNotifiedActivityId: undefined, + }), + ).toBeNull(); + }); + + it("fires for task.progress at verbose level", () => { + const thread = fakeThread({ + activities: [fakeActivity({ id: "a3" as never, kind: "task.progress" })], + }); + const result = resolveAttentionNotification({ + shouldNotify: true, + level: NotificationLevel.Verbose, + thread, + lastNotifiedActivityId: undefined, + }); + expect(result).not.toBeNull(); + expect(result!.title).toBe("Task update"); + }); + + it("skips already-notified activity", () => { + const thread = fakeThread({ + activities: [fakeActivity({ id: "a1" as never, kind: "approval.requested" })], + }); + expect( + resolveAttentionNotification({ + shouldNotify: true, + level: NotificationLevel.Normal, + thread, + lastNotifiedActivityId: "a1", + }), + ).toBeNull(); + }); + + it("picks the latest matching activity", () => { + const thread = fakeThread({ + activities: [ + fakeActivity({ id: "a1" as never, kind: "approval.requested", summary: "First" }), + fakeActivity({ id: "a2" as never, kind: "approval.requested", summary: "Second" }), + ], + }); + const result = resolveAttentionNotification({ + shouldNotify: true, + level: NotificationLevel.Normal, + thread, + lastNotifiedActivityId: undefined, + }); + expect(result).not.toBeNull(); + expect(result!.activityId).toBe("a2"); + expect(result!.body).toBe("Second"); + }); +}); diff --git a/apps/web/src/lib/nativeNotifications.ts b/apps/web/src/lib/nativeNotifications.ts index 1c26e98eb9..baa3036349 100644 --- a/apps/web/src/lib/nativeNotifications.ts +++ b/apps/web/src/lib/nativeNotifications.ts @@ -1,3 +1,17 @@ +import { + OrchestrationSessionStatus, + OrchestrationThread, + OrchestrationThreadActivity, +} from "@t3tools/contracts"; +import { NotificationLevel } from "../appSettings"; + +const IMPORTANT_ACTIVITY_KINDS = new Set(["approval.requested", "user-input.requested"]); +const VERBOSE_ACTIVITY_KINDS = new Set([ + ...IMPORTANT_ACTIVITY_KINDS, + "task.started", + "task.progress", +]); + export function isAppBackgrounded(): boolean { if (typeof document === "undefined") return false; if (document.visibilityState !== "visible") return true; @@ -55,3 +69,109 @@ export function showNativeNotification(input: { return false; } } + +export function resolveTurnCompletionNotification(input: { + shouldNotify: boolean; + level: NotificationLevel; + thread: OrchestrationThread; + previous: + | { + status: OrchestrationSessionStatus; + activeTurnId: string | null; + } + | undefined; + lastNotifiedTurnId: string | undefined; +}): { title: string; body: string; tag: string; turnId: string } | null { + const { shouldNotify, level, thread, previous, lastNotifiedTurnId } = input; + const session = thread.session; + + if ( + !shouldNotify || + !session || + !previous || + previous.status !== "running" || + !previous.activeTurnId || + session.activeTurnId !== null || + (session.status !== "ready" && session.status !== "error") + ) { + return null; + } + + if (level === NotificationLevel.Off) { + return null; + } + + if (session.status === "ready" && level === NotificationLevel.Important) { + return null; + } + + if (lastNotifiedTurnId === previous.activeTurnId) { + return null; + } + + 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}`; + return { title, body, tag, turnId: previous.activeTurnId }; +} + +export function resolveAttentionNotification(input: { + shouldNotify: boolean; + level: NotificationLevel; + thread: OrchestrationThread; + lastNotifiedActivityId: string | undefined; +}): { title: string; body: string; tag: string; activityId: string } | null { + const { shouldNotify, level, thread, lastNotifiedActivityId } = input; + if (!shouldNotify || level === NotificationLevel.Off) { + return null; + } + + const activityKinds = + level === NotificationLevel.Verbose ? VERBOSE_ACTIVITY_KINDS : IMPORTANT_ACTIVITY_KINDS; + const activity = findLatestActivity(thread.activities, activityKinds); + if (!activity) return null; + + const activityId = String(activity.id); + if (lastNotifiedActivityId === activityId) { + return null; + } + + const title = titleForActivity(activity); + const body = activity.summary; + const tag = `t3code:${thread.id}:${activityId}:${activity.kind}`; + return { title, body, tag, activityId }; +} + +function findLatestActivity( + activities: ReadonlyArray, + kinds: ReadonlySet, +): OrchestrationThreadActivity | null { + for (let i = activities.length - 1; i >= 0; i -= 1) { + const activity = activities[i]; + if (!activity) { + continue; + } + if (kinds.has(activity.kind)) { + return activity; + } + } + return null; +} + +function titleForActivity(activity: OrchestrationThreadActivity): string { + switch (activity.kind) { + case "approval.requested": + return "Approval required"; + case "user-input.requested": + return "Input required"; + case "task.started": + return "Task started"; + case "task.progress": + return "Task update"; + case "task.completed": + return "Task completed"; + default: + return "Task update"; + } +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 710fab72d4..e675445d73 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -20,7 +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 { NotificationLevel, useAppSettings } from "../appSettings"; import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; @@ -29,7 +29,12 @@ 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"; +import { + isAppBackgrounded, + resolveAttentionNotification, + resolveTurnCompletionNotification, + showNativeNotification, +} from "../lib/nativeNotifications"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -152,6 +157,7 @@ function EventRouter() { new Map(), ); const lastNotifiedTurnByThreadRef = useRef(new Map()); + const lastNotifiedActivityByThreadRef = useRef(new Map()); pathnameRef.current = pathname; @@ -164,38 +170,42 @@ 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 maybeNotifyForThread = (snapshot: OrchestrationReadModel) => { + const notificationLevel = settings.notificationLevel; + // Only notify when the app is backgrounded and notifications are enabled. + const shouldNotify = isAppBackgrounded() && notificationLevel !== NotificationLevel.Off; 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); - } + const completionNotification = resolveTurnCompletionNotification({ + shouldNotify, + level: notificationLevel, + thread, + previous, + lastNotifiedTurnId: lastNotifiedTurnByThreadRef.current.get(thread.id), + }); + + if (completionNotification) { + const { title, body, tag, turnId } = completionNotification; + if (showNativeNotification({ title, body, tag })) { + lastNotifiedTurnByThreadRef.current.set(thread.id, turnId); + } + } + + const attentionNotification = resolveAttentionNotification({ + shouldNotify, + level: notificationLevel, + thread, + lastNotifiedActivityId: lastNotifiedActivityByThreadRef.current.get(thread.id), + }); + + if (attentionNotification) { + const { title, body, tag, activityId } = attentionNotification; + if (showNativeNotification({ title, body, tag })) { + lastNotifiedActivityByThreadRef.current.set(thread.id, activityId); } } @@ -215,6 +225,7 @@ function EventRouter() { if (!seenThreadIds.has(threadId)) { lastSessionByThreadRef.current.delete(threadId); lastNotifiedTurnByThreadRef.current.delete(threadId); + lastNotifiedActivityByThreadRef.current.delete(threadId); } } }; @@ -223,7 +234,7 @@ function EventRouter() { const snapshot = await api.orchestration.getSnapshot(); if (disposed) return; latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); - maybeNotifyForTurnCompletion(snapshot); + maybeNotifyForThread(snapshot); syncServerReadModel(snapshot); clearPromotedDraftThreads(new Set(snapshot.threads.map((t) => t.id))); const draftThreadIds = Object.keys( @@ -382,7 +393,7 @@ function EventRouter() { queryClient, removeOrphanedTerminalStates, setProjectExpanded, - settings.enableNotifications, + settings.notificationLevel, syncServerReadModel, ]); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index fc6e5acfff..31b23feb3c 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; 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 { MAX_CUSTOM_MODEL_LENGTH, NotificationLevel, useAppSettings } from "../appSettings"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; @@ -24,6 +24,29 @@ import { Switch } from "../components/ui/switch"; import { APP_VERSION } from "../branding"; import { SidebarInset } from "~/components/ui/sidebar"; +const NOTIFICATION_LEVELS = [ + { + value: NotificationLevel.Off, + label: "Off", + description: "Disable all OS notifications.", + }, + { + value: NotificationLevel.Important, + label: "Important", + description: "Approval/input required and failed tasks only.", + }, + { + value: NotificationLevel.Normal, + label: "Normal", + description: "Important plus completed tasks.", + }, + { + value: NotificationLevel.Verbose, + label: "Verbose", + description: "Normal plus task activity updates.", + }, +] as const; + const THEME_OPTIONS = [ { value: "system", @@ -599,22 +622,32 @@ function SettingsRouteView() {

-
-
-

Enable notifications

-

- Show OS notifications for completed or failed tasks. -

-
- - updateSettings({ - enableNotifications: Boolean(checked), - }) - } - aria-label="Enable notifications" - /> +
+ {NOTIFICATION_LEVELS.map((option) => { + const selected = settings.notificationLevel === option.value; + return ( + + ); + })}
@@ -652,7 +685,7 @@ function SettingsRouteView() { size="xs" variant="outline" disabled={ - !settings.enableNotifications || + settings.notificationLevel === NotificationLevel.Off || (!isElectron && notificationPermission !== "granted") } onClick={() => { From bbd70495b1352347ba812cd953c32feaeb5cbf05 Mon Sep 17 00:00:00 2001 From: Yehya Allawand Date: Mon, 16 Mar 2026 17:22:08 +0100 Subject: [PATCH 4/4] format fix --- apps/web/src/routes/_chat.settings.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 31b23feb3c..51a428ffed 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -631,10 +631,11 @@ function SettingsRouteView() { type="button" role="radio" aria-checked={selected} - className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${selected + className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${ + selected ? "border-primary/60 bg-primary/8 text-foreground" : "border-border bg-background text-muted-foreground hover:bg-accent" - }`} + }`} onClick={() => updateSettings({ notificationLevel: option.value,