From 000beddf70451d652cd540665b31d83390f29ee0 Mon Sep 17 00:00:00 2001 From: Yehya Allawand Date: Thu, 12 Mar 2026 15:33:13 +0100 Subject: [PATCH 1/8] 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 5ed218fb254..65ea38a22d7 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 00000000000..9896dfd05b7 --- /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 00000000000..1c26e98eb91 --- /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 3d7a815f099..ef12fa68cfc 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 93e0744421e..19c247f70ab 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/8] 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 2d898b009e3..735900eacbd 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 07fcafc76d7..7904e6f63dd 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 4f22a22f1e3..c5b6c18aeb0 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/8] 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 9d61b0d2e07..a1d57e05cb3 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 9896dfd05b7..052c7b2e4c6 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 1c26e98eb91..baa3036349a 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 710fab72d43..e675445d73b 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 fc6e5acfff1..31b23feb3c8 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/8] 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 31b23feb3c8..51a428ffed6 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, From f0d876751708e1b81371b824c3cc10c9d59563ca Mon Sep 17 00:00:00 2001 From: Yehya Allawand Date: Fri, 20 Mar 2026 08:46:29 +0100 Subject: [PATCH 5/8] fix merge conflict --- apps/web/src/appSettings.ts | 2 +- apps/web/src/routes/_chat.settings.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 552b511aad7..c8b54a39178 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -49,7 +49,7 @@ export const AppSettingsSchema = Schema.Struct({ NotificationLevel.Important, NotificationLevel.Normal, NotificationLevel.Verbose, - ]).pipe(withDefaults(() => NotificationLevel.Normal), + ]).pipe(withDefaults(() => NotificationLevel.Normal)), timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 39fdfc7470a..0fa726d4bc1 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -3,7 +3,12 @@ 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, NotificationLevel, 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"; From 691c77559e4d568e03cf0e7611aeefa3aa80c49a Mon Sep 17 00:00:00 2001 From: Yehya Allawand Date: Thu, 2 Apr 2026 09:02:19 +0200 Subject: [PATCH 6/8] fix formatting --- apps/web/src/components/settings/SettingsPanels.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 78d22f6f8ed..3824f99491b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -960,8 +960,7 @@ export function GeneralSettingsPanel() { size="xs" variant="outline" disabled={ - notificationPermission === "unsupported" || - notificationPermission === "granted" + notificationPermission === "unsupported" || notificationPermission === "granted" } onClick={requestPermission} > From 5b410af78094ec0e0aecc6fba494add60ed9acf0 Mon Sep 17 00:00:00 2001 From: Yehya Allawand Date: Sat, 2 May 2026 13:54:41 +0200 Subject: [PATCH 7/8] Resolving Merge Conflicts --- apps/web/src/hooks/useSettings.ts | 9 +- apps/web/src/lib/nativeNotifications.test.ts | 11 +- apps/web/src/lib/nativeNotifications.ts | 44 +++++--- apps/web/src/routes/__root.tsx | 104 +++++++++---------- packages/contracts/src/settings.ts | 16 +-- 5 files changed, 103 insertions(+), 81 deletions(-) diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 671d61e7737..9f763b3d69d 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -22,6 +22,7 @@ import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS, NotificationLevel, + NotificationLevelSchema, SidebarProjectSortOrder, SidebarThreadSortOrder, TimestampFormat, @@ -68,9 +69,7 @@ function splitPatch(patch: Partial): { * only re-render when the slice they care about changes. */ -export function useSettings( - selector?: (s: UnifiedSettings) => T, -): T { +export function useSettings(selector?: (s: UnifiedSettings) => T): T { const serverSettings = useServerSettings(); const [clientSettings] = useLocalStorage( CLIENT_SETTINGS_STORAGE_KEY, @@ -219,8 +218,8 @@ export function buildLegacyClientSettingsMigrationPatch( patch.timestampFormat = legacySettings.timestampFormat; } - if (Schema.is(NotificationLevel)(legacySettings.notificationLevel)) { - patch.notificationLevel = legacySettings.notificationLevel; + if (Schema.is(NotificationLevelSchema)(legacySettings.notificationLevel)) { + patch.notificationLevel = legacySettings.notificationLevel as NotificationLevel; } return patch; diff --git a/apps/web/src/lib/nativeNotifications.test.ts b/apps/web/src/lib/nativeNotifications.test.ts index 8c65c98a622..390dd526d10 100644 --- a/apps/web/src/lib/nativeNotifications.test.ts +++ b/apps/web/src/lib/nativeNotifications.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OrchestrationThread, OrchestrationThreadActivity } from "@t3tools/contracts"; +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; import { NotificationLevel } from "@t3tools/contracts/settings"; @@ -11,6 +11,7 @@ import { resolveAttentionNotification, resolveTurnCompletionNotification, showNativeNotification, + type NotifiableThread, } from "./nativeNotifications"; type TestWindow = Window & typeof globalThis & { desktopBridge?: unknown; nativeApi?: unknown }; @@ -49,10 +50,10 @@ const SESSION_DEFAULTS = { } as const; function fakeThread( - overrides: Omit, "session"> & { + overrides: Omit, "session"> & { session?: Record | null; }, -): OrchestrationThread { +): NotifiableThread { const { session: sessionOverrides, ...rest } = overrides; return { id: "thread-1", @@ -60,9 +61,9 @@ function fakeThread( activities: [], ...rest, session: sessionOverrides - ? (Object.assign({}, SESSION_DEFAULTS, sessionOverrides) as OrchestrationThread["session"]) + ? (Object.assign({}, SESSION_DEFAULTS, sessionOverrides) as NotifiableThread["session"]) : null, - } as OrchestrationThread; + } as NotifiableThread; } function fakeActivity( diff --git a/apps/web/src/lib/nativeNotifications.ts b/apps/web/src/lib/nativeNotifications.ts index fc65a044c67..55f18c095a7 100644 --- a/apps/web/src/lib/nativeNotifications.ts +++ b/apps/web/src/lib/nativeNotifications.ts @@ -1,8 +1,4 @@ -import { - OrchestrationSessionStatus, - OrchestrationThread, - OrchestrationThreadActivity, -} from "@t3tools/contracts"; +import { OrchestrationSessionStatus, OrchestrationThreadActivity } from "@t3tools/contracts"; import { NotificationLevel } from "@t3tools/contracts/settings"; const IMPORTANT_ACTIVITY_KINDS = new Set(["approval.requested", "user-input.requested"]); @@ -12,6 +8,24 @@ const VERBOSE_ACTIVITY_KINDS = new Set([ "task.progress", ]); +export type NotifiableThread = { + id: string; + title: string; + activities: ReadonlyArray; + session: + | { + status: OrchestrationSessionStatus; + activeTurnId?: string | null | undefined; + lastError?: string | null | undefined; + } + | { + orchestrationStatus: OrchestrationSessionStatus; + activeTurnId?: string | null | undefined; + lastError?: string | null | undefined; + } + | null; +}; + export function isAppBackgrounded(): boolean { if (typeof document === "undefined") return false; if (document.visibilityState !== "visible") return true; @@ -73,7 +87,7 @@ export function showNativeNotification(input: { export function resolveTurnCompletionNotification(input: { shouldNotify: boolean; level: NotificationLevel; - thread: OrchestrationThread; + thread: NotifiableThread; previous: | { status: OrchestrationSessionStatus; @@ -84,6 +98,9 @@ export function resolveTurnCompletionNotification(input: { }): { title: string; body: string; tag: string; turnId: string } | null { const { shouldNotify, level, thread, previous, lastNotifiedTurnId } = input; const session = thread.session; + const sessionStatus = + session && "orchestrationStatus" in session ? session.orchestrationStatus : session?.status; + const activeTurnId = session?.activeTurnId ?? null; if ( !shouldNotify || @@ -91,8 +108,8 @@ export function resolveTurnCompletionNotification(input: { !previous || previous.status !== "running" || !previous.activeTurnId || - session.activeTurnId !== null || - (session.status !== "ready" && session.status !== "error") + activeTurnId !== null || + (sessionStatus !== "ready" && sessionStatus !== "error") ) { return null; } @@ -101,7 +118,7 @@ export function resolveTurnCompletionNotification(input: { return null; } - if (session.status === "ready" && level === NotificationLevel.Important) { + if (sessionStatus === "ready" && level === NotificationLevel.Important) { return null; } @@ -109,17 +126,18 @@ export function resolveTurnCompletionNotification(input: { return null; } - const title = session.status === "error" ? "Task failed" : "Task completed"; - const detail = session.status === "error" && session.lastError ? session.lastError : thread.title; + const title = sessionStatus === "error" ? "Task failed" : "Task completed"; + const lastError = "lastError" in session ? session.lastError : null; + const detail = sessionStatus === "error" && lastError ? lastError : thread.title; const body = detail.length > 180 ? `${detail.slice(0, 177)}...` : detail; - const tag = `t3code:${thread.id}:${previous.activeTurnId}:${session.status}`; + const tag = `t3code:${thread.id}:${previous.activeTurnId}:${sessionStatus}`; return { title, body, tag, turnId: previous.activeTurnId }; } export function resolveAttentionNotification(input: { shouldNotify: boolean; level: NotificationLevel; - thread: OrchestrationThread; + thread: NotifiableThread; lastNotifiedActivityId: string | undefined; }): { title: string; body: string; tag: string; activityId: string } | null { const { shouldNotify, level, thread, lastNotifiedActivityId } = input; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 84ffe980df4..67c27b6b891 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,7 +1,6 @@ import { OrchestrationEvent, ThreadId, - type OrchestrationReadModel, type OrchestrationSessionStatus, type ServerLifecycleWelcomePayload, } from "@t3tools/contracts"; @@ -51,6 +50,7 @@ import { resolveAttentionNotification, resolveTurnCompletionNotification, showNativeNotification, + type NotifiableThread, } from "../lib/nativeNotifications"; export const Route = createRootRouteWithContext<{ @@ -229,66 +229,66 @@ function EventRouter() { pathnameRef.current = pathname; - const maybeNotifyForThreads = useEffectEvent( - (threads: ReadonlyArray) => { - // 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 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), - }); + const maybeNotifyForThreads = useEffectEvent((threads: ReadonlyArray) => { + // 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 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); - } + 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), - }); + 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 (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); - } + if (session) { + // Persist latest session state so we can detect transitions next time. + const status = + "orchestrationStatus" in session ? session.orchestrationStatus : session.status; + lastSessionByThreadRef.current.set(thread.id, { + 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); - } + // 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 handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload) => { migrateLocalSettingsToServer(); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index d7a1db8fc7f..a986967fe4d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -30,6 +30,13 @@ export enum NotificationLevel { Verbose = "verbose", } +export const NotificationLevelSchema = Schema.Literals([ + NotificationLevel.Off, + NotificationLevel.Important, + NotificationLevel.Normal, + NotificationLevel.Verbose, +]); + export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), @@ -41,12 +48,9 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), ), timestampFormat: TimestampFormat.pipe(Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT)), - notificationLevel: Schema.Literals([ - NotificationLevel.Off, - NotificationLevel.Important, - NotificationLevel.Normal, - NotificationLevel.Verbose, - ]).pipe(Schema.withDecodingDefault(() => NotificationLevel.Normal)), + notificationLevel: NotificationLevelSchema.pipe( + Schema.withDecodingDefault(() => NotificationLevel.Normal), + ), }); export type ClientSettings = typeof ClientSettingsSchema.Type; From 2b5b1dbf7ca23f84da7415eee195061385f12211 Mon Sep 17 00:00:00 2001 From: Yehya Allawand Date: Sat, 2 May 2026 14:18:52 +0200 Subject: [PATCH 8/8] Resolving Merge Conflicts --- apps/web/src/components/settings/SettingsPanels.tsx | 9 ++++----- apps/web/src/hooks/useSettings.ts | 2 ++ apps/web/src/routes/__root.tsx | 3 --- packages/contracts/src/settings.ts | 3 +-- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index bf313f72c21..8a98ca9be57 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -121,7 +121,7 @@ const NOTIFICATION_LEVELS = [ description: "Normal plus task activity updates.", }, ] as const; - + const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); function withoutProviderInstanceKey( @@ -522,8 +522,6 @@ export function GeneralSettingsPanel() { const settings = useSettings(); const { updateSettings } = useUpdateSettings(); const { permission: notificationPermission, requestPermission } = useNotification(); - const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); - const [openKeybindingsError, setOpenKeybindingsError] = useState(null); const [openProviderDetails, setOpenProviderDetails] = useState>({ codex: Boolean( settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || @@ -541,6 +539,7 @@ export function GeneralSettingsPanel() { >({ codex: "", claudeAgent: "", + }); const [openingPathByTarget, setOpeningPathByTarget] = useState({ keybindings: false, logsDirectory: false, @@ -1120,7 +1119,7 @@ export function GeneralSettingsPanel() {
- + updateSettings({ autoOpenPlanSidebar: Boolean(checked) }) diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index e2ccb80e630..26fea93e49c 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -325,6 +325,8 @@ export function migrateLocalSettingsToServer(): void { // Remove the legacy key regardless to keep migration one-shot behavior. localStorage.removeItem(OLD_SETTINGS_KEY); } +} + export function __resetClientSettingsPersistenceForTests(): void { clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; clientSettingsHydrated = false; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index bd9369b8ce3..ef9076d5835 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -262,9 +262,6 @@ function EventRouter() { const lastNotifiedTurnByThreadRef = useRef(new Map()); const lastNotifiedActivityByThreadRef = useRef(new Map()); - const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { - if (!payload) return; - const maybeNotifyForThreads = useEffectEvent((threads: ReadonlyArray) => { // Only notify when the app is backgrounded and notifications are enabled. const shouldNotify = isAppBackgrounded() && notificationLevel !== NotificationLevel.Off; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3398d6c1416..a6f543dc663 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -87,9 +87,8 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), - timestampFormat: TimestampFormat.pipe(Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT)), notificationLevel: NotificationLevelSchema.pipe( - Schema.withDecodingDefault(() => NotificationLevel.Normal), + Schema.withDecodingDefault(Effect.succeed(NotificationLevel.Normal)), ), }); export type ClientSettings = typeof ClientSettingsSchema.Type;