diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8fc36d4a32..82e6566839 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -9,8 +9,9 @@ import { type ProviderInstanceId, type ScopedThreadRef, } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS, NotificationLevel } from "@t3tools/contracts/settings"; +import { normalizeModelSlug } from "@t3tools/shared/model"; import { scopeThreadRef } from "@t3tools/client-runtime"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; @@ -26,6 +27,7 @@ import { TraitsPicker } from "../chat/TraitsPicker"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; import { isElectron } from "../../env"; import { useTheme } from "../../hooks/useTheme"; +import { useNotification } from "../../hooks/useNotification"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { @@ -48,6 +50,8 @@ import { useStore, } from "../../store"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; +import { cn } from "../../lib/utils"; +import { showNativeNotification } from "../../lib/nativeNotifications"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { DraftInput } from "../ui/draft-input"; @@ -95,6 +99,29 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +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 DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); function withoutProviderInstanceKey( @@ -451,6 +478,25 @@ export function GeneralSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = useSettings(); const { updateSettings } = useUpdateSettings(); + const { permission: notificationPermission, requestPermission } = useNotification(); + const [openProviderDetails, setOpenProviderDetails] = useState>({ + codex: Boolean( + settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || + settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath || + settings.providers.codex.customModels.length > 0, + ), + claudeAgent: Boolean( + settings.providers.claudeAgent.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || + settings.providers.claudeAgent.customModels.length > 0, + ), + }); + const [customModelInputByProvider, setCustomModelInputByProvider] = useState< + Record + >({ + codex: "", + claudeAgent: "", + }); const [openingPathByTarget, setOpeningPathByTarget] = useState({ keybindings: false, logsDirectory: false, @@ -946,6 +992,117 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + notificationLevel: DEFAULT_UNIFIED_SETTINGS.notificationLevel, + }) + } + /> + ) : null + } + control={ + + } + status={ + NOTIFICATION_LEVELS.find((option) => option.value === settings.notificationLevel) + ?.description ?? null + } + > +
+
+
+

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. +

