diff --git a/README.md b/README.md index c2349e72860a..aed2f8cff998 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Install and first run](./docs/user/install.md) - [Permission modes](./docs/user/permission-modes.md) - [Keyboard shortcuts](./docs/user/keybindings.md) +- [Desktop notifications](./docs/user/desktop-notifications.md) - [Customize a project icon](./docs/user/project-settings.md) - [Remote access from a phone or another machine](./docs/user/remote-access.md) - [Keeping app and server in sync](./docs/user/updating.md) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 503a586d9c5b..f1c1891151e8 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -2,6 +2,12 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; +import { + dismissAllDesktopNotifications, + dismissDesktopNotification, + showDesktopNotification, + showDesktopNotificationTest, +} from "./methods/notifications.ts"; import { clearConnectionCatalog, getConnectionCatalog, @@ -56,6 +62,10 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); + yield* ipc.handle(showDesktopNotification); + yield* ipc.handle(dismissDesktopNotification); + yield* ipc.handle(dismissAllDesktopNotifications); + yield* ipc.handle(showDesktopNotificationTest); yield* ipc.handle(getConnectionCatalog); yield* ipc.handle(setConnectionCatalog); yield* ipc.handle(clearConnectionCatalog); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 4d8e783d1221..b2f80e672407 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -19,6 +19,11 @@ export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; +export const DESKTOP_NOTIFICATION_SHOW_CHANNEL = "desktop:notification-show"; +export const DESKTOP_NOTIFICATION_DISMISS_CHANNEL = "desktop:notification-dismiss"; +export const DESKTOP_NOTIFICATION_DISMISS_ALL_CHANNEL = "desktop:notification-dismiss-all"; +export const DESKTOP_NOTIFICATION_SHOW_TEST_CHANNEL = "desktop:notification-show-test"; +export const DESKTOP_NOTIFICATION_ACTIVATED_CHANNEL = "desktop:notification-activated"; export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog"; diff --git a/apps/desktop/src/ipc/methods/notifications.ts b/apps/desktop/src/ipc/methods/notifications.ts new file mode 100644 index 000000000000..5a9f92cfd137 --- /dev/null +++ b/apps/desktop/src/ipc/methods/notifications.ts @@ -0,0 +1,51 @@ +import { + DesktopNotificationShowInputSchema, + DesktopNotificationShowResultSchema, + DesktopNotificationTargetSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopNotifications from "../../notifications/DesktopNotifications.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const showDesktopNotification = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DESKTOP_NOTIFICATION_SHOW_CHANNEL, + payload: DesktopNotificationShowInputSchema, + result: DesktopNotificationShowResultSchema, + handler: Effect.fn("desktop.ipc.notifications.show")(function* (input) { + const notifications = yield* DesktopNotifications.DesktopNotifications; + return yield* notifications.show(input); + }), +}); + +export const dismissDesktopNotification = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DESKTOP_NOTIFICATION_DISMISS_CHANNEL, + payload: DesktopNotificationTargetSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.notifications.dismiss")(function* (target) { + const notifications = yield* DesktopNotifications.DesktopNotifications; + yield* notifications.dismiss(target); + }), +}); + +export const dismissAllDesktopNotifications = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DESKTOP_NOTIFICATION_DISMISS_ALL_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.notifications.dismissAll")(function* () { + const notifications = yield* DesktopNotifications.DesktopNotifications; + yield* notifications.dismissAll; + }), +}); + +export const showDesktopNotificationTest = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DESKTOP_NOTIFICATION_SHOW_TEST_CHANNEL, + payload: Schema.Struct({ silent: Schema.Boolean }), + result: DesktopNotificationShowResultSchema, + handler: Effect.fn("desktop.ipc.notifications.showTest")(function* (input) { + const notifications = yield* DesktopNotifications.DesktopNotifications; + return yield* notifications.showTest(input); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0616184ec74d..36cb75c3130c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -57,6 +57,7 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; +import * as DesktopNotifications from "./notifications/DesktopNotifications.ts"; import * as BrowserSession from "./preview/BrowserSession.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; @@ -156,6 +157,10 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( Layer.provideMerge(desktopPreviewLayer), ); +const desktopNotificationLayer = DesktopNotifications.layer.pipe( + Layer.provideMerge(desktopWindowLayer), +); + // Pool layer instantiates the backend factory once for the Windows // primary instance and exposes it via pool.primary. Consumers go through // the pool now; the legacy DesktopBackendManager service is gone. The @@ -187,6 +192,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopShellEnvironment.layer, desktopSshLayer, ).pipe( + Layer.provideMerge(desktopNotificationLayer), Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopWslBackendLayer), Layer.provideMerge(desktopLocalEnvironmentAuthLayer), diff --git a/apps/desktop/src/notifications/DesktopNotifications.test.ts b/apps/desktop/src/notifications/DesktopNotifications.test.ts new file mode 100644 index 000000000000..2d78ce59c9d7 --- /dev/null +++ b/apps/desktop/src/notifications/DesktopNotifications.test.ts @@ -0,0 +1,171 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import type * as Electron from "electron"; + +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopNotifications from "./DesktopNotifications.ts"; + +class FakeNativeNotification implements DesktopNotifications.NativeNotification { + readonly listeners = new Map<"click" | "close", () => void>(); + shown = false; + closed = false; + + show(): void { + this.shown = true; + } + + close(): void { + this.closed = true; + this.listeners.get("close")?.(); + } + + once(event: "click" | "close", listener: () => void): void { + this.listeners.set(event, listener); + } + + on(_event: "failed", _listener: (event: unknown, error: string) => void): void {} +} + +function makeWindowLayer(onReveal: Effect.Effect) { + const sent: Array<{ readonly channel: string; readonly payload: unknown }> = []; + const window = { + isDestroyed: () => false, + webContents: { + isLoadingMainFrame: () => false, + once: () => undefined, + send: (channel: string, payload: unknown) => sent.push({ channel, payload }), + }, + } as unknown as Electron.BrowserWindow; + + return { + sent, + layer: Layer.succeed( + DesktopWindow.DesktopWindow, + DesktopWindow.DesktopWindow.of({ + createMain: Effect.succeed(window), + ensureMain: Effect.succeed(window), + revealOrCreateMain: onReveal.pipe(Effect.as(window)), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, + dispatchMenuAction: () => Effect.void, + zoomMain: () => Effect.void, + syncAppearance: Effect.void, + }), + ), + }; +} + +const input = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), + event: "approval" as const, + projectTitle: "t3code", + threadTitle: "Fix failing CI", + showContext: true, + silent: false, +}; + +describe("DesktopNotifications", () => { + it.effect("passes shared copy to the native adapter and replaces a thread notification", () => + Effect.gen(function* () { + const created: Array<{ + readonly options: DesktopNotifications.NativeNotificationOptions; + readonly notification: FakeNativeNotification; + }> = []; + const platform: DesktopNotifications.DesktopNotificationPlatformService["Service"] = { + isSupported: () => true, + isAppFocused: () => false, + create: (options) => { + const notification = new FakeNativeNotification(); + created.push({ options, notification }); + return notification; + }, + }; + const window = makeWindowLayer(Effect.void); + + yield* Effect.gen(function* () { + const notifications = yield* DesktopNotifications.DesktopNotifications; + expect(yield* notifications.show(input)).toBe("shown"); + expect(yield* notifications.show({ ...input, event: "failure" })).toBe("shown"); + }).pipe( + Effect.provide(DesktopNotifications.layerTest(platform).pipe(Layer.provide(window.layer))), + Effect.scoped, + ); + + expect(created[0]?.options).toEqual({ + title: "Approval needed", + body: "Fix failing CI · t3code", + silent: false, + timeoutType: "default", + }); + expect(created[0]?.notification.closed).toBe(true); + expect(created[1]?.notification.shown).toBe(true); + }), + ); + + it.effect("suppresses agent notifications while the app is focused but still allows tests", () => + Effect.gen(function* () { + const created: FakeNativeNotification[] = []; + const platform: DesktopNotifications.DesktopNotificationPlatformService["Service"] = { + isSupported: () => true, + isAppFocused: () => true, + create: () => { + const notification = new FakeNativeNotification(); + created.push(notification); + return notification; + }, + }; + const window = makeWindowLayer(Effect.void); + + yield* Effect.gen(function* () { + const notifications = yield* DesktopNotifications.DesktopNotifications; + expect(yield* notifications.show(input)).toBe("suppressed"); + expect(created).toHaveLength(0); + expect(yield* notifications.showTest({ silent: true })).toBe("shown"); + expect(created).toHaveLength(1); + }).pipe( + Effect.provide(DesktopNotifications.layerTest(platform).pipe(Layer.provide(window.layer))), + Effect.scoped, + ); + }), + ); + + it.effect("reveals the app and forwards the target when a notification is clicked", () => + Effect.gen(function* () { + const revealed = yield* Deferred.make(); + let notification: FakeNativeNotification | null = null; + const platform: DesktopNotifications.DesktopNotificationPlatformService["Service"] = { + isSupported: () => true, + isAppFocused: () => false, + create: () => { + notification = new FakeNativeNotification(); + return notification; + }, + }; + const window = makeWindowLayer(Deferred.succeed(revealed, undefined)); + + yield* Effect.gen(function* () { + const notifications = yield* DesktopNotifications.DesktopNotifications; + yield* notifications.show(input); + notification?.listeners.get("click")?.(); + yield* Deferred.await(revealed); + }).pipe( + Effect.provide(DesktopNotifications.layerTest(platform).pipe(Layer.provide(window.layer))), + Effect.scoped, + ); + + expect(window.sent[0]?.payload).toEqual({ + environmentId: input.environmentId, + threadId: input.threadId, + }); + }), + ); +}); diff --git a/apps/desktop/src/notifications/DesktopNotifications.ts b/apps/desktop/src/notifications/DesktopNotifications.ts new file mode 100644 index 000000000000..545f2add7daa --- /dev/null +++ b/apps/desktop/src/notifications/DesktopNotifications.ts @@ -0,0 +1,219 @@ +import type { + DesktopNotificationShowInput, + DesktopNotificationShowResult, + DesktopNotificationTarget, +} from "@t3tools/contracts"; +import { + formatAgentNotificationContent, + formatAgentNotificationTestContent, + type AgentNotificationContent, +} from "@t3tools/shared/agentAwareness"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as Electron from "electron"; + +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import { DESKTOP_NOTIFICATION_ACTIVATED_CHANNEL } from "../ipc/channels.ts"; + +class DesktopNotificationShowError extends Schema.TaggedErrorClass()( + "DesktopNotificationShowError", + { + notificationKey: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not show native desktop notification ${this.notificationKey}.`; + } +} + +export interface NativeNotification { + readonly show: () => void; + readonly close: () => void; + readonly once: (event: "click" | "close", listener: () => void) => unknown; + readonly on: (event: "failed", listener: (event: unknown, error: string) => void) => unknown; +} + +export interface NativeNotificationOptions { + readonly title: string; + readonly body: string; + readonly silent: boolean; + readonly timeoutType: "default"; +} + +export class DesktopNotifications extends Context.Service< + DesktopNotifications, + { + readonly show: ( + input: DesktopNotificationShowInput, + ) => Effect.Effect; + readonly dismiss: (target: DesktopNotificationTarget) => Effect.Effect; + readonly dismissAll: Effect.Effect; + readonly showTest: (input: { + readonly silent: boolean; + }) => Effect.Effect; + } +>()("@t3tools/desktop/notifications/DesktopNotifications") {} + +export class DesktopNotificationPlatformService extends Context.Service< + DesktopNotificationPlatformService, + { + readonly isSupported: () => boolean; + readonly isAppFocused: () => boolean; + readonly create: (options: NativeNotificationOptions) => NativeNotification; + } +>()("@t3tools/desktop/notifications/DesktopNotifications/DesktopNotificationPlatformService") {} + +export function notificationTargetKey(target: DesktopNotificationTarget): string { + return JSON.stringify([target.environmentId, target.threadId]); +} + +const TEST_NOTIFICATION_KEY = "desktop-notification-test"; + +export const make = Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const platform = yield* DesktopNotificationPlatformService; + const notifications = new Map(); + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + + const closeNotification = (key: string): void => { + const existing = notifications.get(key); + notifications.delete(key); + existing?.close(); + }; + + const closeAllNotifications = (): void => { + for (const notification of notifications.values()) { + notification.close(); + } + notifications.clear(); + }; + + const reveal = (target: DesktopNotificationTarget | null) => + desktopWindow.revealOrCreateMain.pipe( + Effect.tap((window) => + target === null + ? Effect.void + : Effect.sync(() => { + const send = () => { + if (!window.isDestroyed()) { + window.webContents.send(DESKTOP_NOTIFICATION_ACTIVATED_CHANNEL, target); + } + }; + if (window.webContents.isLoadingMainFrame()) { + window.webContents.once("did-finish-load", send); + } else { + send(); + } + }), + ), + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("Could not reveal a desktop notification target.", cause), + ), + ); + + const showContent = (input: { + readonly key: string; + readonly content: AgentNotificationContent; + readonly silent: boolean; + readonly target: DesktopNotificationTarget | null; + }): Effect.Effect => + Effect.gen(function* () { + const supported = yield* Effect.try({ + try: platform.isSupported, + catch: () => false, + }).pipe(Effect.orElseSucceed(() => false)); + if (supported !== true) { + return "unsupported" as const; + } + + return yield* Effect.try({ + try: () => { + closeNotification(input.key); + const notification = platform.create({ + title: input.content.title, + body: input.content.body, + silent: input.silent, + timeoutType: "default", + }); + notifications.set(input.key, notification); + const clearIfCurrent = () => { + if (notifications.get(input.key) === notification) { + notifications.delete(input.key); + } + }; + notification.once("close", clearIfCurrent); + notification.once("click", () => { + clearIfCurrent(); + runFork(reveal(input.target)); + }); + notification.on("failed", (_event, error) => { + clearIfCurrent(); + runFork(Effect.logWarning("Native desktop notification failed.", { error })); + }); + notification.show(); + return "shown" as const; + }, + catch: (cause) => new DesktopNotificationShowError({ notificationKey: input.key, cause }), + }).pipe( + Effect.tapError((error) => Effect.logWarning(error.message, error.cause)), + Effect.orElseSucceed(() => "failed" as const), + ); + }).pipe(Effect.withSpan("desktop.notifications.show")); + + yield* Effect.addFinalizer(() => Effect.sync(closeAllNotifications)); + + return DesktopNotifications.of({ + show: Effect.fn("desktop.notifications.show")(function* (input) { + const appFocused = yield* Effect.try({ + try: platform.isAppFocused, + catch: () => false, + }).pipe(Effect.orElseSucceed(() => false)); + if (appFocused) { + return "suppressed" as const; + } + return yield* showContent({ + key: notificationTargetKey(input), + content: formatAgentNotificationContent(input), + silent: input.silent, + target: { + environmentId: input.environmentId, + threadId: input.threadId, + }, + }); + }), + dismiss: (target) => Effect.sync(() => closeNotification(notificationTargetKey(target))), + dismissAll: Effect.sync(closeAllNotifications), + showTest: (input) => + showContent({ + key: TEST_NOTIFICATION_KEY, + content: formatAgentNotificationTestContent(), + silent: input.silent, + target: null, + }), + }); +}); + +const platformLayer = Layer.succeed( + DesktopNotificationPlatformService, + DesktopNotificationPlatformService.of({ + isSupported: () => Electron.Notification.isSupported(), + isAppFocused: () => Electron.BrowserWindow.getFocusedWindow() !== null, + create: (options) => + new Electron.Notification( + options as Electron.NotificationConstructorOptions, + ) as unknown as NativeNotification, + }), +); + +export const layer = Layer.effect(DesktopNotifications, make).pipe(Layer.provide(platformLayer)); + +export const layerTest = (platform: DesktopNotificationPlatformService["Service"]) => + Layer.effect(DesktopNotifications, make).pipe( + Layer.provide(Layer.succeed(DesktopNotificationPlatformService, platform)), + ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7e8859359b37..f305e7a4c419 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,6 @@ import type { DesktopBridge, + DesktopNotificationTarget, DesktopPreviewPointerEvent, DesktopPreviewRecordingFrame, DesktopPreviewTabState, @@ -11,6 +12,32 @@ import * as IpcChannels from "./ipc/channels.ts"; exposeClerkBridge({ passkeys: true }); +const desktopNotificationActivationListeners = new Set< + (target: DesktopNotificationTarget) => void +>(); +let pendingDesktopNotificationActivation: DesktopNotificationTarget | null = null; + +ipcRenderer.on(IpcChannels.DESKTOP_NOTIFICATION_ACTIVATED_CHANNEL, (_event, target: unknown) => { + if ( + typeof target !== "object" || + target === null || + !("environmentId" in target) || + typeof target.environmentId !== "string" || + !("threadId" in target) || + typeof target.threadId !== "string" + ) { + return; + } + const activation = target as DesktopNotificationTarget; + if (desktopNotificationActivationListeners.size === 0) { + pendingDesktopNotificationActivation = activation; + return; + } + for (const listener of desktopNotificationActivationListeners) { + listener(activation); + } +}); + function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && @@ -147,6 +174,25 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, + notifications: { + show: (input) => ipcRenderer.invoke(IpcChannels.DESKTOP_NOTIFICATION_SHOW_CHANNEL, input), + dismiss: (target) => + ipcRenderer.invoke(IpcChannels.DESKTOP_NOTIFICATION_DISMISS_CHANNEL, target), + dismissAll: () => ipcRenderer.invoke(IpcChannels.DESKTOP_NOTIFICATION_DISMISS_ALL_CHANNEL), + showTest: (input) => + ipcRenderer.invoke(IpcChannels.DESKTOP_NOTIFICATION_SHOW_TEST_CHANNEL, input), + onActivated: (listener) => { + desktopNotificationActivationListeners.add(listener); + const pendingActivation = pendingDesktopNotificationActivation; + pendingDesktopNotificationActivation = null; + if (pendingActivation !== null) { + listener(pendingActivation); + } + return () => { + desktopNotificationActivationListeners.delete(listener); + }; + }, + }, preview: { createTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { tabId }), closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }), diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 861f72178a68..812372dbeb82 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -17,6 +17,12 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, + desktopNotifications: { + enabled: false, + soundEnabled: true, + showContext: true, + events: { approval: true, input: true, completion: true, failure: true }, + }, environmentIdentificationMode: "artwork", favorites: [], fontFamilyCode: "", diff --git a/apps/web/src/components/desktop/DesktopNotificationCoordinator.tsx b/apps/web/src/components/desktop/DesktopNotificationCoordinator.tsx new file mode 100644 index 000000000000..d3df80b2f19d --- /dev/null +++ b/apps/web/src/components/desktop/DesktopNotificationCoordinator.tsx @@ -0,0 +1,141 @@ +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { projectThreadAwareness, type AgentAwarenessState } from "@t3tools/shared/agentAwareness"; + +import { + desktopNotificationEventEnabled, + reconcileAgentNotificationStates, + shouldSuppressDesktopNotification, +} from "../../desktopNotifications.logic.ts"; +import { useClientSettings, useClientSettingsHydrated } from "../../hooks/useSettings.ts"; +import { isElectron } from "../../env.ts"; +import { + setActiveEnvironmentId, + useAllEnvironmentShellsBootstrapped, + useAuthoritativeShellEnvironmentIds, + useProjects, + useThreadShells, +} from "../../state/entities.ts"; + +export function DesktopNotificationCoordinator() { + const bridge = isElectron ? window.desktopBridge?.notifications : undefined; + const settings = useClientSettings((current) => current.desktopNotifications); + const settingsHydrated = useClientSettingsHydrated(); + const shellsBootstrapped = useAllEnvironmentShellsBootstrapped(); + const authoritativeEnvironmentIds = useAuthoritativeShellEnvironmentIds(); + const projects = useProjects(); + const threads = useThreadShells(); + const navigate = useNavigate(); + const previousStatesRef = useRef | null>(null); + const previousAuthoritativeEnvironmentIdsRef = useRef>(new Set()); + const notificationOperationsRef = useRef(Promise.resolve()); + + const enqueueNotificationOperations = useCallback((operation: () => Promise) => { + notificationOperationsRef.current = notificationOperationsRef.current + .then(operation) + .catch(() => undefined); + }, []); + + const observed = useMemo(() => { + const projectsByKey = new Map( + projects.map((project) => [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + project, + ]), + ); + + return threads.flatMap((thread) => { + const project = projectsByKey.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + if (!project) { + return []; + } + const target = scopeThreadRef(thread.environmentId, thread.id); + return [ + { + key: scopedThreadKey(target), + target, + state: projectThreadAwareness({ + environmentId: thread.environmentId, + project, + thread, + }), + }, + ]; + }); + }, [projects, threads]); + + useEffect(() => { + if (!bridge) { + return; + } + return bridge.onActivated((target) => { + setActiveEnvironmentId(target.environmentId); + void navigate({ + to: "/$environmentId/$threadId", + params: target, + }); + }); + }, [bridge, navigate]); + + useEffect(() => { + if (!bridge || !settingsHydrated || settings.enabled) { + return; + } + enqueueNotificationOperations(() => bridge.dismissAll()); + }, [bridge, enqueueNotificationOperations, settings.enabled, settingsHydrated]); + + useEffect(() => { + if (!bridge || !settingsHydrated || !shellsBootstrapped) { + return; + } + + const reconciliation = reconcileAgentNotificationStates(previousStatesRef.current, observed, { + previouslyAuthoritativeEnvironmentIds: previousAuthoritativeEnvironmentIdsRef.current, + authoritativeEnvironmentIds, + }); + previousStatesRef.current = reconciliation.next; + previousAuthoritativeEnvironmentIdsRef.current = authoritativeEnvironmentIds; + + for (const transition of reconciliation.transitions) { + enqueueNotificationOperations(async () => { + if (transition.type === "dismiss") { + await bridge.dismiss(transition.target); + return; + } + if (!desktopNotificationEventEnabled(settings, transition.event)) { + return; + } + if (shouldSuppressDesktopNotification(document.hasFocus())) { + return; + } + await bridge.show({ + environmentId: transition.state.environmentId, + threadId: transition.state.threadId, + event: transition.event, + projectTitle: transition.state.projectTitle, + threadTitle: transition.state.threadTitle, + showContext: settings.showContext, + silent: !settings.soundEnabled, + }); + }); + } + }, [ + bridge, + authoritativeEnvironmentIds, + enqueueNotificationOperations, + observed, + settings, + settingsHydrated, + shellsBootstrapped, + ]); + + return null; +} diff --git a/apps/web/src/components/settings/DesktopNotificationsSettings.tsx b/apps/web/src/components/settings/DesktopNotificationsSettings.tsx new file mode 100644 index 000000000000..f45eb30736a7 --- /dev/null +++ b/apps/web/src/components/settings/DesktopNotificationsSettings.tsx @@ -0,0 +1,197 @@ +import type { DesktopNotificationEvent, DesktopNotificationSettings } from "@t3tools/contracts"; +import { + CircleCheckBigIcon, + CircleXIcon, + MessageCircleQuestionIcon, + ShieldAlertIcon, + TagIcon, + Volume2Icon, + type LucideIcon, +} from "lucide-react"; + +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings.ts"; +import { cn } from "../../lib/utils.ts"; +import { Button } from "../ui/button.tsx"; +import { Switch } from "../ui/switch.tsx"; +import { toastManager } from "../ui/toast.tsx"; +import { SettingsRow, SettingsSection } from "./settingsLayout.tsx"; +import { searchableSetting } from "./settingsSearch.ts"; + +const EVENT_OPTIONS: ReadonlyArray<{ + readonly event: DesktopNotificationEvent; + readonly title: string; + readonly description: string; + readonly icon: LucideIcon; +}> = [ + { + event: "approval", + title: "Approval needed", + description: "An agent is blocked until you approve an action.", + icon: ShieldAlertIcon, + }, + { + event: "input", + title: "Waiting for input", + description: "An agent asks a question or needs more direction.", + icon: MessageCircleQuestionIcon, + }, + { + event: "completion", + title: "Agent finished", + description: "A turn completes while you are working elsewhere.", + icon: CircleCheckBigIcon, + }, + { + event: "failure", + title: "Agent failed", + description: "A provider or agent turn ends with an error.", + icon: CircleXIcon, + }, +]; + +function OptionCard({ + title, + description, + icon: Icon, + selected, + onToggle, +}: { + readonly title: string; + readonly description: string; + readonly icon: LucideIcon; + readonly selected: boolean; + readonly onToggle: (selected: boolean) => void; +}) { + return ( + + ); +} + +export function DesktopNotificationsSettings() { + const settings = useClientSettings((current) => current.desktopNotifications); + const updateClientSettings = useUpdateClientSettings(); + const update = (patch: Partial) => { + updateClientSettings({ desktopNotifications: { ...settings, ...patch } }); + }; + const sendTest = async () => { + const notifications = window.desktopBridge?.notifications; + if (!notifications) { + toastManager.add({ + type: "warning", + title: "Desktop app required", + description: "Native notification tests are available in the desktop app.", + }); + return; + } + const result = await notifications + .showTest({ silent: !settings.soundEnabled }) + .catch(() => "failed" as const); + if (result === "shown") { + toastManager.add({ + type: "success", + title: "Test sent", + description: "Check your system notification center.", + }); + return; + } + toastManager.add({ + type: "warning", + title: "Notification unavailable", + description: + result === "unsupported" + ? "Native notifications are not supported in this desktop session." + : "The operating system could not display the notification.", + }); + }; + + return ( + void sendTest()}> + Send test + + } + > + update({ enabled: Boolean(checked) })} + aria-label="Desktop notifications" + /> + } + /> +
+
+
+
+ {EVENT_OPTIONS.map((option) => ( + + update({ events: { ...settings.events, [option.event]: selected } }) + } + /> + ))} +
+
+ update({ soundEnabled: selected })} + /> + update({ showContext: selected })} + /> +
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c987ef64299d..a93e5c865bcd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -141,6 +141,7 @@ import { } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; +import { DesktopNotificationsSettings } from "./DesktopNotificationsSettings"; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", @@ -445,6 +446,21 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings); + const isDesktopNotificationsDirty = + settings.desktopNotifications.enabled !== + DEFAULT_UNIFIED_SETTINGS.desktopNotifications.enabled || + settings.desktopNotifications.soundEnabled !== + DEFAULT_UNIFIED_SETTINGS.desktopNotifications.soundEnabled || + settings.desktopNotifications.showContext !== + DEFAULT_UNIFIED_SETTINGS.desktopNotifications.showContext || + settings.desktopNotifications.events.approval !== + DEFAULT_UNIFIED_SETTINGS.desktopNotifications.events.approval || + settings.desktopNotifications.events.input !== + DEFAULT_UNIFIED_SETTINGS.desktopNotifications.events.input || + settings.desktopNotifications.events.completion !== + DEFAULT_UNIFIED_SETTINGS.desktopNotifications.events.completion || + settings.desktopNotifications.events.failure !== + DEFAULT_UNIFIED_SETTINGS.desktopNotifications.events.failure; const changedSettingLabels = useMemo( () => [ @@ -492,6 +508,7 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks ? ["Provider update checks"] : []), + ...(isDesktopNotificationsDirty ? ["Desktop notifications"] : []), ...(isBackgroundActivityDirty ? ["Background activity"] : []), ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] @@ -514,6 +531,7 @@ export function useSettingsRestore(onRestored?: () => void) { [ isTextGenerationModelDirty, isBackgroundActivityDirty, + isDesktopNotificationsDirty, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -614,6 +632,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, + desktopNotifications: DEFAULT_UNIFIED_SETTINGS.desktopNotifications, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, @@ -2207,6 +2226,8 @@ export function GeneralSettingsPanel() { /> + {isElectron ? : null} + {isElectron || HOSTED_APP_CHANNEL ? ( diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 0f40e3889162..261c3aa3a2f5 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -36,6 +36,7 @@ import { } from "../ui/sidebar"; import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; import { scrollToSettingsTarget } from "./settingsLayout"; +import { isElectron } from "../../env"; import { searchSettings, SETTINGS_SECTION_LABELS, @@ -79,7 +80,10 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { const searchInputRef = useRef(null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); - const results = useMemo(() => searchSettings(query), [query]); + const results = useMemo( + () => searchSettings(query).filter((item) => !item.desktopOnly || isElectron), + [query], + ); const isSearching = query.trim().length > 0; const hasResults = results.length > 0; diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index f2cd5ec34195..4ed050e03610 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -13,6 +13,7 @@ export interface SettingsSearchItem { readonly title: string; readonly to: SettingsPath; readonly targetId?: string; + readonly desktopOnly?: boolean; } /** @@ -120,6 +121,12 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Provider update checks", to: "/settings/general", }, + { + id: "desktop-notifications", + title: "Desktop notifications", + to: "/settings/general", + desktopOnly: true, + }, { id: "new-threads", title: "New threads", diff --git a/apps/web/src/desktopNotifications.logic.test.ts b/apps/web/src/desktopNotifications.logic.test.ts new file mode 100644 index 000000000000..c550b7f7e809 --- /dev/null +++ b/apps/web/src/desktopNotifications.logic.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { AgentAwarenessPhase, AgentAwarenessState } from "@t3tools/shared/agentAwareness"; + +import { + reconcileAgentNotificationStates, + shouldSuppressDesktopNotification, +} from "./desktopNotifications.logic.ts"; + +function state(phase: AgentAwarenessPhase): AgentAwarenessState { + return { + environmentId: "env-1" as EnvironmentId, + threadId: "thread-1" as ThreadId, + projectTitle: "t3code", + threadTitle: "Fix failing CI", + phase, + headline: "Test", + modelTitle: "gpt-5.4", + updatedAt: "2026-08-09T10:00:00.000Z", + deepLink: "/threads/env-1/thread-1", + }; +} + +const target = { + environmentId: "env-1" as EnvironmentId, + threadId: "thread-1" as ThreadId, +}; + +describe("reconcileAgentNotificationStates", () => { + it("baselines existing work without replaying a notification", () => { + const result = reconcileAgentNotificationStates(null, [ + { key: "env-1:thread-1", target, state: state("completed") }, + ]); + + expect(result.transitions).toEqual([]); + expect(result.next.get("env-1:thread-1")?.phase).toBe("completed"); + }); + + it("emits only configured notification-worthy phase edges", () => { + const result = reconcileAgentNotificationStates( + new Map([["env-1:thread-1", state("running")]]), + [{ key: "env-1:thread-1", target, state: state("waiting_for_approval") }], + ); + + expect(result.transitions).toEqual([ + { type: "show", event: "approval", state: state("waiting_for_approval") }, + ]); + }); + + it("notifies when a new thread is first observed in an attention phase", () => { + const authoritative = new Set([target.environmentId]); + const result = reconcileAgentNotificationStates( + new Map(), + [{ key: "env-1:thread-1", target, state: state("waiting_for_approval") }], + { + previouslyAuthoritativeEnvironmentIds: authoritative, + authoritativeEnvironmentIds: authoritative, + }, + ); + + expect(result.transitions).toEqual([ + { type: "show", event: "approval", state: state("waiting_for_approval") }, + ]); + }); + + it("dismisses an attention notification when the agent resumes", () => { + const result = reconcileAgentNotificationStates( + new Map([["env-1:thread-1", state("waiting_for_input")]]), + [{ key: "env-1:thread-1", target, state: state("running") }], + ); + + expect(result.transitions).toEqual([{ type: "dismiss", target }]); + }); + + it("keeps missing threads in memory so reconnects do not replay old work", () => { + const previous = new Map([["env-1:thread-1", state("completed")]]); + const disconnected = reconcileAgentNotificationStates(previous, [], { + previouslyAuthoritativeEnvironmentIds: new Set([target.environmentId]), + authoritativeEnvironmentIds: new Set(), + }); + const reconnected = reconcileAgentNotificationStates( + disconnected.next, + [{ key: "env-1:thread-1", target, state: state("completed") }], + { + previouslyAuthoritativeEnvironmentIds: new Set(), + authoritativeEnvironmentIds: new Set([target.environmentId]), + }, + ); + + expect(reconnected.transitions).toEqual([]); + }); + + it("baselines historical threads when an environment first reconnects", () => { + const result = reconcileAgentNotificationStates( + new Map(), + [{ key: "env-1:thread-1", target, state: state("failed") }], + { + previouslyAuthoritativeEnvironmentIds: new Set(), + authoritativeEnvironmentIds: new Set([target.environmentId]), + }, + ); + + expect(result.transitions).toEqual([]); + }); + + it("dismisses a removed thread only while its environment is authoritative", () => { + const previous = new Map([["env-1:thread-1", state("waiting_for_input")]]); + const result = reconcileAgentNotificationStates(previous, [], { + previouslyAuthoritativeEnvironmentIds: new Set([target.environmentId]), + authoritativeEnvironmentIds: new Set([target.environmentId]), + }); + + expect(result.transitions).toEqual([{ type: "dismiss", target }]); + expect(result.next.has("env-1:thread-1")).toBe(false); + }); +}); + +describe("shouldSuppressDesktopNotification", () => { + it("suppresses notifications whenever the desktop window is focused", () => { + expect(shouldSuppressDesktopNotification(true)).toBe(true); + expect(shouldSuppressDesktopNotification(false)).toBe(false); + }); +}); diff --git a/apps/web/src/desktopNotifications.logic.ts b/apps/web/src/desktopNotifications.logic.ts new file mode 100644 index 000000000000..e0bdd775c0da --- /dev/null +++ b/apps/web/src/desktopNotifications.logic.ts @@ -0,0 +1,107 @@ +import type { + DesktopNotificationEvent, + DesktopNotificationSettings, + DesktopNotificationTarget, +} from "@t3tools/contracts"; +import { + notificationEventForAwarenessTransition, + type AgentAwarenessState, +} from "@t3tools/shared/agentAwareness"; + +export interface ObservedAgentAwareness { + readonly key: string; + readonly target: DesktopNotificationTarget; + readonly state: AgentAwarenessState | null; +} + +export interface AgentNotificationReconciliationContext { + readonly previouslyAuthoritativeEnvironmentIds: ReadonlySet; + readonly authoritativeEnvironmentIds: ReadonlySet; +} + +export type AgentNotificationTransition = + | { + readonly type: "dismiss"; + readonly target: DesktopNotificationTarget; + } + | { + readonly type: "show"; + readonly event: DesktopNotificationEvent; + readonly state: AgentAwarenessState; + }; + +export function reconcileAgentNotificationStates( + previous: ReadonlyMap | null, + observed: ReadonlyArray, + context?: AgentNotificationReconciliationContext, +): { + readonly next: ReadonlyMap; + readonly transitions: ReadonlyArray; +} { + const next = new Map(previous ?? []); + const transitions: AgentNotificationTransition[] = []; + const observedKeys = new Set(); + + for (const entry of observed) { + observedKeys.add(entry.key); + const hadPrevious = previous?.has(entry.key) === true; + const priorState = hadPrevious ? (previous?.get(entry.key) ?? null) : null; + next.set(entry.key, entry.state); + + // The first complete shell snapshot is a baseline, never a backlog to replay. The same rule + // applies when one environment first becomes authoritative after being disconnected. + const environmentWasAuthoritative = + context?.previouslyAuthoritativeEnvironmentIds.has(entry.target.environmentId) ?? true; + if (previous === null || (!hadPrevious && !environmentWasAuthoritative)) { + continue; + } + + if ( + priorState !== null && + priorState.phase !== entry.state?.phase && + notificationEventForAwarenessTransition(null, priorState) !== null + ) { + transitions.push({ type: "dismiss", target: entry.target }); + } + + const event = notificationEventForAwarenessTransition(priorState, entry.state); + if (event !== null && entry.state !== null) { + transitions.push({ type: "show", event, state: entry.state }); + } + } + + if (previous !== null && context !== undefined) { + for (const [key, priorState] of previous) { + if ( + observedKeys.has(key) || + priorState === null || + !context.authoritativeEnvironmentIds.has(priorState.environmentId) + ) { + continue; + } + if (notificationEventForAwarenessTransition(null, priorState) !== null) { + transitions.push({ + type: "dismiss", + target: { + environmentId: priorState.environmentId, + threadId: priorState.threadId, + }, + }); + } + next.delete(key); + } + } + + return { next, transitions }; +} + +export function desktopNotificationEventEnabled( + settings: DesktopNotificationSettings, + event: DesktopNotificationEvent, +): boolean { + return settings.enabled && settings.events[event]; +} + +export function shouldSuppressDesktopNotification(windowFocused: boolean): boolean { + return windowFocused; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index cff930539044..7c7b55da416d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,6 +17,7 @@ import { CommandPalette } from "../components/CommandPalette"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; +import { DesktopNotificationCoordinator } from "../components/desktop/DesktopNotificationCoordinator"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; @@ -135,6 +136,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 7bca31182379..c010eac938eb 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -23,7 +23,10 @@ import { useMemo } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom } from "./server"; -import { allEnvironmentShellsBootstrappedAtom } from "./shell"; +import { + allEnvironmentShellsBootstrappedAtom, + authoritativeShellEnvironmentIdsAtom, +} from "./shell"; import { environmentThreadDetails, environmentThreadShells } from "./threads"; const EMPTY_PROJECT_REFS: ReadonlyArray = Object.freeze([]); @@ -124,6 +127,10 @@ export function useAllEnvironmentShellsBootstrapped(): boolean { return useAtomValue(allEnvironmentShellsBootstrappedAtom); } +export function useAuthoritativeShellEnvironmentIds(): ReadonlySet { + return useAtomValue(authoritativeShellEnvironmentIdsAtom); +} + export function useThreadShellsForProjectRefs( refs: ReadonlyArray, ): ReadonlyArray { diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index dfb104e5c996..d848ab3e40d3 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -8,6 +8,7 @@ import { createEnvironmentSnapshotAtom, createShellEnvironmentAtoms, } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -22,6 +23,22 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ shellStateValueAtom: environmentShell.stateValueAtom, }); +const EMPTY_AUTHORITATIVE_SHELL_ENVIRONMENT_IDS: ReadonlySet = new Set(); + +export const authoritativeShellEnvironmentIdsAtom = Atom.make((get) => { + const catalog = AsyncResult.value(get(environmentCatalog.catalogAtom)); + if (Option.isNone(catalog)) { + return EMPTY_AUTHORITATIVE_SHELL_ENVIRONMENT_IDS; + } + const environmentIds = new Set(); + for (const environmentId of catalog.value.entries.keys()) { + if (get(environmentShell.stateValueAtom(environmentId)).status === "live") { + environmentIds.add(environmentId); + } + } + return environmentIds; +}).pipe(Atom.withLabel("web-authoritative-shell-environment-ids")); + export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { const catalog = AsyncResult.value(get(environmentCatalog.catalogAtom)); if (Option.isNone(catalog)) { diff --git a/docs/user/desktop-notifications.md b/docs/user/desktop-notifications.md new file mode 100644 index 000000000000..6426cfde378a --- /dev/null +++ b/docs/user/desktop-notifications.md @@ -0,0 +1,28 @@ +# Desktop Notifications + +The T3 Code desktop app can send native system notifications while the app is in the background and +an agent needs your attention or finishes work. + +Open **Settings** → **General** → **Desktop notifications**, then turn on **Notify me while T3 Code +is in the background**. Use **Send test** to confirm that notifications are allowed by your +operating system. + +Choose which events can notify you: + +- **Approval needed** when an agent is blocked on an approval +- **Waiting for input** when an agent asks a question or needs more direction +- **Agent finished** when a turn completes +- **Agent failed** when a provider or turn ends with an error + +Starting and routine working updates do not create notifications. T3 Code suppresses all +notifications while the desktop window is focused. Opening a notification focuses T3 Code and +takes you to the relevant environment and thread. + +The title and message text are identical on macOS, Windows, and Linux. The operating system controls +the notification's visual style, placement, timing, and permission settings. + +Turn off **Sound** for silent notifications. Turn off **Show names** to replace project and thread +names with a generic message on shared screens. + +T3 Code treats the first thread snapshot after launch as current state, not a notification backlog, +so reconnecting does not replay old completions or failures. diff --git a/docs/user/install.md b/docs/user/install.md index fe0b418ca1e5..e98f57ea2b43 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -81,4 +81,5 @@ For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./provi - [Permission modes](./permission-modes.md): how much T3 Code asks before acting - [Remote access](./remote-access.md): connect from a phone, tablet, or another desktop - [Keeping T3 Code in sync](./updating.md): client and server version skew +- [Desktop notifications](./desktop-notifications.md): choose which agent events notify you - [Running in the background](./background-service.md): Linux background service diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 64ef755eedfd..74b13bb17fd5 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -87,7 +87,7 @@ import type { OrchestrationSubscribeThreadInput, OrchestrationThreadStreamItem, } from "./orchestration.ts"; -import { EnvironmentId } from "./baseSchemas.ts"; +import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; @@ -167,6 +167,46 @@ export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); +export const DesktopNotificationEventSchema = Schema.Literals([ + "approval", + "input", + "completion", + "failure", +]); +export type DesktopNotificationEvent = typeof DesktopNotificationEventSchema.Type; + +export const DesktopNotificationTargetSchema = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, +}); +export type DesktopNotificationTarget = typeof DesktopNotificationTargetSchema.Type; + +export const DesktopNotificationShowInputSchema = Schema.Struct({ + ...DesktopNotificationTargetSchema.fields, + event: DesktopNotificationEventSchema, + projectTitle: TrimmedNonEmptyString, + threadTitle: TrimmedNonEmptyString, + showContext: Schema.Boolean, + silent: Schema.Boolean, +}); +export type DesktopNotificationShowInput = typeof DesktopNotificationShowInputSchema.Type; + +export const DesktopNotificationShowResultSchema = Schema.Literals([ + "shown", + "suppressed", + "unsupported", + "failed", +]); +export type DesktopNotificationShowResult = typeof DesktopNotificationShowResultSchema.Type; + +export interface DesktopNotificationsBridge { + show: (input: DesktopNotificationShowInput) => Promise; + dismiss: (target: DesktopNotificationTarget) => Promise; + dismissAll: () => Promise; + showTest: (input: { readonly silent: boolean }) => Promise; + onActivated: (listener: (target: DesktopNotificationTarget) => void) => () => void; +} + export interface DesktopAppBranding { baseName: string; stageLabel: DesktopAppStageLabel; @@ -1058,6 +1098,8 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + /** Native OS notifications. Optional while renderer and desktop versions overlap. */ + notifications?: DesktopNotificationsBridge; /** * Desktop-only preview surface. Present iff the renderer is hosted by the * Electron desktop build; web builds have `preview === undefined`. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 46705837afa4..6abc58f5e3fc 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -67,6 +67,50 @@ describe("ClientSettings environment identification", () => { }); }); +describe("ClientSettings desktop notifications", () => { + it("defaults to opt-in notifications with every attention event selected", () => { + expect(decodeClientSettings({}).desktopNotifications).toEqual({ + enabled: false, + soundEnabled: true, + showContext: true, + events: { + approval: true, + input: true, + completion: true, + failure: true, + }, + }); + }); + + it("decodes user event and privacy choices", () => { + expect( + decodeClientSettingsPatch({ + desktopNotifications: { + enabled: true, + soundEnabled: false, + showContext: false, + events: { + approval: true, + input: true, + completion: false, + failure: true, + }, + }, + }).desktopNotifications, + ).toEqual({ + enabled: true, + soundEnabled: false, + showContext: false, + events: { + approval: true, + input: true, + completion: false, + failure: true, + }, + }); + }); +}); + describe("ClientSettings sidebar", () => { it("defaults to the current sidebar with a three-day auto-settle threshold", () => { const settings = decodeClientSettings({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 388205649c85..157f1551f2a5 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -104,6 +104,31 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; +export const DesktopNotificationEventSettingsSchema = Schema.Struct({ + approval: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + input: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + completion: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + failure: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), +}); +export type DesktopNotificationEventSettings = typeof DesktopNotificationEventSettingsSchema.Type; + +export const DEFAULT_DESKTOP_NOTIFICATION_EVENT_SETTINGS: DesktopNotificationEventSettings = + Schema.decodeSync(DesktopNotificationEventSettingsSchema)({}); + +export const DesktopNotificationSettingsSchema = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + soundEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + showContext: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + events: DesktopNotificationEventSettingsSchema.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_DESKTOP_NOTIFICATION_EVENT_SETTINGS)), + ), +}); +export type DesktopNotificationSettings = typeof DesktopNotificationSettingsSchema.Type; + +export const DEFAULT_DESKTOP_NOTIFICATION_SETTINGS: DesktopNotificationSettings = Schema.decodeSync( + DesktopNotificationSettingsSchema, +)({}); + /** * A user-chosen font family (a single name or a comma-separated list). Empty * means "use the app default"; clients compose their own fallback stacks. @@ -118,6 +143,9 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + desktopNotifications: DesktopNotificationSettingsSchema.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_DESKTOP_NOTIFICATION_SETTINGS)), + ), environmentIdentificationMode: EnvironmentIdentificationMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE)), ), @@ -758,6 +786,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), + desktopNotifications: Schema.optionalKey(DesktopNotificationSettingsSchema), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), fontSizeInterface: Schema.optionalKey(InterfaceFontSize), diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 28e07c04e6f3..03ccd219f38a 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "@effect/vitest"; +import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentId, @@ -9,7 +9,13 @@ import type { } from "@t3tools/contracts"; import { ProviderInstanceId } from "@t3tools/contracts"; -import { projectThreadAwareness } from "./agentAwareness.ts"; +import { + formatAgentNotificationContent, + notificationEventForAwarenessTransition, + projectThreadAwareness, + type AgentAwarenessPhase, + type AgentAwarenessState, +} from "./agentAwareness.ts"; const NOW = "2026-05-22T12:00:00.000Z"; @@ -178,3 +184,73 @@ describe("projectThreadAwareness", () => { }); }); }); + +function awarenessState(phase: AgentAwarenessPhase): AgentAwarenessState { + return { + environmentId: "env-1" as EnvironmentId, + threadId: "thread-1" as ThreadId, + projectTitle: "t3code", + threadTitle: "Fix failing CI", + phase, + headline: "Test", + modelTitle: "gpt-5.4", + updatedAt: NOW, + deepLink: "/threads/env-1/thread-1", + }; +} + +describe("desktop notification projection", () => { + it.each([ + ["waiting_for_approval", "approval"], + ["waiting_for_input", "input"], + ["completed", "completion"], + ["failed", "failure"], + ] as const)("maps a phase edge to %s notifications", (phase, event) => { + expect( + notificationEventForAwarenessTransition(awarenessState("running"), awarenessState(phase)), + ).toBe(event); + }); + + it("does not notify for repeated phases or background progress", () => { + expect( + notificationEventForAwarenessTransition( + awarenessState("waiting_for_input"), + awarenessState("waiting_for_input"), + ), + ).toBeNull(); + expect( + notificationEventForAwarenessTransition( + awarenessState("starting"), + awarenessState("running"), + ), + ).toBeNull(); + }); + + it("uses the same concise content for every platform adapter", () => { + expect( + formatAgentNotificationContent({ + event: "approval", + projectTitle: "t3code", + threadTitle: "Fix failing CI", + showContext: true, + }), + ).toEqual({ + title: "Approval needed", + body: "Fix failing CI · t3code", + }); + }); + + it("can hide project and thread names", () => { + expect( + formatAgentNotificationContent({ + event: "failure", + projectTitle: "Secret project", + threadTitle: "Sensitive task", + showContext: false, + }), + ).toEqual({ + title: "Agent failed", + body: "Open T3 Code to view details.", + }); + }); +}); diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index c0f5842eb7c5..5c46a5d6d0c4 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -1,4 +1,5 @@ import type { + DesktopNotificationEvent, EnvironmentId, OrchestrationProjectShell, OrchestrationThreadShell, @@ -27,6 +28,78 @@ export interface AgentAwarenessState { readonly deepLink: string; } +export interface AgentNotificationContent { + readonly title: string; + readonly body: string; +} + +const AGENT_NOTIFICATION_TITLE_BY_EVENT: Record = { + approval: "Approval needed", + input: "Waiting for input", + completion: "Agent finished", + failure: "Agent failed", +}; + +const PRIVATE_AGENT_NOTIFICATION_BODY = "Open T3 Code to view details."; +const MAX_AGENT_NOTIFICATION_BODY_CHARACTERS = 160; + +export function notificationEventForAwarenessTransition( + previous: AgentAwarenessState | null, + current: AgentAwarenessState | null, +): DesktopNotificationEvent | null { + if (current === null || previous?.phase === current.phase) { + return null; + } + + switch (current.phase) { + case "waiting_for_approval": + return "approval"; + case "waiting_for_input": + return "input"; + case "completed": + return "completion"; + case "failed": + return "failure"; + case "starting": + case "running": + case "stale": + return null; + } +} + +/** Shared notification copy. Every desktop platform receives these exact title/body strings. */ +export function formatAgentNotificationContent(input: { + readonly event: DesktopNotificationEvent; + readonly projectTitle: string; + readonly threadTitle: string; + readonly showContext: boolean; +}): AgentNotificationContent { + return { + title: AGENT_NOTIFICATION_TITLE_BY_EVENT[input.event], + body: input.showContext + ? truncateNotificationText( + `${input.threadTitle.trim()} · ${input.projectTitle.trim()}`, + MAX_AGENT_NOTIFICATION_BODY_CHARACTERS, + ) + : PRIVATE_AGENT_NOTIFICATION_BODY, + }; +} + +export function formatAgentNotificationTestContent(): AgentNotificationContent { + return { + title: "Notifications are working", + body: "T3 Code will alert you when an agent needs attention.", + }; +} + +function truncateNotificationText(value: string, maximumCharacters: number): string { + const characters = Array.from(value); + if (characters.length <= maximumCharacters) { + return value; + } + return `${characters.slice(0, maximumCharacters - 1).join("")}…`; +} + export interface ProjectThreadAwarenessInput { readonly environmentId: EnvironmentId; readonly project: Pick;