diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index d060c2ef06..c0a37150a1 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("")), @@ -31,6 +38,12 @@ const AppSettingsSchema = Schema.Struct({ 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/hooks/useNotification.ts b/apps/web/src/hooks/useNotification.ts new file mode 100644 index 0000000000..521856c1a8 --- /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 0000000000..052c7b2e4c --- /dev/null +++ b/apps/web/src/lib/nativeNotifications.test.ts @@ -0,0 +1,418 @@ +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"; + +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 }; +}; + +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(); + 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); + }); +}); + +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 new file mode 100644 index 0000000000..baa3036349 --- /dev/null +++ b/apps/web/src/lib/nativeNotifications.ts @@ -0,0 +1,177 @@ +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; + 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; + } +} + +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 34f9c4b82f..e675445d73 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 { NotificationLevel, useAppSettings } from "../appSettings"; import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; @@ -24,6 +29,12 @@ import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; +import { + isAppBackgrounded, + resolveAttentionNotification, + resolveTurnCompletionNotification, + showNativeNotification, +} from "../lib/nativeNotifications"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -136,11 +147,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 handledBootstrapThreadIdRef = useRef(null); + const lastSessionByThreadRef = useRef( + new Map(), + ); + const lastNotifiedTurnByThreadRef = useRef(new Map()); + const lastNotifiedActivityByThreadRef = useRef(new Map()); pathnameRef.current = pathname; @@ -153,10 +170,71 @@ function EventRouter() { let pending = false; let needsProviderInvalidation = false; + 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); + + 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); + } + } + + 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); + lastNotifiedActivityByThreadRef.current.delete(threadId); + } + } + }; + const flushSnapshotSync = async (): Promise => { const snapshot = await api.orchestration.getSnapshot(); if (disposed) return; latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); + maybeNotifyForThread(snapshot); syncServerReadModel(snapshot); clearPromotedDraftThreads(new Set(snapshot.threads.map((t) => t.id))); const draftThreadIds = Object.keys( @@ -315,6 +393,7 @@ function EventRouter() { queryClient, removeOrphanedTerminalStates, setProjectExpanded, + settings.notificationLevel, syncServerReadModel, ]); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index e79592c99b..92d260ce3c 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -3,12 +3,14 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -import { getAppModelOptions, MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; +import { getAppModelOptions, MAX_CUSTOM_MODEL_LENGTH, NotificationLevel, 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 { @@ -22,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", @@ -106,6 +131,7 @@ function SettingsRouteView() { const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); + const { permission: notificationPermission, requestPermission } = useNotification(); const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; @@ -658,6 +684,106 @@ function SettingsRouteView() { ) : null} +
+
+

Notifications

+

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

+
+ +
+ {NOTIFICATION_LEVELS.map((option) => { + const selected = settings.notificationLevel === option.value; + return ( + + ); + })} +
+ +
+
+

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