+ ) : null} +
+
+
+ } /> - { + 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/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 37a6872bd9..26fea93e49 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -16,6 +16,11 @@ import { type ClientSettings, DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS, + NotificationLevel, + NotificationLevelSchema, + SidebarProjectSortOrder, + SidebarThreadSortOrder, + TimestampFormat, UnifiedSettings, } from "@t3tools/contracts/settings"; import { ensureLocalApi } from "~/localApi"; @@ -188,6 +193,140 @@ export function useUpdateSettings() { }; } +// ── One-time migration from localStorage ───────────────────────────── + +export function buildLegacyServerSettingsMigrationPatch(legacySettings: Record) { + const patch: DeepMutable = {}; + + if (Predicate.isBoolean(legacySettings.enableAssistantStreaming)) { + patch.enableAssistantStreaming = legacySettings.enableAssistantStreaming; + } + + if (Schema.is(ThreadEnvMode)(legacySettings.defaultThreadEnvMode)) { + patch.defaultThreadEnvMode = legacySettings.defaultThreadEnvMode; + } + + if (Schema.is(ModelSelection)(legacySettings.textGenerationModelSelection)) { + patch.textGenerationModelSelection = legacySettings.textGenerationModelSelection; + } + + if (typeof legacySettings.codexBinaryPath === "string") { + patch.providers ??= {}; + patch.providers.codex ??= {}; + patch.providers.codex.binaryPath = legacySettings.codexBinaryPath; + } + + if (typeof legacySettings.codexHomePath === "string") { + patch.providers ??= {}; + patch.providers.codex ??= {}; + patch.providers.codex.homePath = legacySettings.codexHomePath; + } + + if (Array.isArray(legacySettings.customCodexModels)) { + patch.providers ??= {}; + patch.providers.codex ??= {}; + patch.providers.codex.customModels = normalizeCustomModelSlugs( + legacySettings.customCodexModels, + new Set(), + "codex", + ); + } + + if (Predicate.isString(legacySettings.claudeBinaryPath)) { + patch.providers ??= {}; + patch.providers.claudeAgent ??= {}; + patch.providers.claudeAgent.binaryPath = legacySettings.claudeBinaryPath; + } + + if (Array.isArray(legacySettings.customClaudeModels)) { + patch.providers ??= {}; + patch.providers.claudeAgent ??= {}; + patch.providers.claudeAgent.customModels = normalizeCustomModelSlugs( + legacySettings.customClaudeModels, + new Set(), + "claudeAgent", + ); + } + + return patch; +} + +export function buildLegacyClientSettingsMigrationPatch( + legacySettings: Record, +): Partial> { + const patch: Partial> = {}; + + if (Predicate.isBoolean(legacySettings.confirmThreadArchive)) { + patch.confirmThreadArchive = legacySettings.confirmThreadArchive; + } + + if (Predicate.isBoolean(legacySettings.confirmThreadDelete)) { + patch.confirmThreadDelete = legacySettings.confirmThreadDelete; + } + + if (Predicate.isBoolean(legacySettings.diffWordWrap)) { + patch.diffWordWrap = legacySettings.diffWordWrap; + } + + if (Schema.is(SidebarProjectSortOrder)(legacySettings.sidebarProjectSortOrder)) { + patch.sidebarProjectSortOrder = legacySettings.sidebarProjectSortOrder; + } + + if (Schema.is(SidebarThreadSortOrder)(legacySettings.sidebarThreadSortOrder)) { + patch.sidebarThreadSortOrder = legacySettings.sidebarThreadSortOrder; + } + + if (Schema.is(TimestampFormat)(legacySettings.timestampFormat)) { + patch.timestampFormat = legacySettings.timestampFormat; + } + + if (Schema.is(NotificationLevelSchema)(legacySettings.notificationLevel)) { + patch.notificationLevel = legacySettings.notificationLevel as NotificationLevel; + } + + return patch; +} + +/** + * Call once on app startup. + * If the legacy localStorage key exists, migrate its values to the new server + * and client storage formats, then remove the legacy key so this only runs once. + */ +export function migrateLocalSettingsToServer(): void { + if (typeof window === "undefined") return; + + const raw = localStorage.getItem(OLD_SETTINGS_KEY); + if (!raw) return; + + try { + const old = JSON.parse(raw); + if (!Predicate.isObject(old)) return; + + // Migrate server-relevant keys via RPC + const serverPatch = buildLegacyServerSettingsMigrationPatch(old); + if (Object.keys(serverPatch).length > 0) { + const api = ensureNativeApi(); + void api.server.updateSettings(serverPatch); + } + + // Migrate client-only keys to the new localStorage key + const clientPatch = buildLegacyClientSettingsMigrationPatch(old); + if (Object.keys(clientPatch).length > 0) { + const existing = localStorage.getItem(CLIENT_SETTINGS_STORAGE_KEY); + const current = existing ? (JSON.parse(existing) as Record) : {}; + localStorage.setItem( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify({ ...current, ...clientPatch }), + ); + } + } catch (error) { + console.error("[MIGRATION] Error migrating local settings:", error); + } finally { + // 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/lib/nativeNotifications.test.ts b/apps/web/src/lib/nativeNotifications.test.ts new file mode 100644 index 0000000000..390dd526d1 --- /dev/null +++ b/apps/web/src/lib/nativeNotifications.test.ts @@ -0,0 +1,419 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; + +import { NotificationLevel } from "@t3tools/contracts/settings"; + +import { + canShowNativeNotification, + getNotificationPermission, + requestNotificationPermission, + resolveAttentionNotification, + resolveTurnCompletionNotification, + showNativeNotification, + type NotifiableThread, +} 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; + }, +): NotifiableThread { + const { session: sessionOverrides, ...rest } = overrides; + return { + id: "thread-1", + title: "My thread", + activities: [], + ...rest, + session: sessionOverrides + ? (Object.assign({}, SESSION_DEFAULTS, sessionOverrides) as NotifiableThread["session"]) + : null, + } as NotifiableThread; +} + +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..55f18c095a --- /dev/null +++ b/apps/web/src/lib/nativeNotifications.ts @@ -0,0 +1,195 @@ +import { OrchestrationSessionStatus, OrchestrationThreadActivity } from "@t3tools/contracts"; +import { NotificationLevel } from "@t3tools/contracts/settings"; + +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 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; + 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: NotifiableThread; + 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; + const sessionStatus = + session && "orchestrationStatus" in session ? session.orchestrationStatus : session?.status; + const activeTurnId = session?.activeTurnId ?? null; + + if ( + !shouldNotify || + !session || + !previous || + previous.status !== "running" || + !previous.activeTurnId || + activeTurnId !== null || + (sessionStatus !== "ready" && sessionStatus !== "error") + ) { + return null; + } + + if (level === NotificationLevel.Off) { + return null; + } + + if (sessionStatus === "ready" && level === NotificationLevel.Important) { + return null; + } + + if (lastNotifiedTurnId === previous.activeTurnId) { + return null; + } + + 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}:${sessionStatus}`; + return { title, body, tag, turnId: previous.activeTurnId }; +} + +export function resolveAttentionNotification(input: { + shouldNotify: boolean; + level: NotificationLevel; + thread: NotifiableThread; + 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 58617589df..1f70919038 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,9 @@ -import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; +import { + OrchestrationEvent, + ThreadId, + type OrchestrationSessionStatus, + type ServerLifecycleWelcomePayload, +} from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; import { Outlet, @@ -27,8 +32,10 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { readLocalApi } from "../localApi"; +import { readNativeApi } from "../nativeApi"; +import { NotificationLevel } from "@t3tools/contracts/settings"; import { useSettings } from "../hooks/useSettings"; +import { readLocalApi } from "../localApi"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -43,6 +50,21 @@ import { } from "../rpc/serverState"; import { useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; +import { useTerminalStateStore } from "../terminalStateStore"; +import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; +import { migrateLocalSettingsToServer } from "../hooks/useSettings"; +import { providerQueryKeys } from "../lib/providerReactQuery"; +import { projectQueryKeys } from "../lib/projectReactQuery"; +import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; +import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; +import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; +import { + isAppBackgrounded, + resolveAttentionNotification, + resolveTurnCompletionNotification, + showNativeNotification, + type NotifiableThread, +} from "../lib/nativeNotifications"; import { syncBrowserChromeTheme } from "../hooks/useTheme"; import { ensureEnvironmentConnectionBootstrapped, @@ -276,6 +298,17 @@ function EnvironmentConnectionManagerBootstrap() { } function EventRouter() { + const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); + const syncServerReadModel = useStore((store) => store.syncServerReadModel); + const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); + const syncProjects = useUiStateStore((store) => store.syncProjects); + const syncThreads = useUiStateStore((store) => store.syncThreads); + const clearThreadUi = useUiStateStore((store) => store.clearThreadUi); + const removeTerminalState = useTerminalStateStore((store) => store.removeTerminalState); + const removeOrphanedTerminalStates = useTerminalStateStore( + (store) => store.removeOrphanedTerminalStates, + ); + const notificationLevel = useSettings((state) => state.notificationLevel); const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); @@ -288,10 +321,75 @@ function EventRouter() { const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); const disposedRef = useRef(false); const serverConfig = useServerConfig(); + const lastSessionByThreadRef = useRef( + new Map(), + ); + const lastNotifiedTurnByThreadRef = useRef(new Map()); + const lastNotifiedActivityByThreadRef = useRef(new Map()); + + 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); + } + } + + 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. + 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); + } + } - const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { - if (!payload) return; + // 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(); updatePrimaryEnvironmentDescriptor(payload.environment); setActiveEnvironmentId(payload.environment.environmentId); void (async () => { @@ -299,7 +397,6 @@ function EventRouter() { if (disposedRef.current) { return; } - if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; } @@ -400,6 +497,163 @@ function EventRouter() { ); useEffect(() => { + const api = readNativeApi(); + if (!api) return; + let disposed = false; + disposedRef.current = false; + const recovery = createOrchestrationRecoveryCoordinator(); + let needsProviderInvalidation = false; + const pendingDomainEvents: OrchestrationEvent[] = []; + let flushPendingDomainEventsScheduled = false; + + const reconcileSnapshotDerivedState = () => { + const threads = useStore.getState().threads; + const projects = useStore.getState().projects; + syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); + syncThreads( + threads.map((thread) => ({ + id: thread.id, + seedVisitedAt: thread.updatedAt ?? thread.createdAt, + })), + ); + clearPromotedDraftThreads(threads.map((thread) => thread.id)); + const draftThreadIds = Object.keys( + useComposerDraftStore.getState().draftThreadsByThreadId, + ) as ThreadId[]; + const activeThreadIds = collectActiveTerminalThreadIds({ + snapshotThreads: threads.map((thread) => ({ id: thread.id, deletedAt: null })), + draftThreadIds, + }); + removeOrphanedTerminalStates(activeThreadIds); + }; + + const queryInvalidationThrottler = new Throttler( + () => { + if (!needsProviderInvalidation) { + return; + } + needsProviderInvalidation = false; + void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); + // Invalidate workspace entry queries so the @-mention file picker + // reflects files created, deleted, or restored during this turn. + void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all }); + }, + { + wait: 100, + leading: false, + trailing: true, + }, + ); + + const applyEventBatch = (events: ReadonlyArray) => { + const nextEvents = recovery.markEventBatchApplied(events); + if (nextEvents.length === 0) { + return; + } + + const batchEffects = deriveOrchestrationBatchEffects(nextEvents); + const uiEvents = coalesceOrchestrationUiEvents(nextEvents); + const needsProjectUiSync = nextEvents.some( + (event) => + event.type === "project.created" || + event.type === "project.meta-updated" || + event.type === "project.deleted", + ); + + if (batchEffects.needsProviderInvalidation) { + needsProviderInvalidation = true; + void queryInvalidationThrottler.maybeExecute(); + } + + applyOrchestrationEvents(uiEvents); + if (needsProjectUiSync) { + const projects = useStore.getState().projects; + syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); + } + const needsThreadUiSync = nextEvents.some( + (event) => event.type === "thread.created" || event.type === "thread.deleted", + ); + if (needsThreadUiSync) { + const threads = useStore.getState().threads; + syncThreads( + threads.map((thread) => ({ + id: thread.id, + seedVisitedAt: thread.updatedAt ?? thread.createdAt, + })), + ); + } + const draftStore = useComposerDraftStore.getState(); + for (const threadId of batchEffects.clearPromotedDraftThreadIds) { + clearPromotedDraftThread(threadId); + } + for (const threadId of batchEffects.clearDeletedThreadIds) { + draftStore.clearDraftThread(threadId); + clearThreadUi(threadId); + } + for (const threadId of batchEffects.removeTerminalStateThreadIds) { + removeTerminalState(threadId); + } + maybeNotifyForThreads(useStore.getState().threads); + }; + const flushPendingDomainEvents = () => { + flushPendingDomainEventsScheduled = false; + if (disposed || pendingDomainEvents.length === 0) { + return; + } + + const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); + applyEventBatch(events); + }; + const schedulePendingDomainEventFlush = () => { + if (flushPendingDomainEventsScheduled) { + return; + } + + flushPendingDomainEventsScheduled = true; + queueMicrotask(flushPendingDomainEvents); + }; + + const recoverFromSequenceGap = async (): Promise => { + if (!recovery.beginReplayRecovery("sequence-gap")) { + return; + } + + try { + const events = await api.orchestration.replayEvents(recovery.getState().latestSequence); + if (!disposed) { + applyEventBatch(events); + } + } catch { + recovery.failReplayRecovery(); + void fallbackToSnapshotRecovery(); + return; + } + + if (!disposed && recovery.completeReplayRecovery()) { + void recoverFromSequenceGap(); + } + }; + + const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise => { + if (!recovery.beginSnapshotRecovery(reason)) { + return; + } + + try { + const snapshot = await api.orchestration.getSnapshot(); + if (!disposed) { + syncServerReadModel(snapshot); + reconcileSnapshotDerivedState(); + maybeNotifyForThreads(snapshot.threads); + if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { + void recoverFromSequenceGap(); + } + } + } catch { + // Keep prior state and wait for welcome or a later replay attempt. + recovery.failSnapshotRecovery(); + } + }; if (!serverConfig) { return; } diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 90b9099d17..e4b4e6acea 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -20,6 +20,19 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export enum NotificationLevel { + Off = "off", + Important = "important", + Normal = "normal", + Verbose = "verbose", +} + +export const NotificationLevelSchema = Schema.Literals([ + NotificationLevel.Off, + NotificationLevel.Important, + NotificationLevel.Normal, + NotificationLevel.Verbose, +]); export const SidebarProjectGroupingMode = Schema.Literals([ "repository", "repository_path", @@ -75,6 +88,9 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), + notificationLevel: NotificationLevelSchema.pipe( + Schema.withDecodingDefault(Effect.succeed(NotificationLevel.Normal)), + ), }); export type ClientSettings = typeof ClientSettingsSchema.Type;