From aab5b3d196d128285ba91b7ac1abf11b42d6de8e Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Tue, 11 Aug 2026 16:23:22 +0530 Subject: [PATCH 1/7] feat(desktop): native notifications for agent tasks Agent tasks finished, failed, or blocked on an approval with nothing to tell the user unless they happened to be looking at the window. The only notification stack in the repo was iOS-only (APNs via the relay). Adds native OS notifications to the Electron desktop app, driven by thread phase transitions. Three notifiable events (task completed, task failed, approval needed), each individually toggleable alongside a master switch and a sound toggle in a new Notifications settings section. Clicking a notification reveals the window and navigates to that thread. The trigger engine is pure and unit-tested. Two rules keep it quiet: a thread absent from the previous phase map is seeded silently, and a notification only fires on a transition out of an active phase. Together those mean a launch with cached completed threads, a resync, and an environment reconnect all produce zero notifications. The phase map advances before settings and focus filtering, so a suppressed banner is never replayed later. Co-Authored-By: Claude Opus 5 (1M context) --- .../electron/ElectronNotifications.test.ts | 116 +++++++ .../src/electron/ElectronNotifications.ts | 68 +++++ apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 2 + .../src/ipc/methods/notifications.test.ts | 95 ++++++ apps/desktop/src/ipc/methods/notifications.ts | 44 +++ apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 19 ++ .../settings/DesktopClientSettings.test.ts | 5 + .../settings/NotificationsSettingsPanel.tsx | 133 ++++++++ .../settings/SettingsSidebarNav.tsx | 15 +- .../src/components/settings/settingsSearch.ts | 40 +++ apps/web/src/components/ui/toast.tsx | 28 +- apps/web/src/hooks/useActiveThreadRef.ts | 35 +++ .../DesktopThreadNotifications.tsx | 108 +++++++ .../desktopNotifications.logic.test.ts | 283 ++++++++++++++++++ .../desktopNotifications.logic.ts | 225 ++++++++++++++ apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/__root.tsx | 2 + .../web/src/routes/settings.notifications.tsx | 11 + apps/web/src/state/entities.ts | 4 + packages/contracts/src/ipc.ts | 43 ++- packages/contracts/src/settings.ts | 19 ++ 23 files changed, 1290 insertions(+), 30 deletions(-) create mode 100644 apps/desktop/src/electron/ElectronNotifications.test.ts create mode 100644 apps/desktop/src/electron/ElectronNotifications.ts create mode 100644 apps/desktop/src/ipc/methods/notifications.test.ts create mode 100644 apps/desktop/src/ipc/methods/notifications.ts create mode 100644 apps/web/src/components/settings/NotificationsSettingsPanel.tsx create mode 100644 apps/web/src/hooks/useActiveThreadRef.ts create mode 100644 apps/web/src/notifications/DesktopThreadNotifications.tsx create mode 100644 apps/web/src/notifications/desktopNotifications.logic.test.ts create mode 100644 apps/web/src/notifications/desktopNotifications.logic.ts create mode 100644 apps/web/src/routes/settings.notifications.tsx diff --git a/apps/desktop/src/electron/ElectronNotifications.test.ts b/apps/desktop/src/electron/ElectronNotifications.test.ts new file mode 100644 index 000000000000..a4bf34a0b64e --- /dev/null +++ b/apps/desktop/src/electron/ElectronNotifications.test.ts @@ -0,0 +1,116 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vite-plus/test"; + +const { isSupportedMock, showMock, notificationConstructorMock, handlersByEvent } = vi.hoisted( + () => ({ + isSupportedMock: vi.fn(), + showMock: vi.fn(), + notificationConstructorMock: vi.fn(), + handlersByEvent: new Map void>(), + }), +); + +vi.mock("electron", () => { + class Notification { + static isSupported = isSupportedMock; + + constructor(options: unknown) { + notificationConstructorMock(options); + } + + on(event: string, handler: () => void) { + handlersByEvent.set(event, handler); + return this; + } + + show() { + showMock(); + } + } + + return { Notification }; +}); + +import * as ElectronNotifications from "./ElectronNotifications.ts"; + +describe("ElectronNotifications", () => { + beforeEach(() => { + isSupportedMock.mockReset(); + showMock.mockReset(); + notificationConstructorMock.mockReset(); + handlersByEvent.clear(); + isSupportedMock.mockReturnValue(true); + }); + + it.effect("shows a notification with the requested copy", () => + Effect.gen(function* () { + const notifications = yield* ElectronNotifications.ElectronNotifications; + yield* notifications.show({ + title: "Fix flaky auth test", + body: "Agent finished · t3code", + silent: true, + onActivate: () => {}, + }); + + assert.deepEqual(notificationConstructorMock.mock.calls, [ + [{ title: "Fix flaky auth test", body: "Agent finished · t3code", silent: true }], + ]); + assert.equal(showMock.mock.calls.length, 1); + }).pipe(Effect.provide(ElectronNotifications.layer)), + ); + + it.effect("does nothing when the platform has no notification support", () => + Effect.gen(function* () { + isSupportedMock.mockReturnValue(false); + + const notifications = yield* ElectronNotifications.ElectronNotifications; + yield* notifications.show({ + title: "Fix flaky auth test", + body: "Agent finished", + silent: false, + onActivate: () => {}, + }); + + assert.equal(notificationConstructorMock.mock.calls.length, 0); + assert.equal(showMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronNotifications.layer)), + ); + + it.effect("invokes onActivate when the notification is clicked", () => + Effect.gen(function* () { + let activated = 0; + + const notifications = yield* ElectronNotifications.ElectronNotifications; + yield* notifications.show({ + title: "Fix flaky auth test", + body: "Agent finished", + silent: false, + onActivate: () => { + activated += 1; + }, + }); + + handlersByEvent.get("click")?.(); + assert.equal(activated, 1); + }).pipe(Effect.provide(ElectronNotifications.layer)), + ); + + it.effect("survives a construction failure instead of taking the app down", () => + Effect.gen(function* () { + isSupportedMock.mockImplementation(() => { + throw new Error("notification center unavailable"); + }); + + const notifications = yield* ElectronNotifications.ElectronNotifications; + yield* notifications.show({ + title: "Fix flaky auth test", + body: "Agent finished", + silent: false, + onActivate: () => {}, + }); + + assert.equal(showMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronNotifications.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronNotifications.ts b/apps/desktop/src/electron/ElectronNotifications.ts new file mode 100644 index 000000000000..5cabb8161215 --- /dev/null +++ b/apps/desktop/src/electron/ElectronNotifications.ts @@ -0,0 +1,68 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Electron from "electron"; + +import { makeComponentLogger } from "../app/DesktopObservability.ts"; + +const { logWarning: logNotificationWarning } = makeComponentLogger("desktop-notifications"); + +export interface ShowNotificationInput { + readonly title: string; + readonly body: string; + readonly silent: boolean; + readonly onActivate: () => void; +} + +export class ElectronNotifications extends Context.Service< + ElectronNotifications, + { + /** + * Raises a native OS notification. Never fails: a notification that cannot + * be shown is a cosmetic loss, and taking the app down over one would be + * strictly worse than staying quiet. + */ + readonly show: (input: ShowNotificationInput) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronNotifications") {} + +/** + * Notifications are handed to the OS asynchronously. Dropping the last JS + * reference before the platform is done with it lets GC collect the object + * mid-flight, which silently loses the banner (and its click handler) on both + * macOS and Windows. Entries are released on click or close. + */ +const liveNotifications = new Set(); + +export const make = ElectronNotifications.of({ + show: (input) => + Effect.sync(() => { + if (!Electron.Notification.isSupported()) { + return; + } + + const notification = new Electron.Notification({ + title: input.title, + body: input.body, + silent: input.silent, + }); + liveNotifications.add(notification); + + notification.on("click", () => { + liveNotifications.delete(notification); + input.onActivate(); + }); + notification.on("close", () => { + liveNotifications.delete(notification); + }); + + notification.show(); + }).pipe( + Effect.catchCause((cause) => + logNotificationWarning("Failed to show a native notification.", { cause }), + ), + ), +}); + +export const layer = Layer.succeed(ElectronNotifications, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index cb35ad19ac7f..eb9fcc8b4d67 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -7,6 +7,7 @@ import { getConnectionCatalog, setConnectionCatalog, } from "./methods/connectionCatalog.ts"; +import { showNotification } from "./methods/notifications.ts"; import { getAdvertisedEndpoints, getServerExposureState, @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(showNotification); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 4a1213e4ec66..b20988640900 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,6 +3,8 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const SHOW_NOTIFICATION_CHANNEL = "desktop:show-notification"; +export const NOTIFICATION_ACTIVATED_CHANNEL = "desktop:notification-activated"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/notifications.test.ts b/apps/desktop/src/ipc/methods/notifications.test.ts new file mode 100644 index 000000000000..2feebdb5fc37 --- /dev/null +++ b/apps/desktop/src/ipc/methods/notifications.test.ts @@ -0,0 +1,95 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type Electron from "electron"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ElectronNotifications from "../../electron/ElectronNotifications.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import { NOTIFICATION_ACTIVATED_CHANNEL } from "../channels.ts"; +import { showNotification } from "./notifications.ts"; + +const THREAD_REF = { + environmentId: "env-1" as EnvironmentId, + threadId: "thread-1" as ThreadId, +}; + +const PAYLOAD = { + kind: "task-completed", + title: "Fix flaky auth test", + body: "Agent finished · t3code", + silent: false, + threadRef: THREAD_REF, +} as const; + +describe("showNotification", () => { + it.effect("passes the renderer's copy straight through to the OS", () => { + const shown: Array<{ title: string; body: string; silent: boolean }> = []; + + return Effect.gen(function* () { + yield* showNotification.handler(PAYLOAD); + + assert.deepEqual(shown, [ + { title: "Fix flaky auth test", body: "Agent finished · t3code", silent: false }, + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ElectronNotifications.ElectronNotifications)({ + show: (input) => + Effect.sync(() => { + shown.push({ title: input.title, body: input.body, silent: input.silent }); + }), + }), + Layer.mock(ElectronWindow.ElectronWindow)({ + currentMainOrFirst: Effect.succeed(Option.none()), + reveal: () => Effect.void, + sendAll: () => Effect.void, + }), + ), + ), + ); + }); + + it.effect("reveals the window and reports the thread when clicked", () => { + const window = {} as Electron.BrowserWindow; + const revealed: Array = []; + const sent: Array]> = []; + let activate: (() => void) | null = null; + + return Effect.gen(function* () { + yield* showNotification.handler(PAYLOAD); + + assert.isNotNull(activate); + activate?.(); + // The activation runs as its own promise, so let the microtask queue drain. + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(revealed, [window]); + assert.deepEqual(sent, [[NOTIFICATION_ACTIVATED_CHANNEL, [{ threadRef: THREAD_REF }]]]); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ElectronNotifications.ElectronNotifications)({ + show: (input) => + Effect.sync(() => { + activate = input.onActivate; + }), + }), + Layer.mock(ElectronWindow.ElectronWindow)({ + currentMainOrFirst: Effect.succeed(Option.some(window)), + reveal: (target) => + Effect.sync(() => { + revealed.push(target); + }), + sendAll: (channel, ...args) => + Effect.sync(() => { + sent.push([channel, args]); + }), + }), + ), + ), + ); + }); +}); diff --git a/apps/desktop/src/ipc/methods/notifications.ts b/apps/desktop/src/ipc/methods/notifications.ts new file mode 100644 index 000000000000..4976fa940f1d --- /dev/null +++ b/apps/desktop/src/ipc/methods/notifications.ts @@ -0,0 +1,44 @@ +import { DesktopNotificationInputSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ElectronNotifications from "../../electron/ElectronNotifications.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const showNotification = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SHOW_NOTIFICATION_CHANNEL, + payload: DesktopNotificationInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.notifications.show")(function* (input) { + const notifications = yield* ElectronNotifications.ElectronNotifications; + const electronWindow = yield* ElectronWindow.ElectronWindow; + // The click arrives from Electron outside any fiber, so capture the + // context here and run the activation as its own promise. + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + yield* notifications.show({ + title: input.title, + body: input.body, + silent: input.silent, + onActivate: () => { + void runPromise( + Effect.gen(function* () { + // `currentMainOrFirst`, not `focusedMainOrFirst`: a notification is + // only clicked when the app is unfocused, so nothing is focused yet. + const window = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(window)) { + yield* electronWindow.reveal(window.value); + } + yield* electronWindow.sendAll(IpcChannels.NOTIFICATION_ACTIVATED_CHANNEL, { + threadRef: input.threadRef, + }); + }), + ); + }, + }); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0616184ec74d..2fa9c74a6f03 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -24,6 +24,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; +import * as ElectronNotifications from "./electron/ElectronNotifications.ts"; import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; @@ -117,6 +118,7 @@ const electronLayer = Layer.mergeAll( ElectronApp.layer, ElectronDialog.layer, ElectronMenu.layer, + ElectronNotifications.layer, ElectronPowerMonitor.layer, ElectronProtocol.layer, ElectronSafeStorage.layer, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2aa345ee5847..719d0426700b 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,6 @@ import type { DesktopBridge, + DesktopNotificationActivation, DesktopPreviewPointerEvent, DesktopPreviewRecordingFrame, DesktopPreviewTabState, @@ -105,6 +106,24 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + showNotification: (input) => ipcRenderer.invoke(IpcChannels.SHOW_NOTIFICATION_CHANNEL, input), + onNotificationActivated: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, activation: unknown) => { + if (typeof activation !== "object" || activation === null) return; + const { threadRef } = activation as { threadRef?: unknown }; + if (typeof threadRef !== "object" || threadRef === null) return; + const { environmentId, threadId } = threadRef as Record; + if (typeof environmentId !== "string" || typeof threadId !== "string") return; + listener({ + threadRef: { environmentId, threadId }, + } as DesktopNotificationActivation); + }; + + ipcRenderer.on(IpcChannels.NOTIFICATION_ACTIVATED_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.NOTIFICATION_ACTIVATED_CHANNEL, wrappedListener); + }; + }, onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 861f72178a68..81f96659dc8a 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -15,6 +15,11 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, + desktopNotificationsEnabled: true, + desktopNotificationSound: true, + desktopNotifyApprovalNeeded: true, + desktopNotifyTaskCompleted: true, + desktopNotifyTaskFailed: true, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", diff --git a/apps/web/src/components/settings/NotificationsSettingsPanel.tsx b/apps/web/src/components/settings/NotificationsSettingsPanel.tsx new file mode 100644 index 000000000000..ba63c7df89bf --- /dev/null +++ b/apps/web/src/components/settings/NotificationsSettingsPanel.tsx @@ -0,0 +1,133 @@ +import { BellIcon } from "lucide-react"; +import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings"; + +import { isElectron } from "~/env"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { Switch } from "../ui/switch"; +import { + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "./settingsLayout"; +import { searchableSetting, type SettingsSearchItemId } from "./settingsSearch"; + +type NotificationToggleKey = Extract< + keyof ClientSettings, + | "desktopNotifyTaskCompleted" + | "desktopNotifyTaskFailed" + | "desktopNotifyApprovalNeeded" + | "desktopNotificationSound" +>; + +const NOTIFICATION_TOGGLES: ReadonlyArray<{ + readonly key: NotificationToggleKey; + readonly searchId: SettingsSearchItemId; + readonly description: string; + readonly resetLabel: string; +}> = [ + { + key: "desktopNotifyTaskCompleted", + searchId: "notification-task-completed", + description: "The agent finished its turn.", + resetLabel: "task completed notifications", + }, + { + key: "desktopNotifyTaskFailed", + searchId: "notification-task-failed", + description: "The agent stopped with an error.", + resetLabel: "task failed notifications", + }, + { + key: "desktopNotifyApprovalNeeded", + searchId: "notification-approval-needed", + description: "The agent is blocked until you approve an action.", + resetLabel: "approval notifications", + }, + { + key: "desktopNotificationSound", + searchId: "notification-sound", + description: "Play the system notification sound.", + resetLabel: "notification sound", + }, +]; + +export function NotificationsSettingsPanel() { + const settings = useClientSettings(); + const updateSettings = useUpdateClientSettings(); + + if (!isElectron) { + return ( + + }> +

+ Native notifications are available in the desktop app. +

+
+
+ ); + } + + const notificationsDisabled = !settings.desktopNotificationsEnabled; + + return ( + + }> + + updateSettings({ + desktopNotificationsEnabled: + DEFAULT_CLIENT_SETTINGS.desktopNotificationsEnabled, + }) + } + /> + ) : null + } + control={ + + updateSettings({ desktopNotificationsEnabled: Boolean(checked) }) + } + aria-label="Enable notifications" + /> + } + /> + + {NOTIFICATION_TOGGLES.map((toggle) => ( + + updateSettings({ [toggle.key]: DEFAULT_CLIENT_SETTINGS[toggle.key] }) + } + /> + ) : null + } + control={ + updateSettings({ [toggle.key]: Boolean(checked) })} + aria-label={searchableSetting(toggle.searchId).title} + /> + } + /> + ))} + + + ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index c34ca1016531..00ab8696e390 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -10,6 +10,7 @@ import { import { ArchiveIcon, ArrowLeftIcon, + BellIcon, BotIcon, GitBranchIcon, KeyboardIcon, @@ -36,16 +37,19 @@ import { import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; import { scrollToSettingsTarget } from "./settingsLayout"; import { + isSettingsPathAvailable, searchSettings, SETTINGS_SECTION_LABELS, type SettingsPath, type SettingsSearchItem, } from "./settingsSearch"; +import { isElectron } from "~/env"; const SETTINGS_SECTION_ICONS: Readonly< Record> > = { "/settings/general": Settings2Icon, + "/settings/notifications": BellIcon, "/settings/appearance": PaletteIcon, "/settings/keybindings": KeyboardIcon, "/settings/providers": BotIcon, @@ -77,7 +81,14 @@ 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 availableNavItems = useMemo( + () => SETTINGS_NAV_ITEMS.filter((item) => isSettingsPathAvailable(item.to, isElectron)), + [], + ); + const results = useMemo( + () => searchSettings(query).filter((result) => isSettingsPathAvailable(result.to, isElectron)), + [query], + ); const isSearching = query.trim().length > 0; const hasResults = results.length > 0; @@ -275,7 +286,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { )) - : SETTINGS_NAV_ITEMS.map((item) => { + : availableNavItems.map((item) => { const Icon = item.icon; const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`); return ( diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 34fd4602f784..6783678c2015 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,5 +1,6 @@ export type SettingsPath = | "/settings/general" + | "/settings/notifications" | "/settings/appearance" | "/settings/keybindings" | "/settings/providers" @@ -20,6 +21,7 @@ export interface SettingsSearchItem { */ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/general": "General", + "/settings/notifications": "Notifications", "/settings/appearance": "Appearance", "/settings/keybindings": "Keybindings", "/settings/providers": "Providers", @@ -149,6 +151,31 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Text generation model", to: "/settings/general", }, + { + id: "notifications", + title: "Notifications", + to: "/settings/notifications", + }, + { + id: "notification-task-completed", + title: "Notify when a task completes", + to: "/settings/notifications", + }, + { + id: "notification-task-failed", + title: "Notify when a task fails", + to: "/settings/notifications", + }, + { + id: "notification-approval-needed", + title: "Notify when approval is needed", + to: "/settings/notifications", + }, + { + id: "notification-sound", + title: "Notification sound", + to: "/settings/notifications", + }, { id: "diagnostics", title: "Diagnostics", @@ -198,6 +225,19 @@ export const SETTINGS_SEARCH_ITEMS = [ export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"]; +/** + * Sections that only exist where a desktop bridge does. The web build hides + * them from the nav and from search rather than routing to a panel whose + * controls could not do anything. + */ +export const DESKTOP_ONLY_SETTINGS_PATHS: ReadonlySet = new Set([ + "/settings/notifications", +]); + +export function isSettingsPathAvailable(to: SettingsPath, desktop: boolean): boolean { + return desktop || !DESKTOP_ONLY_SETTINGS_PATHS.has(to); +} + const SEARCH_ITEMS_BY_ID = Object.fromEntries( SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item]), ) as Readonly>; diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index cd23ac2710a1..3de9a105afcc 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -3,14 +3,12 @@ import { Toast } from "@base-ui/react/toast"; import { useEffect, - useMemo, useState, type CSSProperties, type ComponentPropsWithoutRef, type KeyboardEvent, type ReactNode, } from "react"; -import { useParams } from "@tanstack/react-router"; import { type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; import { CheckIcon, @@ -27,9 +25,8 @@ import { import { cn } from "~/lib/utils"; import { buttonVariants } from "~/components/ui/button"; -import { useComposerDraftStore } from "~/composerDraftStore"; +import { useActiveThreadRefFromRoute } from "~/hooks/useActiveThreadRef"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { resolveThreadRouteTarget } from "~/threadRoutes"; import { buildVisibleToastLayout, shouldHideCollapsedToastContent, @@ -427,29 +424,6 @@ interface ToastProviderProps extends Toast.Provider.Props { position?: ToastPosition; } -function useActiveThreadRefFromRoute(): ScopedThreadRef | null { - const routeTarget = useParams({ - strict: false, - select: (params) => resolveThreadRouteTarget(params), - }); - const activeDraftSession = useComposerDraftStore((store) => - routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, - ); - - return useMemo(() => { - if (routeTarget?.kind === "server") { - return routeTarget.threadRef; - } - if (routeTarget?.kind === "draft" && activeDraftSession) { - return { - environmentId: activeDraftSession.environmentId, - threadId: activeDraftSession.threadId, - }; - } - return null; - }, [activeDraftSession, routeTarget]); -} - function ThreadToastVisibleAutoDismiss({ toastId, dismissAfterVisibleMs, diff --git a/apps/web/src/hooks/useActiveThreadRef.ts b/apps/web/src/hooks/useActiveThreadRef.ts new file mode 100644 index 000000000000..e25695097d1f --- /dev/null +++ b/apps/web/src/hooks/useActiveThreadRef.ts @@ -0,0 +1,35 @@ +import { useMemo } from "react"; +import { useParams } from "@tanstack/react-router"; +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { useComposerDraftStore } from "~/composerDraftStore"; +import { resolveThreadRouteTarget } from "~/threadRoutes"; + +/** + * The thread the user is currently looking at, or null anywhere else in the + * app (settings, pull requests, the thread list). A draft resolves to the + * server thread it was promoted into, so surfaces that key off "is this thread + * on screen" keep working across the promotion. + */ +export function useActiveThreadRefFromRoute(): ScopedThreadRef | null { + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const activeDraftSession = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + + return useMemo(() => { + if (routeTarget?.kind === "server") { + return routeTarget.threadRef; + } + if (routeTarget?.kind === "draft" && activeDraftSession) { + return { + environmentId: activeDraftSession.environmentId, + threadId: activeDraftSession.threadId, + }; + } + return null; + }, [activeDraftSession, routeTarget]); +} diff --git a/apps/web/src/notifications/DesktopThreadNotifications.tsx b/apps/web/src/notifications/DesktopThreadNotifications.tsx new file mode 100644 index 000000000000..783322ac9053 --- /dev/null +++ b/apps/web/src/notifications/DesktopThreadNotifications.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import type { ClientSettings } from "@t3tools/contracts/settings"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; + +import { isElectron } from "~/env"; +import { getClientSettings } from "~/hooks/useSettings"; +import { useActiveThreadRefFromRoute } from "~/hooks/useActiveThreadRef"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { environmentThreadShells } from "~/state/threads"; +import { readProjects, readThreadShells } from "~/state/entities"; +import { + buildProjectTitleMap, + reconcileThreadNotifications, + EMPTY_THREAD_PHASE_SNAPSHOT, + type ThreadNotificationSettings, + type ThreadPhaseSnapshot, +} from "./desktopNotifications.logic"; + +export function selectThreadNotificationSettings( + settings: ClientSettings, +): ThreadNotificationSettings { + return { + enabled: settings.desktopNotificationsEnabled, + taskCompleted: settings.desktopNotifyTaskCompleted, + taskFailed: settings.desktopNotifyTaskFailed, + approvalNeeded: settings.desktopNotifyApprovalNeeded, + }; +} + +function DesktopThreadNotifications() { + const navigate = useNavigate(); + const activeThreadRef = useActiveThreadRefFromRoute(); + // Mirrored into a ref so the atom subscription below can read the current + // route without re-subscribing on every navigation. + const activeThreadRefMirror = useRef(activeThreadRef); + activeThreadRefMirror.current = activeThreadRef; + + useEffect(() => { + const showNotification = window.desktopBridge?.showNotification; + if (typeof showNotification !== "function") { + return; + } + + let phases: ThreadPhaseSnapshot = EMPTY_THREAD_PHASE_SNAPSHOT; + + const reconcile = (threads: ReadonlyArray) => { + const settings = getClientSettings(); + const { notifications, next } = reconcileThreadNotifications({ + previous: phases, + threads, + projectTitles: buildProjectTitleMap(readProjects()), + settings: selectThreadNotificationSettings(settings), + windowFocused: document.visibilityState === "visible" && document.hasFocus(), + activeThreadRef: activeThreadRefMirror.current, + }); + phases = next; + + for (const notification of notifications) { + void showNotification({ + kind: notification.kind, + title: notification.title, + body: notification.body, + silent: !settings.desktopNotificationSound, + threadRef: notification.threadRef, + }).catch(() => undefined); + } + }; + + // Seed from the current shells before subscribing: everything already + // loaded is recorded without firing, so a launch never replays a backlog. + reconcile(readThreadShells()); + + return appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, reconcile); + }, []); + + useEffect(() => { + const onNotificationActivated = window.desktopBridge?.onNotificationActivated; + if (typeof onNotificationActivated !== "function") { + return; + } + + const unsubscribe = onNotificationActivated(({ threadRef }) => { + void navigate({ + to: "/$environmentId/$threadId", + params: { + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }, + }); + }); + + return () => { + unsubscribe?.(); + }; + }, [navigate]); + + return null; +} + +/** + * Native OS notifications for agent task transitions. Desktop-only: without a + * desktop bridge there is nothing to raise a notification with, so the whole + * subtree stays unmounted on web. + */ +export function DesktopThreadNotificationsHost() { + return isElectron ? : null; +} diff --git a/apps/web/src/notifications/desktopNotifications.logic.test.ts b/apps/web/src/notifications/desktopNotifications.logic.test.ts new file mode 100644 index 000000000000..b76347046e23 --- /dev/null +++ b/apps/web/src/notifications/desktopNotifications.logic.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; + +import { + buildProjectTitleMap, + EMPTY_THREAD_PHASE_SNAPSHOT, + notifiableKind, + reconcileThreadNotifications, + threadNotificationKey, + type ThreadNotificationSettings, + type ThreadPhaseSnapshot, +} from "./desktopNotifications.logic"; + +const ENVIRONMENT_ID = "env-1" as EnvironmentId; +const PROJECT_ID = "project-1" as ProjectId; +const THREAD_ID = "thread-1" as ThreadId; + +const ALL_ENABLED: ThreadNotificationSettings = { + enabled: true, + taskCompleted: true, + taskFailed: true, + approvalNeeded: true, +}; + +type ThreadPhaseFixture = "running" | "completed" | "failed" | "approval" | "input" | "unknown"; + +/** + * Builds the smallest shell that drives `projectThreadAwareness` to the wanted + * phase, so the tests exercise the real phase resolution rather than a stub. + */ +function makeThread( + phase: ThreadPhaseFixture, + overrides: { + readonly threadId?: ThreadId; + readonly archivedAt?: string | null; + } = {}, +): EnvironmentThreadShell { + const base = { + id: overrides.threadId ?? THREAD_ID, + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + title: "Fix flaky auth test", + modelSelection: { provider: "codex", model: "gpt-5" }, + updatedAt: "2026-08-11T10:00:00.000Z", + archivedAt: overrides.archivedAt ?? null, + hasPendingApprovals: phase === "approval", + hasPendingUserInput: phase === "input", + latestTurn: null as unknown, + session: null as unknown, + }; + + switch (phase) { + case "running": + return { + ...base, + session: { status: "running", providerName: "Codex", lastError: null }, + latestTurn: { state: "running", completedAt: null }, + } as unknown as EnvironmentThreadShell; + case "completed": + return { + ...base, + session: { status: "ready", providerName: "Codex", lastError: null }, + latestTurn: { state: "completed", completedAt: "2026-08-11T10:00:00.000Z" }, + } as unknown as EnvironmentThreadShell; + case "failed": + return { + ...base, + session: { status: "error", providerName: "Codex", lastError: "Provider crashed" }, + } as unknown as EnvironmentThreadShell; + case "approval": + case "input": + return { + ...base, + session: { status: "running", providerName: "Codex", lastError: null }, + latestTurn: { state: "running", completedAt: null }, + } as unknown as EnvironmentThreadShell; + case "unknown": + return base as unknown as EnvironmentThreadShell; + } +} + +const PROJECT_TITLES = buildProjectTitleMap([ + { environmentId: ENVIRONMENT_ID, id: PROJECT_ID, title: "t3code" }, +] as unknown as ReadonlyArray); + +function reconcile( + previous: ThreadPhaseSnapshot, + threads: ReadonlyArray, + overrides: Partial<{ + settings: ThreadNotificationSettings; + windowFocused: boolean; + activeThreadRef: { environmentId: EnvironmentId; threadId: ThreadId } | null; + }> = {}, +) { + return reconcileThreadNotifications({ + previous, + threads, + projectTitles: PROJECT_TITLES, + settings: overrides.settings ?? ALL_ENABLED, + windowFocused: overrides.windowFocused ?? false, + activeThreadRef: overrides.activeThreadRef ?? null, + }); +} + +const KEY = threadNotificationKey({ environmentId: ENVIRONMENT_ID, threadId: THREAD_ID }); + +describe("notifiableKind", () => { + it("announces terminal phases reached from an active phase", () => { + expect(notifiableKind("running", "completed")).toBe("task-completed"); + expect(notifiableKind("running", "failed")).toBe("task-failed"); + expect(notifiableKind("running", "waiting_for_approval")).toBe("approval-needed"); + expect(notifiableKind("starting", "completed")).toBe("task-completed"); + }); + + it("stays quiet when the thread was not previously active", () => { + expect(notifiableKind("completed", "waiting_for_approval")).toBeNull(); + expect(notifiableKind("stale", "completed")).toBeNull(); + expect(notifiableKind(null, "completed")).toBeNull(); + expect(notifiableKind("running", null)).toBeNull(); + }); + + it("does not treat an approval-to-approval or non-terminal move as news", () => { + expect(notifiableKind("waiting_for_approval", "waiting_for_approval")).toBeNull(); + expect(notifiableKind("running", "running")).toBeNull(); + expect(notifiableKind("starting", "running")).toBeNull(); + }); +}); + +describe("reconcileThreadNotifications", () => { + it("seeds silently on first observation", () => { + const result = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]); + + expect(result.notifications).toEqual([]); + expect(result.next.get(KEY)).toBe("running"); + }); + + it("seeds a thread that is already completed without notifying", () => { + const result = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("completed")]); + + expect(result.notifications).toEqual([]); + expect(result.next.get(KEY)).toBe("completed"); + }); + + it("fires once when a running thread completes", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const result = reconcile(seeded, [makeThread("completed")]); + + expect(result.notifications).toHaveLength(1); + expect(result.notifications[0]?.kind).toBe("task-completed"); + expect(result.notifications[0]?.title).toBe("Fix flaky auth test"); + expect(result.notifications[0]?.body).toContain("t3code"); + expect(result.notifications[0]?.threadRef).toEqual({ + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + }); + }); + + it("does not re-fire while the thread sits in completed", () => { + let phases = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + let fired = 0; + + for (let pass = 0; pass < 3; pass += 1) { + const result = reconcile(phases, [makeThread("completed")]); + fired += result.notifications.length; + phases = result.next; + } + + expect(fired).toBe(1); + }); + + it("fires again when the thread is re-run", () => { + let phases = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + phases = reconcile(phases, [makeThread("completed")]).next; + phases = reconcile(phases, [makeThread("running")]).next; + + const result = reconcile(phases, [makeThread("completed")]); + expect(result.notifications).toHaveLength(1); + }); + + it("reports a failure with the provider error as the body", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const result = reconcile(seeded, [makeThread("failed")]); + + expect(result.notifications[0]?.kind).toBe("task-failed"); + expect(result.notifications[0]?.body).toContain("Provider crashed"); + }); + + it("reports an approval prompt", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const result = reconcile(seeded, [makeThread("approval")]); + + expect(result.notifications[0]?.kind).toBe("approval-needed"); + }); + + it("does not re-fire when a phase disappears and comes back", () => { + let phases = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + phases = reconcile(phases, [makeThread("completed")]).next; + phases = reconcile(phases, [makeThread("unknown")]).next; + expect(phases.get(KEY)).toBeNull(); + + const result = reconcile(phases, [makeThread("completed")]); + expect(result.notifications).toEqual([]); + }); + + it("suppresses the banner while the user watches that thread, but still advances", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const result = reconcile(seeded, [makeThread("completed")], { + windowFocused: true, + activeThreadRef: { environmentId: ENVIRONMENT_ID, threadId: THREAD_ID }, + }); + + expect(result.notifications).toEqual([]); + expect(result.next.get(KEY)).toBe("completed"); + + // Navigating away later must not replay the suppressed transition. + const afterNavigation = reconcile(result.next, [makeThread("completed")]); + expect(afterNavigation.notifications).toEqual([]); + }); + + it("still notifies when focused on a different thread", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const result = reconcile(seeded, [makeThread("completed")], { + windowFocused: true, + activeThreadRef: { environmentId: ENVIRONMENT_ID, threadId: "thread-2" as ThreadId }, + }); + + expect(result.notifications).toHaveLength(1); + }); + + it("still notifies when that thread is open but the window is not focused", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const result = reconcile(seeded, [makeThread("completed")], { + windowFocused: false, + activeThreadRef: { environmentId: ENVIRONMENT_ID, threadId: THREAD_ID }, + }); + + expect(result.notifications).toHaveLength(1); + }); + + it("filters by kind without backfilling once a toggle is turned on", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const filtered = reconcile(seeded, [makeThread("completed")], { + settings: { ...ALL_ENABLED, taskCompleted: false }, + }); + + expect(filtered.notifications).toEqual([]); + expect(filtered.next.get(KEY)).toBe("completed"); + + const afterEnabling = reconcile(filtered.next, [makeThread("completed")]); + expect(afterEnabling.notifications).toEqual([]); + }); + + it("filters everything when the master toggle is off but still advances", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const result = reconcile(seeded, [makeThread("completed")], { + settings: { ...ALL_ENABLED, enabled: false }, + }); + + expect(result.notifications).toEqual([]); + expect(result.next.get(KEY)).toBe("completed"); + }); + + it("ignores archived threads entirely", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const result = reconcile(seeded, [ + makeThread("completed", { archivedAt: "2026-08-11T09:00:00.000Z" }), + ]); + + expect(result.notifications).toEqual([]); + expect(result.next.has(KEY)).toBe(false); + }); + + it("prunes dropped threads and re-seeds them silently on return", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const afterDrop = reconcile(seeded, []); + expect(afterDrop.next.has(KEY)).toBe(false); + + const afterReturn = reconcile(afterDrop.next, [makeThread("completed")]); + expect(afterReturn.notifications).toEqual([]); + expect(afterReturn.next.get(KEY)).toBe("completed"); + }); +}); diff --git a/apps/web/src/notifications/desktopNotifications.logic.ts b/apps/web/src/notifications/desktopNotifications.logic.ts new file mode 100644 index 000000000000..0b10d89b7955 --- /dev/null +++ b/apps/web/src/notifications/desktopNotifications.logic.ts @@ -0,0 +1,225 @@ +import type { DesktopNotificationKind, ScopedThreadRef } from "@t3tools/contracts"; +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { projectThreadAwareness, type AgentAwarenessPhase } from "@t3tools/shared/agentAwareness"; + +/** + * Phases a thread must be leaving for its next phase to be worth announcing. + * Requiring an active predecessor is what makes a thread that is merely + * *observed* as finished (a cached snapshot, a resync, a reconnect) stay quiet: + * nothing happened, we just learned about it. + */ +const ACTIVE_PHASES: ReadonlySet = new Set([ + "starting", + "running", + "waiting_for_input", + "waiting_for_approval", +]); + +/** Phases that can precede an approval prompt and still count as progress. */ +const PRE_APPROVAL_PHASES: ReadonlySet = new Set([ + "starting", + "running", + "waiting_for_input", +]); + +/** macOS truncates long bodies anyway, and this rides a hot IPC path. */ +const MAX_BODY_LENGTH = 180; + +export interface ThreadNotificationSettings { + readonly enabled: boolean; + readonly taskCompleted: boolean; + readonly taskFailed: boolean; + readonly approvalNeeded: boolean; +} + +/** + * threadKey -> the phase we last observed. `null` is a real value (the thread + * exists but has no resolvable phase); collapsing it into "absent" would let + * `completed -> null -> completed` fire a second time. + */ +export type ThreadPhaseSnapshot = ReadonlyMap; + +export const EMPTY_THREAD_PHASE_SNAPSHOT: ThreadPhaseSnapshot = new Map(); + +export interface PendingThreadNotification { + readonly kind: DesktopNotificationKind; + readonly threadRef: ScopedThreadRef; + readonly title: string; + readonly body: string; +} + +export interface ReconcileThreadNotificationsInput { + readonly previous: ThreadPhaseSnapshot; + readonly threads: ReadonlyArray; + /** scoped project key -> title. A miss only costs the project name in the body. */ + readonly projectTitles: ReadonlyMap; + readonly settings: ThreadNotificationSettings; + readonly windowFocused: boolean; + readonly activeThreadRef: ScopedThreadRef | null; +} + +export interface ReconcileThreadNotificationsResult { + readonly notifications: ReadonlyArray; + readonly next: ThreadPhaseSnapshot; +} + +export function threadNotificationKey(ref: ScopedThreadRef): string { + return `${ref.environmentId}:${ref.threadId}`; +} + +export function projectTitleKey(ref: { + readonly environmentId: string; + readonly projectId: string; +}): string { + return `${ref.environmentId}:${ref.projectId}`; +} + +export function buildProjectTitleMap( + projects: ReadonlyArray, +): ReadonlyMap { + const titles = new Map(); + for (const project of projects) { + titles.set( + projectTitleKey({ environmentId: project.environmentId, projectId: project.id }), + project.title, + ); + } + return titles; +} + +export function notifiableKind( + previousPhase: AgentAwarenessPhase | null, + nextPhase: AgentAwarenessPhase | null, +): DesktopNotificationKind | null { + if (previousPhase === null || nextPhase === null) { + return null; + } + if (nextPhase === "completed" && ACTIVE_PHASES.has(previousPhase)) { + return "task-completed"; + } + if (nextPhase === "failed" && ACTIVE_PHASES.has(previousPhase)) { + return "task-failed"; + } + if (nextPhase === "waiting_for_approval" && PRE_APPROVAL_PHASES.has(previousPhase)) { + return "approval-needed"; + } + return null; +} + +function isNotificationKindEnabled( + kind: DesktopNotificationKind, + settings: ThreadNotificationSettings, +): boolean { + switch (kind) { + case "task-completed": + return settings.taskCompleted; + case "task-failed": + return settings.taskFailed; + case "approval-needed": + return settings.approvalNeeded; + } +} + +function truncateBody(value: string): string { + const trimmed = value.trim(); + if (trimmed.length <= MAX_BODY_LENGTH) { + return trimmed; + } + return `${trimmed.slice(0, MAX_BODY_LENGTH - 3).trimEnd()}...`; +} + +function sameThread(left: ScopedThreadRef, right: ScopedThreadRef): boolean { + return left.environmentId === right.environmentId && left.threadId === right.threadId; +} + +/** + * Reconciles the previously observed thread phases against the current shells + * and returns the notifications to raise plus the phase map to carry forward. + * + * Two invariants keep this quiet in the cases that matter: + * + * 1. A thread absent from `previous` is recorded and never fires. That single + * rule covers first mount, an environment connecting late, a cached snapshot + * hydrating, and a brand-new thread, with no bootstrap flag or timer. + * 2. `next` always advances, even when a notification is filtered out by the + * settings or the focus check. Suppressing the banner must not leave the + * transition pending, or it would fire later when the user navigates away or + * flips a toggle on. + */ +export function reconcileThreadNotifications( + input: ReconcileThreadNotificationsInput, +): ReconcileThreadNotificationsResult { + const next = new Map(); + const notifications: PendingThreadNotification[] = []; + + for (const thread of input.threads) { + // Archived threads are already dropped from the server snapshot; this keeps + // the invariant local so a projection change can't start raising banners + // for threads the user has filed away. + if (thread.archivedAt !== null) { + continue; + } + + const threadRef: ScopedThreadRef = { + environmentId: thread.environmentId, + threadId: thread.id, + }; + const key = threadNotificationKey(threadRef); + const projectTitle = + input.projectTitles.get( + projectTitleKey({ environmentId: thread.environmentId, projectId: thread.projectId }), + ) ?? ""; + const awareness = projectThreadAwareness({ + environmentId: thread.environmentId, + project: { title: projectTitle }, + thread, + }); + const phase = awareness?.phase ?? null; + + const seen = input.previous.has(key); + const previousPhase = seen ? (input.previous.get(key) ?? null) : null; + next.set(key, phase); + + if (!seen || previousPhase === phase) { + continue; + } + + const kind = notifiableKind(previousPhase, phase); + if (kind === null || awareness === null) { + continue; + } + + if (!input.settings.enabled || !isNotificationKindEnabled(kind, input.settings)) { + continue; + } + + // The user is already watching this exact thread, so the banner would tell + // them something they can see. Any other case (another thread, settings, + // another app entirely) still notifies. + const watchingThisThread = + input.windowFocused && + input.activeThreadRef !== null && + sameThread(input.activeThreadRef, threadRef); + if (watchingThisThread) { + continue; + } + + const detail = kind === "task-failed" ? awareness.detail : undefined; + const bodyParts = [detail ?? awareness.headline]; + if (projectTitle.length > 0) { + bodyParts.push(projectTitle); + } + + notifications.push({ + kind, + threadRef, + title: awareness.threadTitle, + body: truncateBody(bodyParts.join(" · ")), + }); + } + + return { notifications, next }; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 697eb607c4d8..99fe0add47ad 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' +import { Route as SettingsNotificationsRouteImport } from './routes/settings.notifications' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' @@ -68,6 +69,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ path: '/providers', getParentRoute: () => SettingsRoute, } as any) +const SettingsNotificationsRoute = SettingsNotificationsRouteImport.update({ + id: '/notifications', + path: '/notifications', + getParentRoute: () => SettingsRoute, +} as any) const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', @@ -140,6 +146,7 @@ export interface FileRoutesByFullPath { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/notifications': typeof SettingsNotificationsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -159,6 +166,7 @@ export interface FileRoutesByTo { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/notifications': typeof SettingsNotificationsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute @@ -181,6 +189,7 @@ export interface FileRoutesById { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/notifications': typeof SettingsNotificationsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute @@ -204,6 +213,7 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/notifications' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' @@ -223,6 +233,7 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/notifications' | '/settings/providers' | '/settings/source-control' | '/' @@ -244,6 +255,7 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/notifications' | '/settings/providers' | '/settings/source-control' | '/_chat/' @@ -319,6 +331,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } + '/settings/notifications': { + id: '/settings/notifications' + path: '/notifications' + fullPath: '/settings/notifications' + preLoaderRoute: typeof SettingsNotificationsRouteImport + parentRoute: typeof SettingsRoute + } '/settings/keybindings': { id: '/settings/keybindings' path: '/keybindings' @@ -422,6 +441,7 @@ interface SettingsRouteChildren { SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsNotificationsRoute: typeof SettingsNotificationsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute } @@ -433,6 +453,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, + SettingsNotificationsRoute: SettingsNotificationsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c4f65564efc7..478b9b83efdb 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -19,6 +19,7 @@ import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDi import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; +import { DesktopThreadNotificationsHost } from "../notifications/DesktopThreadNotifications"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; import { Button } from "../components/ui/button"; @@ -141,6 +142,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + {appShell} {/* Above the router: a theme draft is judged by walking the app, so the editor has to survive navigation away from settings. */} diff --git a/apps/web/src/routes/settings.notifications.tsx b/apps/web/src/routes/settings.notifications.tsx new file mode 100644 index 000000000000..3e355525eb2f --- /dev/null +++ b/apps/web/src/routes/settings.notifications.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { NotificationsSettingsPanel } from "../components/settings/NotificationsSettingsPanel"; + +function SettingsNotificationsRoute() { + return ; +} + +export const Route = createFileRoute("/settings/notifications")({ + component: SettingsNotificationsRoute, +}); diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 7bca31182379..e34aca5a195c 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -286,6 +286,10 @@ export function readThreadShells(): ReadonlyArray { return appAtomRegistry.get(environmentThreadShells.threadShellsAtom); } +export function readProjects(): ReadonlyArray { + return appAtomRegistry.get(environmentProjects.projectsAtom); +} + export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null { return ( appAtomRegistry diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 7f9fdb271d47..5d0846040966 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -90,7 +90,7 @@ import type { import { EnvironmentId } from "./baseSchemas.ts"; import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; -import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { ExecutionEnvironmentDescriptor, ScopedThreadRef } from "./environment.ts"; import type { ClientSettings } from "./settings.ts"; import type { SourceControlCloneRepositoryInput, @@ -167,6 +167,38 @@ export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); +/** + * Agent-task events that raise a native OS notification. Deliberately narrow: + * these are the moments a thread stops making progress without the user, so + * app-lifecycle noise (updates, connection changes) never lands here. + */ +export const DesktopNotificationKindSchema = Schema.Literals([ + "task-completed", + "task-failed", + "approval-needed", +]); +export type DesktopNotificationKind = typeof DesktopNotificationKindSchema.Type; + +/** + * Title and body are composed in the renderer and shipped as data: only it has + * the project title, thread title, and awareness headline. The main process + * never sees a phase and never formats copy. + */ +export const DesktopNotificationInputSchema = Schema.Struct({ + kind: DesktopNotificationKindSchema, + title: Schema.String, + body: Schema.String, + silent: Schema.Boolean, + threadRef: ScopedThreadRef, +}); +export type DesktopNotificationInput = typeof DesktopNotificationInputSchema.Type; + +/** Echoed back to the renderer when the user clicks a notification. */ +export const DesktopNotificationActivationSchema = Schema.Struct({ + threadRef: ScopedThreadRef, +}); +export type DesktopNotificationActivation = typeof DesktopNotificationActivationSchema.Type; + export interface DesktopAppBranding { baseName: string; stageLabel: DesktopAppStageLabel; @@ -1048,6 +1080,15 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; + /** + * Raises a native OS notification. Optional: older desktop builds lack it, + * and web builds have no bridge at all, so callers feature-check first. + */ + showNotification?: (input: DesktopNotificationInput) => Promise; + /** Fires when the user clicks a notification raised by `showNotification`. */ + onNotificationActivated?: ( + listener: (activation: DesktopNotificationActivation) => void, + ) => () => void; onMenuAction: (listener: (action: string) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 388205649c85..2375b150fba9 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -177,6 +177,20 @@ export const ClientSettingsSchema = Schema.Struct({ // old keys, so everyone, including prior beta opt-outs, resets to the new // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Native OS notifications for agent tasks. Desktop-only: the settings section + // is hidden without a desktop bridge, and the keys are inert there. Default + // on because the OS still owns the real consent prompt (macOS asks on the + // first notification), and the trigger engine seeds silently on first + // observation, so a launch never produces a backlog of notifications. + desktopNotificationsEnabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + ), + desktopNotifyTaskCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + desktopNotifyTaskFailed: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + desktopNotifyApprovalNeeded: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + ), + desktopNotificationSound: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -792,6 +806,11 @@ export const ClientSettingsPatch = Schema.Struct({ ), planModeEnabled: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), + desktopNotificationsEnabled: Schema.optionalKey(Schema.Boolean), + desktopNotifyTaskCompleted: Schema.optionalKey(Schema.Boolean), + desktopNotifyTaskFailed: Schema.optionalKey(Schema.Boolean), + desktopNotifyApprovalNeeded: Schema.optionalKey(Schema.Boolean), + desktopNotificationSound: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( From e20aabc0ee7389df7fd161edc72433ab8e26cc9d Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Tue, 11 Aug 2026 16:58:21 +0530 Subject: [PATCH 2/7] feat(desktop): sharpen notification copy and disable auto-updates Three fixes on top of the initial notification support. Notifications fired while a task was still running. A provider parks the session at "ready" between turns while subagents, workflows, or watch loops keep working, and the awareness helper reads that as finished. The sidebar already resolves this by preferring backgroundLiveness, so the notification path now does the same and holds those threads at running. Notification copy read as a chat message rather than a status. The title now names what happened ("Completed", "Failed", "Approval Required") and the body carries the start of the agent's actual response, flattened from markdown so no literal ** or backticks reach the banner. The project name is gone. Response text is read only when a thread's detail is already in memory: mounting the detail atom would open a websocket subscription per thread, so the body falls back to the thread title instead. Auto-updates are inverted to off by default. This fork has no update feed of its own, so every prompt pointed at an upstream release that lacks these changes and would overwrite them. Provider update checks default off for the same reason. Both remain overridable, which is what lets the updater suite keep exercising the real flow. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop/src/app/DesktopConfig.ts | 11 +- .../src/updates/DesktopUpdates.test.ts | 5 + .../DesktopThreadNotifications.tsx | 41 ++++++- .../desktopNotifications.logic.test.ts | 58 ++++++++- .../desktopNotifications.logic.ts | 62 +++++++--- .../notifications/notificationCopy.test.ts | 113 ++++++++++++++++++ .../web/src/notifications/notificationCopy.ts | 90 ++++++++++++++ packages/contracts/src/settings.ts | 7 +- 8 files changed, 361 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/notifications/notificationCopy.test.ts create mode 100644 apps/web/src/notifications/notificationCopy.ts diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index d157a4c6ba44..cf98127fc4ee 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -49,7 +49,16 @@ export const DesktopConfig = Config.all({ Config.withDefault(10_000), ), appImagePath: trimmedString("APPIMAGE"), - disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), + // Auto-updates are off in this fork. It is built locally and has no update + // feed of its own, so every prompt would point at an upstream release that + // lacks these changes and would overwrite them on install. Inverted from + // upstream's opt-in: absent env means disabled, and only an explicit + // T3CODE_DISABLE_AUTO_UPDATE=false turns the updater back on (which the + // updater's own tests rely on to exercise the real flow). + disableAutoUpdate: Config.boolean("T3CODE_DISABLE_AUTO_UPDATE").pipe( + Config.option, + Config.map(Option.getOrElse(() => true)), + ), mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), mockUpdateServerPort: Config.port("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe( Config.withDefault(3000), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 32224c7a5ca0..1bc0a7444418 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -147,6 +147,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, T3CODE_DESKTOP_MOCK_UPDATES: "true", T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", + // Auto-updates default to disabled in this fork, so the suite that + // exercises the updater has to opt back in explicitly. + T3CODE_DISABLE_AUTO_UPDATE: "false", ...options.env, }), ), @@ -181,6 +184,8 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, T3CODE_DESKTOP_MOCK_UPDATES: "true", T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", + // See above: this fork disables auto-updates by default. + T3CODE_DISABLE_AUTO_UPDATE: "false", ...options.env, }), ), diff --git a/apps/web/src/notifications/DesktopThreadNotifications.tsx b/apps/web/src/notifications/DesktopThreadNotifications.tsx index 783322ac9053..21627cca9e5c 100644 --- a/apps/web/src/notifications/DesktopThreadNotifications.tsx +++ b/apps/web/src/notifications/DesktopThreadNotifications.tsx @@ -1,13 +1,17 @@ import { useEffect, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; import type { ClientSettings } from "@t3tools/contracts/settings"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { + EnvironmentThread, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; import { isElectron } from "~/env"; import { getClientSettings } from "~/hooks/useSettings"; import { useActiveThreadRefFromRoute } from "~/hooks/useActiveThreadRef"; import { appAtomRegistry } from "~/rpc/atomRegistry"; -import { environmentThreadShells } from "~/state/threads"; +import { environmentThreadDetails, environmentThreadShells } from "~/state/threads"; import { readProjects, readThreadShells } from "~/state/entities"; import { buildProjectTitleMap, @@ -17,6 +21,38 @@ import { type ThreadPhaseSnapshot, } from "./desktopNotifications.logic"; +/** + * Latest assistant text for a thread, but only when that thread's detail atom + * is already live in the registry. + * + * Reading `detailAtom` through `registry.get` would *mount* it, and a thread + * detail atom is stream-backed: doing that for every thread in the sidebar + * would open a websocket subscription per thread purely to caption a + * notification. Peeking at existing nodes keeps this free, and the body falls + * back to the thread title whenever nothing is loaded. + */ +function readLoadedResponseText(ref: ScopedThreadRef): string | null { + const atom = environmentThreadDetails.detailAtom(ref); + const node = appAtomRegistry.getNodes().get(atom); + if (node === undefined || node.currentState() !== "valid") { + return null; + } + + const detail = node.value() as EnvironmentThread | null; + const messages = detail?.messages; + if (messages === undefined) { + return null; + } + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role === "assistant" && message.text.trim().length > 0) { + return message.text; + } + } + return null; +} + export function selectThreadNotificationSettings( settings: ClientSettings, ): ThreadNotificationSettings { @@ -53,6 +89,7 @@ function DesktopThreadNotifications() { settings: selectThreadNotificationSettings(settings), windowFocused: document.visibilityState === "visible" && document.hasFocus(), activeThreadRef: activeThreadRefMirror.current, + readResponseText: readLoadedResponseText, }); phases = next; diff --git a/apps/web/src/notifications/desktopNotifications.logic.test.ts b/apps/web/src/notifications/desktopNotifications.logic.test.ts index b76347046e23..0677a708149f 100644 --- a/apps/web/src/notifications/desktopNotifications.logic.test.ts +++ b/apps/web/src/notifications/desktopNotifications.logic.test.ts @@ -34,6 +34,7 @@ function makeThread( overrides: { readonly threadId?: ThreadId; readonly archivedAt?: string | null; + readonly backgroundLiveness?: "working" | "monitoring" | null; } = {}, ): EnvironmentThreadShell { const base = { @@ -44,6 +45,7 @@ function makeThread( modelSelection: { provider: "codex", model: "gpt-5" }, updatedAt: "2026-08-11T10:00:00.000Z", archivedAt: overrides.archivedAt ?? null, + backgroundLiveness: overrides.backgroundLiveness ?? null, hasPendingApprovals: phase === "approval", hasPendingUserInput: phase === "input", latestTurn: null as unknown, @@ -91,6 +93,7 @@ function reconcile( settings: ThreadNotificationSettings; windowFocused: boolean; activeThreadRef: { environmentId: EnvironmentId; threadId: ThreadId } | null; + readResponseText: () => string | null; }> = {}, ) { return reconcileThreadNotifications({ @@ -100,6 +103,7 @@ function reconcile( settings: overrides.settings ?? ALL_ENABLED, windowFocused: overrides.windowFocused ?? false, activeThreadRef: overrides.activeThreadRef ?? null, + ...(overrides.readResponseText ? { readResponseText: overrides.readResponseText } : {}), }); } @@ -148,14 +152,61 @@ describe("reconcileThreadNotifications", () => { expect(result.notifications).toHaveLength(1); expect(result.notifications[0]?.kind).toBe("task-completed"); - expect(result.notifications[0]?.title).toBe("Fix flaky auth test"); - expect(result.notifications[0]?.body).toContain("t3code"); + expect(result.notifications[0]?.title).toBe("Completed"); + // The project name is deliberately absent from the banner. + expect(result.notifications[0]?.body).not.toContain("t3code"); expect(result.notifications[0]?.threadRef).toEqual({ environmentId: ENVIRONMENT_ID, threadId: THREAD_ID, }); }); + it("uses the agent's response as the body, flattened to plain text", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const result = reconcile(seeded, [makeThread("completed")], { + readResponseText: () => + "## Done\n\nFixed the **flaky** `auth` test in [the suite](http://x).", + }); + + expect(result.notifications[0]?.body).toBe("Done Fixed the flaky auth test in the suite."); + }); + + it("falls back to the thread title when no response text is loaded", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const result = reconcile(seeded, [makeThread("completed")], { + readResponseText: () => null, + }); + + expect(result.notifications[0]?.body).toBe("Fix flaky auth test"); + }); + + it("titles each kind by what happened", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + + expect(reconcile(seeded, [makeThread("failed")]).notifications[0]?.title).toBe("Failed"); + expect(reconcile(seeded, [makeThread("approval")]).notifications[0]?.title).toBe( + "Approval Required", + ); + }); + + it("does not announce completion while background work is still live", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const working = reconcile(seeded, [makeThread("completed", { backgroundLiveness: "working" })]); + + expect(working.notifications).toEqual([]); + // Held at running, so the real completion still reads as a transition. + expect(working.next.get(KEY)).toBe("running"); + + const monitoring = reconcile(working.next, [ + makeThread("completed", { backgroundLiveness: "monitoring" }), + ]); + expect(monitoring.notifications).toEqual([]); + + const settled = reconcile(monitoring.next, [makeThread("completed")]); + expect(settled.notifications).toHaveLength(1); + expect(settled.notifications[0]?.kind).toBe("task-completed"); + }); + it("does not re-fire while the thread sits in completed", () => { let phases = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; let fired = 0; @@ -183,7 +234,8 @@ describe("reconcileThreadNotifications", () => { const result = reconcile(seeded, [makeThread("failed")]); expect(result.notifications[0]?.kind).toBe("task-failed"); - expect(result.notifications[0]?.body).toContain("Provider crashed"); + expect(result.notifications[0]?.title).toBe("Failed"); + expect(result.notifications[0]?.body).toBe("Provider crashed"); }); it("reports an approval prompt", () => { diff --git a/apps/web/src/notifications/desktopNotifications.logic.ts b/apps/web/src/notifications/desktopNotifications.logic.ts index 0b10d89b7955..c7d84f54b2cd 100644 --- a/apps/web/src/notifications/desktopNotifications.logic.ts +++ b/apps/web/src/notifications/desktopNotifications.logic.ts @@ -5,6 +5,8 @@ import type { } from "@t3tools/client-runtime/state/shell"; import { projectThreadAwareness, type AgentAwarenessPhase } from "@t3tools/shared/agentAwareness"; +import { notificationBody, notificationTitle } from "./notificationCopy"; + /** * Phases a thread must be leaving for its next phase to be worth announcing. * Requiring an active predecessor is what makes a thread that is merely @@ -25,9 +27,6 @@ const PRE_APPROVAL_PHASES: ReadonlySet = new Set([ "waiting_for_input", ]); -/** macOS truncates long bodies anyway, and this rides a hot IPC path. */ -const MAX_BODY_LENGTH = 180; - export interface ThreadNotificationSettings { readonly enabled: boolean; readonly taskCompleted: boolean; @@ -59,6 +58,12 @@ export interface ReconcileThreadNotificationsInput { readonly settings: ThreadNotificationSettings; readonly windowFocused: boolean; readonly activeThreadRef: ScopedThreadRef | null; + /** + * The agent's latest assistant text for a thread, when it is already in + * memory. Optional and allowed to return null: resolving it must never + * force a thread subscription just to build a notification body. + */ + readonly readResponseText?: (ref: ScopedThreadRef) => string | null; } export interface ReconcileThreadNotificationsResult { @@ -123,18 +128,33 @@ function isNotificationKindEnabled( } } -function truncateBody(value: string): string { - const trimmed = value.trim(); - if (trimmed.length <= MAX_BODY_LENGTH) { - return trimmed; - } - return `${trimmed.slice(0, MAX_BODY_LENGTH - 3).trimEnd()}...`; -} - function sameThread(left: ScopedThreadRef, right: ScopedThreadRef): boolean { return left.environmentId === right.environmentId && left.threadId === right.threadId; } +/** + * Holds a thread at "running" while native background work is still alive. + * + * `projectThreadAwareness` reads a session sitting at `ready`/`idle` with + * nothing pending as finished, which is right for a thread that has genuinely + * stopped. But a provider also parks the session at `ready` *between* turns + * while subagents, workflows, or watch loops keep going, so a long task + * momentarily looks complete and would fire a "Completed" notification while + * it is still working. The sidebar already resolves this the same way + * (`resolveSidebarThreadStatus` prefers `backgroundLiveness` over `ready`); + * this keeps the notification view consistent with what the user sees there. + */ +export function resolveNotifiablePhase( + thread: Pick, + phase: AgentAwarenessPhase | null, +): AgentAwarenessPhase | null { + if (phase !== "completed") { + return phase; + } + const liveness = thread.backgroundLiveness ?? null; + return liveness === "working" || liveness === "monitoring" ? "running" : phase; +} + /** * Reconciles the previously observed thread phases against the current shells * and returns the notifications to raise plus the phase map to carry forward. @@ -177,7 +197,7 @@ export function reconcileThreadNotifications( project: { title: projectTitle }, thread, }); - const phase = awareness?.phase ?? null; + const phase = resolveNotifiablePhase(thread, awareness?.phase ?? null); const seen = input.previous.has(key); const previousPhase = seen ? (input.previous.get(key) ?? null) : null; @@ -207,17 +227,21 @@ export function reconcileThreadNotifications( continue; } - const detail = kind === "task-failed" ? awareness.detail : undefined; - const bodyParts = [detail ?? awareness.headline]; - if (projectTitle.length > 0) { - bodyParts.push(projectTitle); - } + // A failure's error text is more useful than its last assistant message. + const responseText = + kind === "task-failed" + ? (awareness.detail ?? input.readResponseText?.(threadRef) ?? null) + : (input.readResponseText?.(threadRef) ?? null); notifications.push({ kind, threadRef, - title: awareness.threadTitle, - body: truncateBody(bodyParts.join(" · ")), + title: notificationTitle(kind), + body: notificationBody({ + responseText, + threadTitle: awareness.threadTitle, + fallbackHeadline: awareness.headline, + }), }); } diff --git a/apps/web/src/notifications/notificationCopy.test.ts b/apps/web/src/notifications/notificationCopy.test.ts new file mode 100644 index 000000000000..68ff625e0bca --- /dev/null +++ b/apps/web/src/notifications/notificationCopy.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + notificationBody, + notificationTitle, + toPlainNotificationText, + truncateNotificationBody, +} from "./notificationCopy"; + +describe("notificationTitle", () => { + it("names what happened", () => { + expect(notificationTitle("task-completed")).toBe("Completed"); + expect(notificationTitle("task-failed")).toBe("Failed"); + expect(notificationTitle("approval-needed")).toBe("Approval Required"); + }); +}); + +describe("toPlainNotificationText", () => { + it("strips emphasis without eating the words", () => { + expect(toPlainNotificationText("Fixed the **flaky** _auth_ test")).toBe( + "Fixed the flaky auth test", + ); + expect(toPlainNotificationText("__bold__ and ~~gone~~")).toBe("bold and gone"); + }); + + it("unwraps inline code and links", () => { + expect(toPlainNotificationText("Ran `pnpm test` in [the suite](https://example.com)")).toBe( + "Ran pnpm test in the suite", + ); + expect(toPlainNotificationText("![a diagram](img.png) follows")).toBe("a diagram follows"); + }); + + it("replaces fenced code blocks with a marker", () => { + expect(toPlainNotificationText("Before\n```ts\nconst x = 1;\n```\nAfter")).toBe( + "Before (code) After", + ); + }); + + it("removes headings, quotes, and list markers", () => { + expect(toPlainNotificationText("## Summary\n\n- one\n- two\n\n> quoted")).toBe( + "Summary one two quoted", + ); + expect(toPlainNotificationText("1. first\n2. second")).toBe("first second"); + expect(toPlainNotificationText("- [x] done\n- [ ] todo")).toBe("done todo"); + }); + + it("drops html and horizontal rules", () => { + expect(toPlainNotificationText("bold text")).toBe("bold text"); + expect(toPlainNotificationText("above\n\n---\n\nbelow")).toBe("above below"); + }); + + it("collapses newlines into a single flowing line", () => { + expect(toPlainNotificationText("one\n\n\ntwo three\n")).toBe("one two three"); + }); + + it("leaves plain prose untouched", () => { + expect(toPlainNotificationText("Just a normal sentence.")).toBe("Just a normal sentence."); + }); +}); + +describe("truncateNotificationBody", () => { + it("passes short text through", () => { + expect(truncateNotificationBody("short")).toBe("short"); + }); + + it("truncates long text with an ellipsis", () => { + const result = truncateNotificationBody("x".repeat(400)); + expect(result.length).toBeLessThanOrEqual(180); + expect(result.endsWith("…")).toBe(true); + }); +}); + +describe("notificationBody", () => { + it("prefers the response text", () => { + expect( + notificationBody({ + responseText: "## All set\n\nShipped it.", + threadTitle: "Fix auth", + fallbackHeadline: "Agent finished", + }), + ).toBe("All set Shipped it."); + }); + + it("falls back to the thread title when there is no response", () => { + expect( + notificationBody({ + responseText: null, + threadTitle: "Fix auth", + fallbackHeadline: "Agent finished", + }), + ).toBe("Fix auth"); + }); + + it("falls back when the response flattens to nothing", () => { + expect( + notificationBody({ + responseText: "```\n\n```", + threadTitle: " ", + fallbackHeadline: "Agent finished", + }), + ).toBe("(code)"); + }); + + it("uses the headline when both response and title are empty", () => { + expect( + notificationBody({ + responseText: " ", + threadTitle: " ", + fallbackHeadline: "Agent finished", + }), + ).toBe("Agent finished"); + }); +}); diff --git a/apps/web/src/notifications/notificationCopy.ts b/apps/web/src/notifications/notificationCopy.ts new file mode 100644 index 000000000000..6098f2eaf67b --- /dev/null +++ b/apps/web/src/notifications/notificationCopy.ts @@ -0,0 +1,90 @@ +import type { DesktopNotificationKind } from "@t3tools/contracts"; + +/** Notification titles. The kind leads, so the banner is scannable at a glance. */ +const NOTIFICATION_TITLES: Readonly> = { + "task-completed": "Completed", + "task-failed": "Failed", + "approval-needed": "Approval Required", +}; + +export function notificationTitle(kind: DesktopNotificationKind): string { + return NOTIFICATION_TITLES[kind]; +} + +/** macOS elides past roughly two lines; this also keeps the IPC payload small. */ +const MAX_BODY_LENGTH = 180; + +/** + * Flattens assistant markdown into the plain text a notification can render. + * + * Notification bodies are plain text: any markdown that survives shows up as + * literal `**` and backticks in the banner. This strips the syntax rather than + * the content, so the reader sees the sentence the agent wrote. + * + * Deliberately regex-based and not a full markdown parser: this runs on the + * notification path for a single leading excerpt, and pulling a parser in for + * it would cost far more than the fidelity is worth. + */ +export function toPlainNotificationText(markdown: string): string { + let text = markdown; + + // Fenced code blocks: keep a marker rather than dumping code into a banner. + text = text.replace(/```[\s\S]*?```/g, " (code) "); + text = text.replace(/~~~[\s\S]*?~~~/g, " (code) "); + + // Images before links: the image syntax is a link with a leading `!`. + text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1"); + text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1"); + + // Inline code, bold, italic, strikethrough. Emphasis runs last so `**x**` + // is not half-consumed by the single-character rules. + text = text.replace(/`([^`]+)`/g, "$1"); + text = text.replace(/~~([^~]+)~~/g, "$1"); + text = text.replace(/\*\*([^*]+)\*\*/g, "$1"); + text = text.replace(/__([^_]+)__/g, "$1"); + text = text.replace(/\*([^*]+)\*/g, "$1"); + text = text.replace(/(^|\s)_([^_]+)_(?=\s|$)/g, "$1$2"); + + // Block syntax at line starts: headings, quotes, list bullets, tasks. + text = text.replace(/^\s{0,3}#{1,6}\s+/gm, ""); + text = text.replace(/^\s{0,3}>\s?/gm, ""); + text = text.replace(/^\s{0,3}[-*+]\s+\[[ xX]\]\s+/gm, ""); + text = text.replace(/^\s{0,3}[-*+]\s+/gm, ""); + text = text.replace(/^\s{0,3}\d+\.\s+/gm, ""); + // Horizontal rules, once their characters can no longer read as emphasis. + text = text.replace(/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/gm, " "); + + // HTML comments, then any remaining tags. + text = text.replace(//g, " "); + text = text.replace(/<[^>]+>/g, ""); + + // Collapse all whitespace: a banner is one flowing line, so newlines from + // the original layout would otherwise leave ragged gaps. + return text.replace(/\s+/g, " ").trim(); +} + +export function truncateNotificationBody(value: string): string { + const trimmed = value.trim(); + if (trimmed.length <= MAX_BODY_LENGTH) { + return trimmed; + } + return `${trimmed.slice(0, MAX_BODY_LENGTH - 1).trimEnd()}…`; +} + +/** + * Body text for a notification: the start of what the agent actually said, + * falling back to the thread title when no response text is available (the + * thread was never opened this session, so its messages are not in memory). + */ +export function notificationBody(input: { + readonly responseText: string | null; + readonly threadTitle: string; + readonly fallbackHeadline: string; +}): string { + const plain = input.responseText === null ? "" : toPlainNotificationText(input.responseText); + if (plain.length > 0) { + return truncateNotificationBody(plain); + } + const title = input.threadTitle.trim(); + return truncateNotificationBody(title.length > 0 ? title : input.fallbackHeadline); +} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2375b150fba9..521023f82211 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -558,7 +558,12 @@ export const ServerSettings = Schema.Struct({ enableLegacyTokenStreaming: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), - enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // Off by default alongside the desktop auto-updater: the provider update + // banners are the other source of "update available" prompts, and leaving + // them on would keep nagging after auto-updates were turned off. + enableProviderUpdateChecks: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), backgroundActivity: BackgroundActivitySettings, // Legacy flat fields retained for old settings files and old clients. New // consumers should resolve `backgroundActivity` instead. From 45e6be3399c60329d39356361afaf3ab2d4504ad Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Tue, 11 Aug 2026 17:13:34 +0530 Subject: [PATCH 3/7] feat(web): make task alerts survive Do Not Disturb Notifications only worked when the OS agreed to show them. Under a Focus mode the banner and its sound are both suppressed, and Electron exposes no do-not-disturb state on any platform, so the app cannot even know it was silenced. A task could finish with the user learning nothing. Rather than detect Do Not Disturb, this adds two signals that do not depend on it: A chime, synthesised with WebAudio, plays whenever a task lands while the window is unfocused. It is the app's own sound, so a Focus mode does not mute it, and it carries to another Space or a full-screen app. Once per batch, not per notification: three threads finishing together is one event, and three overlapping chimes is a malfunction. A sidebar highlight marks the thread and stays until the user opens it: green for completed, red for failed, with a short splash on the row edge to draw the eye. This is what survives being missed entirely, so it is written before the banner is raised rather than after. A failure outranks an unseen completion on the same thread. Notification titles now name the project ("Completed - t3code"), which is what tells several concurrently running agents apart from the lock screen. Long project names shorten from the middle so both ends stay readable. The component is no longer gated on Electron: the chime and the highlights are plain web, and only the native banner needs a desktop bridge. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/Sidebar.tsx | 21 ++++ apps/web/src/index.css | 22 ++++ .../DesktopThreadNotifications.tsx | 65 ++++++++--- apps/web/src/notifications/alertSound.ts | 110 ++++++++++++++++++ .../desktopNotifications.logic.test.ts | 53 ++++++++- .../desktopNotifications.logic.ts | 15 ++- .../notifications/notificationCopy.test.ts | 20 ++++ .../web/src/notifications/notificationCopy.ts | 33 +++++- .../notifications/threadAlertStore.test.ts | 78 +++++++++++++ .../web/src/notifications/threadAlertStore.ts | 75 ++++++++++++ 10 files changed, 465 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/notifications/alertSound.ts create mode 100644 apps/web/src/notifications/threadAlertStore.test.ts create mode 100644 apps/web/src/notifications/threadAlertStore.ts diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 76d678cb35cc..dfd345642347 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -162,6 +162,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { useThreadAlert } from "~/notifications/threadAlertStore"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; @@ -758,6 +759,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // Same semantics as the legacy sidebar (never-visited counts as read): // switching sidebars must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); + // Set when a task finished or failed while this thread was not on screen, + // and cleared when the user opens it. + const threadAlert = useThreadAlert({ + environmentId: thread.environmentId, + threadId: thread.id, + }); const status = resolveSidebarThreadStatus(thread); // A woken thread reappears at its original position (the sort is // deliberately static), so the pill has to carry the weight. Snoozing is @@ -1036,6 +1043,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { !props.isActive && !isSelected && "opacity-70 transition-opacity hover:opacity-100", + // A thread that finished or failed while the user was away keeps a tint + // until they open it. This is the signal that survives Do Not Disturb, + // when the banner and the chime may both have been swallowed. + threadAlert === "completed" && "bg-success/10", + threadAlert === "failed" && "bg-error/10", ); const title = isRenaming ? ( @@ -1282,6 +1294,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> } > + {threadAlert === null ? null : ( + + )}
{ - const showNotification = window.desktopBridge?.showNotification; - if (typeof showNotification !== "function") { - return; - } - + // Resolved per batch, not once: the sound and the sidebar highlights work + // without a desktop bridge, so a missing `showNotification` must not + // disable them. let phases: ThreadPhaseSnapshot = EMPTY_THREAD_PHASE_SNAPSHOT; const reconcile = (threads: ReadonlyArray) => { const settings = getClientSettings(); - const { notifications, next } = reconcileThreadNotifications({ + const { notifications, playAlertSound, next } = reconcileThreadNotifications({ previous: phases, threads, projectTitles: buildProjectTitleMap(readProjects()), @@ -93,14 +92,32 @@ function DesktopThreadNotifications() { }); phases = next; + const showNotification = window.desktopBridge?.showNotification; for (const notification of notifications) { - void showNotification({ - kind: notification.kind, - title: notification.title, - body: notification.body, - silent: !settings.desktopNotificationSound, - threadRef: notification.threadRef, - }).catch(() => undefined); + // Mark the row before the banner: the highlight is what survives Do + // Not Disturb, so it must not depend on the banner succeeding. + if (notification.kind === "task-completed" || notification.kind === "task-failed") { + markThreadAlert( + notification.threadRef, + notification.kind === "task-failed" ? "failed" : "completed", + ); + } + + if (typeof showNotification === "function") { + void showNotification({ + kind: notification.kind, + title: notification.title, + body: notification.body, + silent: !settings.desktopNotificationSound, + threadRef: notification.threadRef, + }).catch(() => undefined); + } + } + + // Played by the app itself rather than by the OS notification, which a + // Focus mode would mute along with the banner. + if (playAlertSound && settings.desktopNotificationSound) { + playAlertChime(); } }; @@ -111,6 +128,14 @@ function DesktopThreadNotifications() { return appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, reconcile); }, []); + // Opening a thread is the user seeing it, so the highlight has done its job. + useEffect(() => { + if (activeThreadRef === null) { + return; + } + clearThreadAlert(activeThreadRef); + }, [activeThreadRef]); + useEffect(() => { const onNotificationActivated = window.desktopBridge?.onNotificationActivated; if (typeof onNotificationActivated !== "function") { @@ -136,10 +161,14 @@ function DesktopThreadNotifications() { } /** - * Native OS notifications for agent task transitions. Desktop-only: without a - * desktop bridge there is nothing to raise a notification with, so the whole - * subtree stays unmounted on web. + * Alerts for agent task transitions: the native OS banner on desktop, plus the + * chime and sidebar highlights that survive Do Not Disturb. + * + * Mounted everywhere, not just on desktop. The banner needs a desktop bridge + * and is skipped without one, but the chime and highlights are plain web and + * work in the browser too — gating the whole component on Electron would + * discard them for no reason. */ export function DesktopThreadNotificationsHost() { - return isElectron ? : null; + return ; } diff --git a/apps/web/src/notifications/alertSound.ts b/apps/web/src/notifications/alertSound.ts new file mode 100644 index 000000000000..5e7d2e51eef4 --- /dev/null +++ b/apps/web/src/notifications/alertSound.ts @@ -0,0 +1,110 @@ +/** + * The in-app alert chime. + * + * This exists because Do Not Disturb suppresses the OS notification *and* its + * sound, with no API to detect that it happened: Electron exposes no + * do-not-disturb state on any platform. Rather than guess, the app plays its + * own short tone whenever its window is unfocused, which is audible in a Focus + * mode, on another Space, or behind a full-screen app. When the banner does get + * through, the two land together and read as one alert. + * + * Synthesised with WebAudio instead of shipping an audio file: it is a two-note + * chime, and an asset would mean a bundled binary, a fetch, and a decode for + * something describable in a few oscillator settings. + */ + +/** A soft major sixth (A5 → F#6). Pleasant, and distinct from system alerts. */ +const CHIME_NOTES: ReadonlyArray<{ readonly frequency: number; readonly startOffset: number }> = [ + { frequency: 880, startOffset: 0 }, + { frequency: 1174.7, startOffset: 0.085 }, +]; + +const NOTE_DURATION_SECONDS = 0.34; +const PEAK_GAIN = 0.11; + +type AudioContextConstructor = new () => AudioContext; + +let sharedContext: AudioContext | null = null; + +function resolveAudioContextConstructor(): AudioContextConstructor | null { + if (typeof window === "undefined") { + return null; + } + const candidate = + window.AudioContext ?? + (window as unknown as { webkitAudioContext?: AudioContextConstructor }).webkitAudioContext; + return candidate ?? null; +} + +/** + * One context for the app's lifetime: browsers cap how many can exist, and + * creating one per chime leaks them. + */ +function getAudioContext(): AudioContext | null { + if (sharedContext !== null) { + return sharedContext; + } + const Constructor = resolveAudioContextConstructor(); + if (Constructor === null) { + return null; + } + try { + sharedContext = new Constructor(); + return sharedContext; + } catch { + return null; + } +} + +/** + * Plays the alert chime. Never throws and never rejects: audio is an + * enhancement, and a blocked or unavailable output must not disturb the + * notification path that calls it. + */ +export function playAlertChime(): void { + const context = getAudioContext(); + if (context === null) { + return; + } + + try { + // Autoplay policy can leave the context suspended until the page has been + // interacted with. Resuming is best-effort; the app has invariably been + // clicked long before an agent finishes a task. + if (context.state === "suspended") { + void context.resume().catch(() => undefined); + } + + const startedAt = context.currentTime; + for (const note of CHIME_NOTES) { + const oscillator = context.createOscillator(); + const gain = context.createGain(); + const noteStart = startedAt + note.startOffset; + const noteEnd = noteStart + NOTE_DURATION_SECONDS; + + // A sine avoids the harsh harmonics that make synthesised alerts grating. + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime(note.frequency, noteStart); + + // Quick attack, exponential decay. Ramping to a small non-zero value + // because exponentialRampToValueAtTime cannot reach exactly zero, then + // cutting to silence so the note ends cleanly instead of clicking. + gain.gain.setValueAtTime(0.0001, noteStart); + gain.gain.exponentialRampToValueAtTime(PEAK_GAIN, noteStart + 0.012); + gain.gain.exponentialRampToValueAtTime(0.0001, noteEnd); + gain.gain.setValueAtTime(0, noteEnd); + + oscillator.connect(gain); + gain.connect(context.destination); + oscillator.start(noteStart); + oscillator.stop(noteEnd + 0.02); + } + } catch { + // Output device removed mid-play, context closed by the platform, etc. + } +} + +/** Test-only: drops the shared context so specs start from a clean slate. */ +export function __resetAlertSoundForTests(): void { + sharedContext = null; +} diff --git a/apps/web/src/notifications/desktopNotifications.logic.test.ts b/apps/web/src/notifications/desktopNotifications.logic.test.ts index 0677a708149f..a9df49f6125a 100644 --- a/apps/web/src/notifications/desktopNotifications.logic.test.ts +++ b/apps/web/src/notifications/desktopNotifications.logic.test.ts @@ -152,8 +152,8 @@ describe("reconcileThreadNotifications", () => { expect(result.notifications).toHaveLength(1); expect(result.notifications[0]?.kind).toBe("task-completed"); - expect(result.notifications[0]?.title).toBe("Completed"); - // The project name is deliberately absent from the banner. + // The project belongs in the title, so the body stays the agent's words. + expect(result.notifications[0]?.title).toBe("Completed - t3code"); expect(result.notifications[0]?.body).not.toContain("t3code"); expect(result.notifications[0]?.threadRef).toEqual({ environmentId: ENVIRONMENT_ID, @@ -183,12 +183,55 @@ describe("reconcileThreadNotifications", () => { it("titles each kind by what happened", () => { const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; - expect(reconcile(seeded, [makeThread("failed")]).notifications[0]?.title).toBe("Failed"); + expect(reconcile(seeded, [makeThread("failed")]).notifications[0]?.title).toBe( + "Failed - t3code", + ); expect(reconcile(seeded, [makeThread("approval")]).notifications[0]?.title).toBe( - "Approval Required", + "Approval Required - t3code", ); }); + describe("alert sound", () => { + it("asks for one chime per batch, not one per notification", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [ + makeThread("running"), + makeThread("running", { threadId: "thread-2" as ThreadId }), + ]).next; + const result = reconcile(seeded, [ + makeThread("completed"), + makeThread("completed", { threadId: "thread-2" as ThreadId }), + ]); + + expect(result.notifications).toHaveLength(2); + expect(result.playAlertSound).toBe(true); + }); + + it("stays silent when the window is focused", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const result = reconcile(seeded, [makeThread("completed")], { windowFocused: true }); + + // Still notified (a different thread is on screen), but the chime is for + // reaching someone who is looking elsewhere. + expect(result.notifications).toHaveLength(1); + expect(result.playAlertSound).toBe(false); + }); + + it("stays silent when nothing happened", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + + expect(reconcile(seeded, [makeThread("running")]).playAlertSound).toBe(false); + }); + + it("stays silent when notifications are switched off", () => { + const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; + const result = reconcile(seeded, [makeThread("completed")], { + settings: { ...ALL_ENABLED, enabled: false }, + }); + + expect(result.playAlertSound).toBe(false); + }); + }); + it("does not announce completion while background work is still live", () => { const seeded = reconcile(EMPTY_THREAD_PHASE_SNAPSHOT, [makeThread("running")]).next; const working = reconcile(seeded, [makeThread("completed", { backgroundLiveness: "working" })]); @@ -234,7 +277,7 @@ describe("reconcileThreadNotifications", () => { const result = reconcile(seeded, [makeThread("failed")]); expect(result.notifications[0]?.kind).toBe("task-failed"); - expect(result.notifications[0]?.title).toBe("Failed"); + expect(result.notifications[0]?.title).toBe("Failed - t3code"); expect(result.notifications[0]?.body).toBe("Provider crashed"); }); diff --git a/apps/web/src/notifications/desktopNotifications.logic.ts b/apps/web/src/notifications/desktopNotifications.logic.ts index c7d84f54b2cd..52254d8d3a55 100644 --- a/apps/web/src/notifications/desktopNotifications.logic.ts +++ b/apps/web/src/notifications/desktopNotifications.logic.ts @@ -68,6 +68,15 @@ export interface ReconcileThreadNotificationsInput { export interface ReconcileThreadNotificationsResult { readonly notifications: ReadonlyArray; + /** + * Whether to play the in-app alert chime for this batch. + * + * Once per batch, not once per notification: three threads finishing + * together is one event to the user, and three overlapping chimes is a + * malfunction. Only set while the window is unfocused — the sound exists to + * reach someone who is looking elsewhere. + */ + readonly playAlertSound: boolean; readonly next: ThreadPhaseSnapshot; } @@ -236,7 +245,7 @@ export function reconcileThreadNotifications( notifications.push({ kind, threadRef, - title: notificationTitle(kind), + title: notificationTitle(kind, projectTitle), body: notificationBody({ responseText, threadTitle: awareness.threadTitle, @@ -245,5 +254,7 @@ export function reconcileThreadNotifications( }); } - return { notifications, next }; + // The chime is the one signal Do Not Disturb cannot swallow, so it is tied + // to the window being unfocused rather than to the banner being shown. + return { notifications, playAlertSound: notifications.length > 0 && !input.windowFocused, next }; } diff --git a/apps/web/src/notifications/notificationCopy.test.ts b/apps/web/src/notifications/notificationCopy.test.ts index 68ff625e0bca..a7b996f1be6a 100644 --- a/apps/web/src/notifications/notificationCopy.test.ts +++ b/apps/web/src/notifications/notificationCopy.test.ts @@ -13,6 +13,26 @@ describe("notificationTitle", () => { expect(notificationTitle("task-failed")).toBe("Failed"); expect(notificationTitle("approval-needed")).toBe("Approval Required"); }); + + it("appends the project so several running agents stay distinguishable", () => { + expect(notificationTitle("task-completed", "t3code")).toBe("Completed - t3code"); + expect(notificationTitle("task-failed", "api")).toBe("Failed - api"); + expect(notificationTitle("approval-needed", "web")).toBe("Approval Required - web"); + }); + + it("omits an absent or blank project rather than leaving a dangling separator", () => { + expect(notificationTitle("task-completed", null)).toBe("Completed"); + expect(notificationTitle("task-completed", " ")).toBe("Completed"); + expect(notificationTitle("task-completed", undefined)).toBe("Completed"); + }); + + it("shortens a long project name from the middle so both ends survive", () => { + const title = notificationTitle("task-completed", "internal-platform-billing-service-monorepo"); + + expect(title.startsWith("Completed - internal")).toBe(true); + expect(title.endsWith("-monorepo")).toBe(true); + expect(title).toContain("…"); + }); }); describe("toPlainNotificationText", () => { diff --git a/apps/web/src/notifications/notificationCopy.ts b/apps/web/src/notifications/notificationCopy.ts index 6098f2eaf67b..16772b68fb11 100644 --- a/apps/web/src/notifications/notificationCopy.ts +++ b/apps/web/src/notifications/notificationCopy.ts @@ -7,8 +7,37 @@ const NOTIFICATION_TITLES: Readonly> = { "approval-needed": "Approval Required", }; -export function notificationTitle(kind: DesktopNotificationKind): string { - return NOTIFICATION_TITLES[kind]; +/** + * A title long enough to be elided by the OS defeats the point, and the + * project name is the part that gets cut. Long project names are shortened + * from the middle so both ends stay recognisable. + */ +const MAX_TITLE_PROJECT_LENGTH = 28; + +function shortenProjectName(projectTitle: string): string { + const trimmed = projectTitle.trim(); + if (trimmed.length <= MAX_TITLE_PROJECT_LENGTH) { + return trimmed; + } + const head = trimmed.slice(0, MAX_TITLE_PROJECT_LENGTH - 12).trimEnd(); + const tail = trimmed.slice(-9).trimStart(); + return `${head}…${tail}`; +} + +/** + * Notification title: what happened, then which project it happened in. + * + * The project name answers "which of my running agents is this?" without + * opening the app, which matters most when several are working at once. It is + * omitted rather than left dangling when the project title is unavailable. + */ +export function notificationTitle( + kind: DesktopNotificationKind, + projectTitle?: string | null, +): string { + const kindLabel = NOTIFICATION_TITLES[kind]; + const project = projectTitle === null || projectTitle === undefined ? "" : projectTitle.trim(); + return project.length > 0 ? `${kindLabel} - ${shortenProjectName(project)}` : kindLabel; } /** macOS elides past roughly two lines; this also keeps the IPC payload small. */ diff --git a/apps/web/src/notifications/threadAlertStore.test.ts b/apps/web/src/notifications/threadAlertStore.test.ts new file mode 100644 index 000000000000..e3077813350b --- /dev/null +++ b/apps/web/src/notifications/threadAlertStore.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { + __resetThreadAlertsForTests, + clearThreadAlert, + markThreadAlert, + readThreadAlert, +} from "./threadAlertStore"; + +const THREAD_A = { + environmentId: "env-1" as EnvironmentId, + threadId: "thread-1" as ThreadId, +}; +const THREAD_B = { + environmentId: "env-1" as EnvironmentId, + threadId: "thread-2" as ThreadId, +}; +/** Same thread id, different environment: the two must not collide. */ +const THREAD_A_OTHER_ENV = { + environmentId: "env-2" as EnvironmentId, + threadId: "thread-1" as ThreadId, +}; + +describe("threadAlertStore", () => { + afterEach(() => { + __resetThreadAlertsForTests(); + }); + + it("has no highlight until something happens", () => { + expect(readThreadAlert(THREAD_A)).toBeNull(); + }); + + it("marks and clears a thread", () => { + markThreadAlert(THREAD_A, "completed"); + expect(readThreadAlert(THREAD_A)).toBe("completed"); + + clearThreadAlert(THREAD_A); + expect(readThreadAlert(THREAD_A)).toBeNull(); + }); + + it("keeps threads independent, including across environments", () => { + markThreadAlert(THREAD_A, "completed"); + markThreadAlert(THREAD_B, "failed"); + markThreadAlert(THREAD_A_OTHER_ENV, "failed"); + + expect(readThreadAlert(THREAD_A)).toBe("completed"); + expect(readThreadAlert(THREAD_B)).toBe("failed"); + expect(readThreadAlert(THREAD_A_OTHER_ENV)).toBe("failed"); + + clearThreadAlert(THREAD_A); + expect(readThreadAlert(THREAD_B)).toBe("failed"); + expect(readThreadAlert(THREAD_A_OTHER_ENV)).toBe("failed"); + }); + + it("lets a failure outrank a completion the user has not seen yet", () => { + markThreadAlert(THREAD_A, "completed"); + markThreadAlert(THREAD_A, "failed"); + expect(readThreadAlert(THREAD_A)).toBe("failed"); + + // And does not let a later completion mask that failure. + markThreadAlert(THREAD_A, "completed"); + expect(readThreadAlert(THREAD_A)).toBe("failed"); + }); + + it("re-marks after the user has seen and cleared it", () => { + markThreadAlert(THREAD_A, "failed"); + clearThreadAlert(THREAD_A); + markThreadAlert(THREAD_A, "completed"); + + expect(readThreadAlert(THREAD_A)).toBe("completed"); + }); + + it("ignores clearing a thread that was never marked", () => { + expect(() => clearThreadAlert(THREAD_A)).not.toThrow(); + expect(readThreadAlert(THREAD_A)).toBeNull(); + }); +}); diff --git a/apps/web/src/notifications/threadAlertStore.ts b/apps/web/src/notifications/threadAlertStore.ts new file mode 100644 index 000000000000..a6eb5f0b484c --- /dev/null +++ b/apps/web/src/notifications/threadAlertStore.ts @@ -0,0 +1,75 @@ +/** + * Sidebar highlights for threads that changed while the user was away. + * + * Do Not Disturb can swallow the notification banner entirely, and Electron + * exposes no way to know whether that happened. So the alert has to survive + * being missed: when a thread completes or fails without the user watching it, + * its sidebar row is marked, and the mark stays until they actually open the + * thread. Coming back from another app, another Space, or a Focus mode, the + * sidebar itself says what happened and where — even if nothing was ever + * shown or heard. + * + * State is deliberately in-memory. A highlight answers "what happened while I + * was away just now"; restoring week-old marks on launch would be noise. + */ +import { useAtomValue } from "@effect/atom-react"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { appAtomRegistry } from "~/rpc/atomRegistry"; + +/** Green for a finished task, red for a failed one. */ +export type ThreadAlertKind = "completed" | "failed"; + +const EMPTY_ALERTS: Readonly> = Object.freeze({}); + +const threadAlertsAtom = Atom.make>>(EMPTY_ALERTS).pipe( + Atom.keepAlive, + Atom.withLabel("web-thread-alerts"), +); + +export function markThreadAlert(ref: ScopedThreadRef, kind: ThreadAlertKind): void { + const key = scopedThreadKey(ref); + const current = appAtomRegistry.get(threadAlertsAtom); + // A failure outranks a completion for the same thread: if both land before + // the user looks, the failure is the one they need to see. + if (current[key] === kind || (current[key] === "failed" && kind === "completed")) { + return; + } + appAtomRegistry.set(threadAlertsAtom, { ...current, [key]: kind }); +} + +export function clearThreadAlert(ref: ScopedThreadRef): void { + const key = scopedThreadKey(ref); + const current = appAtomRegistry.get(threadAlertsAtom); + if (current[key] === undefined) { + return; + } + const { [key]: _cleared, ...rest } = current; + appAtomRegistry.set(threadAlertsAtom, rest); +} + +export function readThreadAlert(ref: ScopedThreadRef): ThreadAlertKind | null { + return appAtomRegistry.get(threadAlertsAtom)[scopedThreadKey(ref)] ?? null; +} + +/** + * The highlight for one thread row. Reads the whole map rather than a + * per-thread atom family: the map is small (only unseen alerts), and a family + * would allocate an atom per thread in the sidebar for a value that is + * almost always absent. + */ +export function useThreadAlert(ref: ScopedThreadRef | null): ThreadAlertKind | null { + const alerts = useAtomValue(threadAlertsAtom); + return ref === null ? null : (alerts[scopedThreadKey(ref)] ?? null); +} + +export function useHasThreadAlerts(): boolean { + return Object.keys(useAtomValue(threadAlertsAtom)).length > 0; +} + +/** Test-only reset so specs do not leak highlights into each other. */ +export function __resetThreadAlertsForTests(): void { + appAtomRegistry.set(threadAlertsAtom, EMPTY_ALERTS); +} From 25b818f1a831b01b78c5744d53127eb17c0f1294 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Tue, 11 Aug 2026 17:30:53 +0530 Subject: [PATCH 4/7] fix(web): bound how long a thread alert splash stays on screen The sidebar highlight had no time limit: it stayed until the thread was opened. Coming back to the app with several finished threads left rows pulsing indefinitely, which reads as a fault rather than a signal. Highlights now clear on two bounds. Three seconds after the window has focus, since focus is the moment the signal has done its job. And five seconds from when it was raised regardless, which covers an alert that lands while the window is already focused and has no later focus event to start the first countdown. The focused countdown starts once and is not extended by regaining focus, so clicking around or switching Spaces cannot keep a highlight alive. A single timer drives every alert, scheduled to the nearest deadline and only while something is highlighted. The splash animation is now a steady one-second cycle rather than three fixed passes: its old ~4.8s run would have outlived the three-second focused window and stopped mid-fade. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/index.css | 8 +- .../DesktopThreadNotifications.tsx | 83 ++++++++++- .../notifications/threadAlertStore.test.ts | 100 +++++++++++-- .../web/src/notifications/threadAlertStore.ts | 131 +++++++++++++++++- 4 files changed, 299 insertions(+), 23 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 1e65b6616437..1c7f7146fd58 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -148,10 +148,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --animate-ghost-pulse: ghost-pulse 2.4s infinite; --animate-status-ping: status-ping 2s infinite; /* Thread alert splash: a sidebar row lighting up for a task that finished - while the user was away. Runs three times and stops — this marks a thread - worth a look, and a permanent animation in a list reads as an error. The - lasting signal is the tint the row keeps underneath. */ - --animate-thread-alert-splash: thread-alert-splash 1.6s ease-out 3; + while the user was away. The highlight is removed after at most five + seconds (three once the window has focus), so the cycle is sized to pulse + steadily for that window rather than to end on its own. */ + --animate-thread-alert-splash: thread-alert-splash 1s ease-out infinite; --color-warning-foreground: var(--warning-foreground); --color-warning: var(--warning); --color-warning-surface: var(--warning-surface); diff --git a/apps/web/src/notifications/DesktopThreadNotifications.tsx b/apps/web/src/notifications/DesktopThreadNotifications.tsx index 44e3bcb7d099..f6d4d3c53897 100644 --- a/apps/web/src/notifications/DesktopThreadNotifications.tsx +++ b/apps/web/src/notifications/DesktopThreadNotifications.tsx @@ -20,7 +20,16 @@ import { type ThreadPhaseSnapshot, } from "./desktopNotifications.logic"; import { playAlertChime } from "./alertSound"; -import { clearThreadAlert, markThreadAlert } from "./threadAlertStore"; +import { + clearThreadAlert, + markThreadAlert, + markThreadAlertsFocused, + pruneExpiredThreadAlerts, + readThreadAlerts, + subscribeThreadAlerts, + THREAD_ALERT_FOCUSED_TTL_MS, + THREAD_ALERT_MAX_TTL_MS, +} from "./threadAlertStore"; /** * Latest assistant text for a thread, but only when that thread's detail atom @@ -81,17 +90,19 @@ function DesktopThreadNotifications() { const reconcile = (threads: ReadonlyArray) => { const settings = getClientSettings(); + const windowFocused = document.visibilityState === "visible" && document.hasFocus(); const { notifications, playAlertSound, next } = reconcileThreadNotifications({ previous: phases, threads, projectTitles: buildProjectTitleMap(readProjects()), settings: selectThreadNotificationSettings(settings), - windowFocused: document.visibilityState === "visible" && document.hasFocus(), + windowFocused, activeThreadRef: activeThreadRefMirror.current, readResponseText: readLoadedResponseText, }); phases = next; + const nowMs = Date.now(); const showNotification = window.desktopBridge?.showNotification; for (const notification of notifications) { // Mark the row before the banner: the highlight is what survives Do @@ -100,6 +111,7 @@ function DesktopThreadNotifications() { markThreadAlert( notification.threadRef, notification.kind === "task-failed" ? "failed" : "completed", + { nowMs, windowFocused }, ); } @@ -136,6 +148,73 @@ function DesktopThreadNotifications() { clearThreadAlert(activeThreadRef); }, [activeThreadRef]); + // Highlights are bounded twice over: they fade shortly after the window has + // focus (the user is looking, so the signal has landed), and in any case + // never outlive the hard ceiling. A single timer drives both, rather than + // one per alert, and only runs while something is actually highlighted. + useEffect(() => { + const isFocused = () => document.visibilityState === "visible" && document.hasFocus(); + + let timeoutId: number | null = null; + + const cancelPending = () => { + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + timeoutId = null; + } + }; + + const scheduleNextPrune = () => { + cancelPending(); + const alerts = readThreadAlerts(); + const deadlines: number[] = []; + const nowMs = Date.now(); + + for (const alert of Object.values(alerts)) { + deadlines.push(alert.markedAtMs + THREAD_ALERT_MAX_TTL_MS); + if (alert.focusedAtMs !== null) { + deadlines.push(alert.focusedAtMs + THREAD_ALERT_FOCUSED_TTL_MS); + } + } + if (deadlines.length === 0) { + return; + } + + const nextDeadline = Math.min(...deadlines); + timeoutId = window.setTimeout( + () => { + timeoutId = null; + pruneExpiredThreadAlerts(Date.now()); + scheduleNextPrune(); + }, + Math.max(0, nextDeadline - nowMs), + ); + }; + + const handleFocusChange = () => { + if (isFocused()) { + markThreadAlertsFocused(Date.now()); + } + pruneExpiredThreadAlerts(Date.now()); + scheduleNextPrune(); + }; + + window.addEventListener("focus", handleFocusChange); + window.addEventListener("blur", scheduleNextPrune); + document.addEventListener("visibilitychange", handleFocusChange); + // New alerts arrive without a focus event, so reschedule when the set changes. + const unsubscribe = subscribeThreadAlerts(handleFocusChange); + handleFocusChange(); + + return () => { + cancelPending(); + unsubscribe(); + window.removeEventListener("focus", handleFocusChange); + window.removeEventListener("blur", scheduleNextPrune); + document.removeEventListener("visibilitychange", handleFocusChange); + }; + }, []); + useEffect(() => { const onNotificationActivated = window.desktopBridge?.onNotificationActivated; if (typeof onNotificationActivated !== "function") { diff --git a/apps/web/src/notifications/threadAlertStore.test.ts b/apps/web/src/notifications/threadAlertStore.test.ts index e3077813350b..901a8c999809 100644 --- a/apps/web/src/notifications/threadAlertStore.test.ts +++ b/apps/web/src/notifications/threadAlertStore.test.ts @@ -4,8 +4,14 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { __resetThreadAlertsForTests, clearThreadAlert, + isThreadAlertExpired, markThreadAlert, + markThreadAlertsFocused, + pruneExpiredThreadAlerts, readThreadAlert, + readThreadAlerts, + THREAD_ALERT_FOCUSED_TTL_MS, + THREAD_ALERT_MAX_TTL_MS, } from "./threadAlertStore"; const THREAD_A = { @@ -22,6 +28,9 @@ const THREAD_A_OTHER_ENV = { threadId: "thread-1" as ThreadId, }; +const T0 = 1_000_000; +const UNFOCUSED = { nowMs: T0, windowFocused: false } as const; + describe("threadAlertStore", () => { afterEach(() => { __resetThreadAlertsForTests(); @@ -32,7 +41,7 @@ describe("threadAlertStore", () => { }); it("marks and clears a thread", () => { - markThreadAlert(THREAD_A, "completed"); + markThreadAlert(THREAD_A, "completed", UNFOCUSED); expect(readThreadAlert(THREAD_A)).toBe("completed"); clearThreadAlert(THREAD_A); @@ -40,9 +49,9 @@ describe("threadAlertStore", () => { }); it("keeps threads independent, including across environments", () => { - markThreadAlert(THREAD_A, "completed"); - markThreadAlert(THREAD_B, "failed"); - markThreadAlert(THREAD_A_OTHER_ENV, "failed"); + markThreadAlert(THREAD_A, "completed", UNFOCUSED); + markThreadAlert(THREAD_B, "failed", UNFOCUSED); + markThreadAlert(THREAD_A_OTHER_ENV, "failed", UNFOCUSED); expect(readThreadAlert(THREAD_A)).toBe("completed"); expect(readThreadAlert(THREAD_B)).toBe("failed"); @@ -54,19 +63,18 @@ describe("threadAlertStore", () => { }); it("lets a failure outrank a completion the user has not seen yet", () => { - markThreadAlert(THREAD_A, "completed"); - markThreadAlert(THREAD_A, "failed"); + markThreadAlert(THREAD_A, "completed", UNFOCUSED); + markThreadAlert(THREAD_A, "failed", UNFOCUSED); expect(readThreadAlert(THREAD_A)).toBe("failed"); - // And does not let a later completion mask that failure. - markThreadAlert(THREAD_A, "completed"); + markThreadAlert(THREAD_A, "completed", UNFOCUSED); expect(readThreadAlert(THREAD_A)).toBe("failed"); }); it("re-marks after the user has seen and cleared it", () => { - markThreadAlert(THREAD_A, "failed"); + markThreadAlert(THREAD_A, "failed", UNFOCUSED); clearThreadAlert(THREAD_A); - markThreadAlert(THREAD_A, "completed"); + markThreadAlert(THREAD_A, "completed", UNFOCUSED); expect(readThreadAlert(THREAD_A)).toBe("completed"); }); @@ -75,4 +83,76 @@ describe("threadAlertStore", () => { expect(() => clearThreadAlert(THREAD_A)).not.toThrow(); expect(readThreadAlert(THREAD_A)).toBeNull(); }); + + describe("expiry", () => { + it("holds indefinitely while the window has never been focused", () => { + const alert = { kind: "completed" as const, markedAtMs: T0, focusedAtMs: null }; + + expect(isThreadAlertExpired(alert, T0 + THREAD_ALERT_MAX_TTL_MS - 1)).toBe(false); + }); + + it("expires at the hard ceiling even without focus", () => { + const alert = { kind: "completed" as const, markedAtMs: T0, focusedAtMs: null }; + + expect(isThreadAlertExpired(alert, T0 + THREAD_ALERT_MAX_TTL_MS)).toBe(true); + }); + + it("expires shortly after the window gains focus", () => { + const alert = { kind: "completed" as const, markedAtMs: T0, focusedAtMs: T0 + 500 }; + + expect(isThreadAlertExpired(alert, T0 + 500 + THREAD_ALERT_FOCUSED_TTL_MS - 1)).toBe(false); + expect(isThreadAlertExpired(alert, T0 + 500 + THREAD_ALERT_FOCUSED_TTL_MS)).toBe(true); + }); + + it("starts the focused countdown immediately when marked while focused", () => { + markThreadAlert(THREAD_A, "completed", { nowMs: T0, windowFocused: true }); + + expect( + readThreadAlerts()[`${THREAD_A.environmentId}:${THREAD_A.threadId}`]?.focusedAtMs, + ).toBe(T0); + }); + + it("starts the countdown when focus arrives later", () => { + markThreadAlert(THREAD_A, "completed", UNFOCUSED); + markThreadAlertsFocused(T0 + 1_000); + + const key = `${THREAD_A.environmentId}:${THREAD_A.threadId}`; + expect(readThreadAlerts()[key]?.focusedAtMs).toBe(T0 + 1_000); + }); + + it("does not push the deadline out when focus is regained repeatedly", () => { + markThreadAlert(THREAD_A, "completed", UNFOCUSED); + markThreadAlertsFocused(T0 + 1_000); + // Clicking around, re-focusing the window, switching Spaces back and forth. + markThreadAlertsFocused(T0 + 1_500); + markThreadAlertsFocused(T0 + 2_000); + + const key = `${THREAD_A.environmentId}:${THREAD_A.threadId}`; + expect(readThreadAlerts()[key]?.focusedAtMs).toBe(T0 + 1_000); + }); + + it("prunes only the alerts that are actually expired", () => { + markThreadAlert(THREAD_A, "completed", { nowMs: T0, windowFocused: true }); + markThreadAlert(THREAD_B, "failed", { nowMs: T0 + 2_500, windowFocused: true }); + + pruneExpiredThreadAlerts(T0 + THREAD_ALERT_FOCUSED_TTL_MS); + + expect(readThreadAlert(THREAD_A)).toBeNull(); + expect(readThreadAlert(THREAD_B)).toBe("failed"); + }); + + it("never lets a highlight outlive the ceiling, focused or not", () => { + markThreadAlert(THREAD_A, "completed", UNFOCUSED); + + pruneExpiredThreadAlerts(T0 + THREAD_ALERT_MAX_TTL_MS); + expect(readThreadAlert(THREAD_A)).toBeNull(); + }); + + it("leaves everything alone when nothing has expired", () => { + markThreadAlert(THREAD_A, "completed", UNFOCUSED); + + pruneExpiredThreadAlerts(T0 + 100); + expect(readThreadAlert(THREAD_A)).toBe("completed"); + }); + }); }); diff --git a/apps/web/src/notifications/threadAlertStore.ts b/apps/web/src/notifications/threadAlertStore.ts index a6eb5f0b484c..67d0f788b0b9 100644 --- a/apps/web/src/notifications/threadAlertStore.ts +++ b/apps/web/src/notifications/threadAlertStore.ts @@ -22,22 +22,130 @@ import { appAtomRegistry } from "~/rpc/atomRegistry"; /** Green for a finished task, red for a failed one. */ export type ThreadAlertKind = "completed" | "failed"; -const EMPTY_ALERTS: Readonly> = Object.freeze({}); +/** + * How long a highlight survives once the user is actually looking at the app. + * Focus is the moment the signal has done its job, so it fades shortly after + * rather than lingering as decoration. + */ +export const THREAD_ALERT_FOCUSED_TTL_MS = 3_000; + +/** + * Hard ceiling from when the alert was raised, regardless of focus. Bounds the + * case where the window was already focused (the user was in another Space, or + * simply looking away) so nothing can pulse indefinitely. + */ +export const THREAD_ALERT_MAX_TTL_MS = 5_000; -const threadAlertsAtom = Atom.make>>(EMPTY_ALERTS).pipe( +export interface ThreadAlert { + readonly kind: ThreadAlertKind; + /** When the alert was raised. */ + readonly markedAtMs: number; + /** When the window first had focus while this alert was live, if it has. */ + readonly focusedAtMs: number | null; +} + +const EMPTY_ALERTS: Readonly> = Object.freeze({}); + +const threadAlertsAtom = Atom.make>>(EMPTY_ALERTS).pipe( Atom.keepAlive, Atom.withLabel("web-thread-alerts"), ); -export function markThreadAlert(ref: ScopedThreadRef, kind: ThreadAlertKind): void { +/** + * Whether an alert has outlived both of its bounds. + * + * Pure and time-injected so the expiry rules are testable without waiting on + * real clocks. + */ +export function isThreadAlertExpired(alert: ThreadAlert, nowMs: number): boolean { + if (nowMs - alert.markedAtMs >= THREAD_ALERT_MAX_TTL_MS) { + return true; + } + return alert.focusedAtMs !== null && nowMs - alert.focusedAtMs >= THREAD_ALERT_FOCUSED_TTL_MS; +} + +export function markThreadAlert( + ref: ScopedThreadRef, + kind: ThreadAlertKind, + options: { readonly nowMs: number; readonly windowFocused: boolean }, +): void { const key = scopedThreadKey(ref); const current = appAtomRegistry.get(threadAlertsAtom); + const existing = current[key]; // A failure outranks a completion for the same thread: if both land before // the user looks, the failure is the one they need to see. - if (current[key] === kind || (current[key] === "failed" && kind === "completed")) { + if (existing !== undefined && existing.kind === "failed" && kind === "completed") { + return; + } + appAtomRegistry.set(threadAlertsAtom, { + ...current, + [key]: { + kind, + markedAtMs: options.nowMs, + // An alert raised while the window is already focused starts its focused + // countdown immediately; there is no later focus event to start it. + focusedAtMs: options.windowFocused ? options.nowMs : null, + }, + }); +} + +/** + * Starts the focused countdown for every live alert. Called when the window + * regains focus; alerts already counting down keep their original deadline, so + * clicking around does not keep pushing the fade out. + */ +export function markThreadAlertsFocused(nowMs: number): void { + const current = appAtomRegistry.get(threadAlertsAtom); + const keys = Object.keys(current); + if (keys.length === 0) { return; } - appAtomRegistry.set(threadAlertsAtom, { ...current, [key]: kind }); + + let changed = false; + const next: Record = {}; + for (const key of keys) { + const alert = current[key]; + if (alert === undefined) { + continue; + } + if (alert.focusedAtMs === null) { + next[key] = { ...alert, focusedAtMs: nowMs }; + changed = true; + } else { + next[key] = alert; + } + } + + if (changed) { + appAtomRegistry.set(threadAlertsAtom, next); + } +} + +/** Drops every alert that has outlived its bounds. */ +export function pruneExpiredThreadAlerts(nowMs: number): void { + const current = appAtomRegistry.get(threadAlertsAtom); + const keys = Object.keys(current); + if (keys.length === 0) { + return; + } + + const next: Record = {}; + let removed = false; + for (const key of keys) { + const alert = current[key]; + if (alert === undefined) { + continue; + } + if (isThreadAlertExpired(alert, nowMs)) { + removed = true; + } else { + next[key] = alert; + } + } + + if (removed) { + appAtomRegistry.set(threadAlertsAtom, next); + } } export function clearThreadAlert(ref: ScopedThreadRef): void { @@ -51,7 +159,16 @@ export function clearThreadAlert(ref: ScopedThreadRef): void { } export function readThreadAlert(ref: ScopedThreadRef): ThreadAlertKind | null { - return appAtomRegistry.get(threadAlertsAtom)[scopedThreadKey(ref)] ?? null; + return appAtomRegistry.get(threadAlertsAtom)[scopedThreadKey(ref)]?.kind ?? null; +} + +export function readThreadAlerts(): Readonly> { + return appAtomRegistry.get(threadAlertsAtom); +} + +/** Notifies when the live alert set changes, so expiry can be rescheduled. */ +export function subscribeThreadAlerts(listener: () => void): () => void { + return appAtomRegistry.subscribe(threadAlertsAtom, listener); } /** @@ -62,7 +179,7 @@ export function readThreadAlert(ref: ScopedThreadRef): ThreadAlertKind | null { */ export function useThreadAlert(ref: ScopedThreadRef | null): ThreadAlertKind | null { const alerts = useAtomValue(threadAlertsAtom); - return ref === null ? null : (alerts[scopedThreadKey(ref)] ?? null); + return ref === null ? null : (alerts[scopedThreadKey(ref)]?.kind ?? null); } export function useHasThreadAlerts(): boolean { From cd0caae319209665285938a568903591af4a97ae Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Tue, 11 Aug 2026 18:47:42 +0530 Subject: [PATCH 5/7] feat(worktree): add automated cleanup for stale worktrees - Run daily cleanup sweeps and prepare worktrees before agent turns - Prune recognized dependency artifacts before removing inactive worktrees after seven days - Preserve worktrees with local changes, unpushed commits, or no upstream and publish notices - Persist cleanup state and expose lifecycle controls through server settings and the web UI --- .../Layers/ProviderCommandReactor.ts | 11 + .../project/ProjectSetupScriptRunner.test.ts | 1 + apps/server/src/server.ts | 8 +- apps/server/src/serverRuntimeStartup.ts | 5 + apps/server/src/terminal/Manager.ts | 4 + apps/server/src/worktreeCleanup.test.ts | 269 +++++++ apps/server/src/worktreeCleanup.ts | 687 ++++++++++++++++++ apps/server/src/ws.ts | 22 +- .../components/settings/SettingsPanels.tsx | 56 ++ .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/routes/__root.tsx | 37 + docs/user/source-control.md | 15 + .../client-runtime/src/state/server.test.ts | 23 + packages/client-runtime/src/state/server.ts | 9 + packages/contracts/src/server.ts | 36 + packages/contracts/src/settings.test.ts | 17 + packages/contracts/src/settings.ts | 15 + 17 files changed, 1218 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/worktreeCleanup.test.ts create mode 100644 apps/server/src/worktreeCleanup.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ff639797179f..2bb015d4a56d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -45,6 +45,7 @@ import { } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { WorktreeCleanup } from "../../worktreeCleanup.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderDriverKind = Schema.is(ProviderDriverKind); @@ -320,6 +321,7 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + const worktreeCleanup = yield* Effect.serviceOption(WorktreeCleanup); const serverCommandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); @@ -612,6 +614,15 @@ const make = Effect.gen(function* () { } } const project = yield* resolveProject(thread.projectId); + if (project && Option.isSome(worktreeCleanup)) { + yield* worktreeCleanup.value.prepareForTurn({ + threadId: thread.id, + projectId: thread.projectId, + projectCwd: project.workspaceRoot, + branch: thread.branch, + worktreePath: thread.worktreePath, + }); + } const effectiveCwd = resolveThreadWorkspaceCwd({ thread, projects: project ? [project] : [], diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 5c5da4666b0d..ba0d03f4664e 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -59,6 +59,7 @@ const makeTerminalManagerLayer = ( close: () => Effect.void, subscribe: () => Effect.succeed(() => undefined), subscribeMetadata: () => Effect.succeed(() => undefined), + listMetadata: () => Effect.succeed([]), }); const testLayer = ( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8d4f8bb61d75..50be50e15d67 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -105,6 +105,7 @@ import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as WorktreeCleanup from "./worktreeCleanup.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -365,7 +366,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( +const RuntimeCoreDependenciesBaseLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(CheckpointingLayerLive), @@ -412,6 +413,11 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( ), ); +const RuntimeCoreDependenciesLive = Layer.merge( + RuntimeCoreDependenciesBaseLive, + WorktreeCleanup.layer.pipe(Layer.provide(RuntimeCoreDependenciesBaseLive)), +); + const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(BackgroundLayerLive), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 5db2b75556ee..70e963f0a94e 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -34,6 +34,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; +import * as WorktreeCleanup from "./worktreeCleanup.ts"; import { forkParked } from "./serverActivation.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import { @@ -302,6 +303,7 @@ export const make = (options?: StartupOptions) => const keybindings = yield* Keybindings.Keybindings; const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor; const providerSessionReaper = yield* ProviderSessionReaper.ProviderSessionReaper; + const worktreeCleanup = yield* Effect.serviceOption(WorktreeCleanup.WorktreeCleanup); const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; @@ -351,6 +353,9 @@ export const make = (options?: StartupOptions) => Effect.gen(function* () { yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); + if (Option.isSome(worktreeCleanup)) { + yield* worktreeCleanup.value.start().pipe(Scope.provide(reactorScope)); + } }), ); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 6dc9e1892b63..0a200d8f925d 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -185,6 +185,9 @@ export class TerminalManager extends Context.Service< readonly subscribeMetadata: ( listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, ) => Effect.Effect<() => void>; + + /** Read current terminal metadata without opening or attaching to a session. */ + readonly listMetadata: () => Effect.Effect>; } >()("t3/terminal/Manager/TerminalManager") {} @@ -2701,6 +2704,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func close, subscribe, subscribeMetadata, + listMetadata: readAllTerminalMetadata, }); }); diff --git a/apps/server/src/worktreeCleanup.test.ts b/apps/server/src/worktreeCleanup.test.ts new file mode 100644 index 000000000000..37bec4cd3543 --- /dev/null +++ b/apps/server/src/worktreeCleanup.test.ts @@ -0,0 +1,269 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + type OrchestrationProjectShell, + type OrchestrationThreadShell, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as DateTime from "effect/DateTime"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as ServerConfig from "./config.ts"; +import { GitWorkflowService } from "./git/GitWorkflowService.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectSetupScriptRunner } from "./project/ProjectSetupScriptRunner.ts"; +import * as ServerSettings from "./serverSettings.ts"; +import { TerminalManager } from "./terminal/Manager.ts"; +import { GitVcsDriver } from "./vcs/GitVcsDriver.ts"; +import { + artifactDirectoryNamesForEntries, + groupManagedWorktrees, + WorktreeCleanup, + layer as worktreeCleanupLayer, +} from "./worktreeCleanup.ts"; + +const projectId = ProjectId.make("project-1"); + +const project: OrchestrationProjectShell = { + id: projectId, + title: "T3 Code", + workspaceRoot: "/projects/t3code", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", +}; + +function thread(input: { + readonly id: string; + readonly worktreePath: string | null; + readonly updatedAt: string; +}): OrchestrationThreadShell { + return { + id: ThreadId.make(input.id), + projectId, + title: input.id, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feat/storage-cleanup", + worktreePath: input.worktreePath, + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: input.updatedAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: input.updatedAt, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +it("uses the newest thread update for a shared worktree", () => { + const groups = groupManagedWorktrees({ + projects: [project], + threads: [ + thread({ + id: "older-thread", + worktreePath: "/worktrees/shared", + updatedAt: "2026-08-02T00:00:00.000Z", + }), + thread({ + id: "newer-thread", + worktreePath: "/worktrees/shared", + updatedAt: "2026-08-05T00:00:00.000Z", + }), + thread({ + id: "main-checkout-thread", + worktreePath: null, + updatedAt: "2026-08-10T00:00:00.000Z", + }), + ], + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.lastUpdatedAt).toBe("2026-08-05T00:00:00.000Z"); + expect(groups[0]?.threads).toHaveLength(2); +}); + +it("matches generated directories only when their ecosystem marker is present", () => { + expect( + artifactDirectoryNamesForEntries(["package.json", "node_modules", ".next", ".turbo"]), + ).toEqual(["node_modules", ".next", ".turbo"]); + expect(artifactDirectoryNamesForEntries(["Cargo.toml", "target", "vendor"])).toEqual([ + "target", + "vendor", + ]); + expect(artifactDirectoryNamesForEntries(["composer.json", "vendor"])).toEqual(["vendor"]); +}); + +it("does not treat a Go vendor directory as disposable", () => { + expect(artifactDirectoryNamesForEntries(["go.mod", "vendor"])).toEqual([]); +}); + +it.effect("prunes ignored generated artifacts while preserving Go vendor", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-worktree-cleanup-test-", + }); + const worktreePath = path.join(baseDir, "worktrees", "project-1", "feature"); + yield* Effect.all( + [ + ["package.json", "{}"], + ["node_modules/package/index.js", "generated"], + [".next/cache/data", "generated"], + ["go.mod", "module example.test/project"], + ["vendor/example.test/dependency/source.go", "package dependency"], + ].map(([relativePath, contents]) => { + const filePath = path.join(worktreePath, relativePath ?? ""); + return fileSystem + .makeDirectory(path.dirname(filePath), { recursive: true }) + .pipe(Effect.andThen(fileSystem.writeFileString(filePath, contents ?? ""))); + }), + { concurrency: "unbounded" }, + ); + + const activeProject = { ...project, workspaceRoot: baseDir }; + const activeThread = thread({ + id: "inactive-thread", + worktreePath, + updatedAt: DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { days: 3 })), + }); + const snapshot = { + snapshotSequence: 1, + projects: [activeProject], + threads: [activeThread], + updatedAt: "2026-08-01T00:00:00.000Z", + }; + const emptySnapshot = { ...snapshot, projects: [], threads: [] }; + const success = { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + const dependencies = Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), baseDir).pipe(Layer.provide(NodeServices.layer)), + ServerSettings.layerTest({ worktreeCleanupAfterDays: 2 }), + Layer.mock(ProjectionSnapshotQuery)({ + getShellSnapshot: () => Effect.succeed(snapshot), + getArchivedShellSnapshot: () => Effect.succeed(emptySnapshot), + }), + Layer.mock(TerminalManager)({ listMetadata: () => Effect.succeed([]) }), + Layer.mock(GitVcsDriver)({ execute: () => Effect.succeed(success) }), + Layer.mock(GitWorkflowService)({}), + Layer.mock(ProjectSetupScriptRunner)({}), + NodeServices.layer, + ); + + yield* Effect.gen(function* () { + const cleanup = yield* WorktreeCleanup; + yield* cleanup.runNow; + expect(yield* fileSystem.exists(path.join(worktreePath, "node_modules"))).toBe(false); + expect(yield* fileSystem.exists(path.join(worktreePath, ".next"))).toBe(false); + expect(yield* fileSystem.exists(path.join(worktreePath, "vendor"))).toBe(true); + }).pipe(Effect.provide(worktreeCleanupLayer.pipe(Layer.provide(dependencies)))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), +); + +it.effect("keeps an inactive worktree and reports commits missing from its upstream", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-worktree-cleanup-unpushed-test-", + }); + const worktreePath = path.join(baseDir, "worktrees", "project-1", "feature"); + yield* fileSystem.makeDirectory(worktreePath, { recursive: true }); + + const activeProject = { ...project, workspaceRoot: baseDir }; + const activeThread = thread({ + id: "unpushed-thread", + worktreePath, + updatedAt: DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { days: 8 })), + }); + const snapshot = { + snapshotSequence: 1, + projects: [activeProject], + threads: [activeThread], + updatedAt: activeThread.updatedAt, + }; + const emptySnapshot = { ...snapshot, projects: [], threads: [] }; + const gitResult = (stdout = "", exitCode = 0) => ({ + exitCode: ChildProcessSpawner.ExitCode(exitCode), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + const dependencies = Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), baseDir).pipe(Layer.provide(NodeServices.layer)), + ServerSettings.layerTest({ worktreeCleanupAfterDays: 2 }), + Layer.mock(ProjectionSnapshotQuery)({ + getShellSnapshot: () => Effect.succeed(snapshot), + getArchivedShellSnapshot: () => Effect.succeed(emptySnapshot), + }), + Layer.mock(TerminalManager)({ listMetadata: () => Effect.succeed([]) }), + Layer.mock(GitVcsDriver)({ + execute: (input) => { + switch (input.operation) { + case "WorktreeCleanup.upstreamRemote": + return Effect.succeed(gitResult("origin\n")); + case "WorktreeCleanup.upstreamMergeRef": + return Effect.succeed(gitResult("refs/heads/feat/storage-cleanup\n")); + case "WorktreeCleanup.verifyPushedHead": + return Effect.succeed(gitResult("", 1)); + default: + return Effect.succeed(gitResult()); + } + }, + fetchRemoteTrackingBranch: () => Effect.void, + statusDetailsRemote: () => + Effect.succeed({ + isRepo: true, + isDefaultBranch: false, + branch: "feat/storage-cleanup", + upstreamRef: "origin/feat/storage-cleanup", + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 1, + }), + }), + Layer.mock(GitWorkflowService)({ + removeWorktree: () => Effect.die("unsafe removal was attempted"), + }), + Layer.mock(ProjectSetupScriptRunner)({}), + NodeServices.layer, + ); + + yield* Effect.gen(function* () { + const cleanup = yield* WorktreeCleanup; + yield* cleanup.runNow; + expect(yield* fileSystem.exists(worktreePath)).toBe(true); + expect(yield* cleanup.notices).toEqual([ + expect.objectContaining({ + worktreePath, + branch: "feat/storage-cleanup", + reason: "unpushed-commits", + }), + ]); + }).pipe(Effect.provide(worktreeCleanupLayer.pipe(Layer.provide(dependencies)))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), +); diff --git a/apps/server/src/worktreeCleanup.ts b/apps/server/src/worktreeCleanup.ts new file mode 100644 index 000000000000..184bdfd1fc02 --- /dev/null +++ b/apps/server/src/worktreeCleanup.ts @@ -0,0 +1,687 @@ +import { + type OrchestrationProjectShell, + type OrchestrationThreadShell, + type WorktreeCleanupNotice, + type WorktreeCleanupNoticeReason, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "./config.ts"; +import { GitWorkflowService } from "./git/GitWorkflowService.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectSetupScriptRunner } from "./project/ProjectSetupScriptRunner.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; +import { forkParked } from "./serverActivation.ts"; +import { TerminalManager } from "./terminal/Manager.ts"; +import { GitVcsDriver } from "./vcs/GitVcsDriver.ts"; + +const DAY_MS = 24 * 60 * 60 * 1_000; +const WORKTREE_RETIRE_AFTER_DAYS = 7; +const SWEEP_INTERVAL = Duration.hours(24); +const STATE_FILE_NAME = "worktree-cleanup.json"; + +const CleanupEntry = Schema.Struct({ + worktreePath: Schema.String, + artifactsPrunedAt: Schema.NullOr(Schema.String), + worktreeRemovedAt: Schema.NullOr(Schema.String), +}); + +const CleanupState = Schema.Struct({ + version: Schema.Literal(1), + entries: Schema.Array(CleanupEntry), + notices: Schema.Array( + Schema.Struct({ + id: Schema.String, + worktreePath: Schema.String, + projectTitle: Schema.String, + branch: Schema.NullOr(Schema.String), + reason: Schema.Literals([ + "local-changes", + "local-files", + "no-upstream", + "unpushed-commits", + "inspection-failed", + "removal-failed", + ]), + createdAt: Schema.String, + }), + ), +}); +type CleanupState = typeof CleanupState.Type; + +const CleanupStateJson = Schema.fromJsonString(CleanupState); +const decodeCleanupState = Schema.decodeUnknownEffect(CleanupStateJson); +const encodeCleanupState = Schema.encodeEffect(CleanupStateJson); + +const EMPTY_STATE: CleanupState = { + version: 1, + entries: [], + notices: [], +}; + +interface ArtifactRule { + readonly directory: string; + readonly markers: ReadonlyArray; +} + +/** Marker files make same-named source directories safe across language ecosystems. */ +const ARTIFACT_RULES: ReadonlyArray = [ + { directory: "node_modules", markers: ["package.json"] }, + { directory: ".next", markers: ["package.json"] }, + { directory: ".turbo", markers: ["package.json", "turbo.json"] }, + { directory: "target", markers: ["Cargo.toml"] }, + { directory: "vendor", markers: ["composer.json", "Cargo.toml"] }, + { directory: ".venv", markers: ["pyproject.toml", "requirements.txt"] }, + { directory: ".build", markers: ["Package.swift"] }, + { directory: "Pods", markers: ["Podfile"] }, + { + directory: ".gradle", + markers: ["settings.gradle", "settings.gradle.kts", "build.gradle", "build.gradle.kts"], + }, + { directory: "build", markers: ["build.gradle", "build.gradle.kts"] }, +]; + +const ARTIFACT_DIRECTORY_NAMES = new Set(ARTIFACT_RULES.map((rule) => rule.directory)); +const WALK_SKIP_NAMES = new Set([".git", ".t3", ...ARTIFACT_DIRECTORY_NAMES]); + +export function artifactDirectoryNamesForEntries(entries: Iterable): ReadonlyArray { + const entryNames = new Set(entries); + return ARTIFACT_RULES.filter( + (rule) => + entryNames.has(rule.directory) && rule.markers.some((marker) => entryNames.has(marker)), + ).map((rule) => rule.directory); +} + +interface WorktreeGroup { + readonly path: string; + readonly project: OrchestrationProjectShell; + readonly threads: ReadonlyArray; + readonly lastUpdatedAt: string; +} + +export function groupManagedWorktrees(input: { + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +}): ReadonlyArray { + const projects = new Map(input.projects.map((project) => [project.id, project] as const)); + const groups = new Map>(); + for (const thread of input.threads) { + if (!thread.worktreePath) continue; + const existing = groups.get(thread.worktreePath) ?? []; + existing.push(thread); + groups.set(thread.worktreePath, existing); + } + + return [...groups.entries()].flatMap(([worktreePath, threads]) => { + const firstThread = threads[0]; + if (!firstThread) return []; + const project = projects.get(firstThread.projectId); + if (!project) return []; + const lastUpdatedAt = threads.reduce( + (latest, thread) => (thread.updatedAt > latest ? thread.updatedAt : latest), + threads[0]?.updatedAt ?? project.updatedAt, + ); + return [{ path: worktreePath, project, threads, lastUpdatedAt }]; + }); +} + +function cleanupStatesEqual(left: CleanupState, right: CleanupState): boolean { + return ( + left.entries.length === right.entries.length && + left.notices.length === right.notices.length && + left.entries.every((entry, index) => { + const other = right.entries[index]; + return ( + other !== undefined && + entry.worktreePath === other.worktreePath && + entry.artifactsPrunedAt === other.artifactsPrunedAt && + entry.worktreeRemovedAt === other.worktreeRemovedAt + ); + }) && + left.notices.every((notice, index) => { + const other = right.notices[index]; + return ( + other !== undefined && + notice.id === other.id && + notice.worktreePath === other.worktreePath && + notice.projectTitle === other.projectTitle && + notice.branch === other.branch && + notice.reason === other.reason && + notice.createdAt === other.createdAt + ); + }) + ); +} + +function isPathWithinRoot(path: Path.Path, root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative.length > 0 && + !path.isAbsolute(relative) && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) + ); +} + +function noticeId(worktreePath: string, reason: WorktreeCleanupNoticeReason): string { + return `${reason}:${worktreePath}`; +} + +function worktreeIsBusy(group: WorktreeGroup, runningTerminalThreadIds: ReadonlySet) { + return group.threads.some( + (thread) => + runningTerminalThreadIds.has(thread.id) || + thread.backgroundLiveness != null || + (thread.session !== null && thread.session.status !== "stopped"), + ); +} + +export class WorktreePreparationError extends Schema.TaggedErrorClass()( + "WorktreePreparationError", + { + worktreePath: Schema.String, + operation: Schema.Literals(["recreate", "inspect"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not ${this.operation} managed worktree '${this.worktreePath}'.`; + } +} + +export class WorktreeCleanup extends Context.Service< + WorktreeCleanup, + { + readonly start: () => Effect.Effect; + readonly runNow: Effect.Effect; + readonly notices: Effect.Effect>; + readonly noticeChanges: Stream.Stream>; + readonly prepareForTurn: (input: { + readonly threadId: string; + readonly projectId: string; + readonly projectCwd: string; + readonly branch: string | null; + readonly worktreePath: string | null; + }) => Effect.Effect; + } +>()("t3/worktreeCleanup") {} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const settings = yield* ServerSettingsService; + const projections = yield* ProjectionSnapshotQuery; + const terminals = yield* TerminalManager; + const git = yield* GitVcsDriver; + const gitWorkflow = yield* GitWorkflowService; + const setupScripts = yield* ProjectSetupScriptRunner; + const lock = yield* Semaphore.make(1); + const noticePubSub = yield* PubSub.sliding>(1); + const statePath = path.join(config.stateDir, STATE_FILE_NAME); + + const initialState = yield* fileSystem.exists(statePath).pipe( + Effect.flatMap((exists) => + exists + ? fileSystem.readFileString(statePath).pipe( + Effect.flatMap(decodeCleanupState), + Effect.catchCause((cause) => + Effect.logWarning("worktree cleanup state could not be read", { cause }).pipe( + Effect.as(EMPTY_STATE), + ), + ), + ) + : Effect.succeed(EMPTY_STATE), + ), + ); + const stateRef = yield* Ref.make(initialState); + + const persistState = Effect.fn("WorktreeCleanup.persistState")(function* (state: CleanupState) { + const encoded = yield* encodeCleanupState(state); + const temporaryPath = `${statePath}.tmp`; + yield* fileSystem.writeFileString(temporaryPath, encoded); + yield* fileSystem.rename(temporaryPath, statePath); + }); + + const publishState = Effect.fn("WorktreeCleanup.publishState")(function* (state: CleanupState) { + yield* Ref.set(stateRef, state); + yield* persistState(state); + yield* PubSub.publish(noticePubSub, state.notices); + }); + + const managedExistingPath = Effect.fn("WorktreeCleanup.managedExistingPath")(function* ( + candidate: string, + ) { + const normalizedRoot = path.resolve(config.worktreesDir); + const normalizedCandidate = path.resolve(candidate); + if (!isPathWithinRoot(path, normalizedRoot, normalizedCandidate)) return null; + if (!(yield* fileSystem.exists(normalizedCandidate))) return null; + const [realRoot, realCandidate] = yield* Effect.all([ + fileSystem.realPath(normalizedRoot), + fileSystem.realPath(normalizedCandidate), + ]); + return isPathWithinRoot(path, realRoot, realCandidate) ? normalizedCandidate : null; + }); + + const findArtifactDirectories = Effect.fn("WorktreeCleanup.findArtifactDirectories")(function* ( + worktreePath: string, + ) { + const root = yield* managedExistingPath(worktreePath); + if (!root) return []; + const realRoot = yield* fileSystem.realPath(root); + const queue = [root]; + const visitedDirectories = new Set(); + const artifacts: string[] = []; + + while (queue.length > 0) { + const directory = queue.pop(); + if (!directory) continue; + const realDirectory = yield* fileSystem + .realPath(directory) + .pipe(Effect.orElseSucceed(() => null)); + if (!realDirectory || visitedDirectories.has(realDirectory)) continue; + visitedDirectories.add(realDirectory); + const entries = yield* fileSystem + .readDirectory(directory, { recursive: false }) + .pipe(Effect.orElseSucceed(() => [])); + const entryNames = new Set(entries); + + for (const artifactDirectory of artifactDirectoryNamesForEntries(entryNames)) { + const candidate = path.join(directory, artifactDirectory); + const ignored = yield* git + .execute({ + operation: "WorktreeCleanup.checkIgnoredArtifact", + cwd: root, + args: ["check-ignore", "-q", "--", path.relative(root, candidate)], + allowNonZeroExit: true, + }) + .pipe( + Effect.map((result) => result.exitCode === 0), + Effect.orElseSucceed(() => false), + ); + if (!ignored) continue; + const realCandidate = yield* fileSystem + .realPath(candidate) + .pipe(Effect.orElseSucceed(() => null)); + if (!realCandidate || !isPathWithinRoot(path, realRoot, realCandidate)) continue; + artifacts.push(candidate); + } + + for (const entry of entries) { + if (WALK_SKIP_NAMES.has(entry)) continue; + const child = path.join(directory, entry); + const info = yield* fileSystem.stat(child).pipe(Effect.orElseSucceed(() => null)); + if (info?.type !== "Directory") continue; + const realChild = yield* fileSystem.realPath(child).pipe(Effect.orElseSucceed(() => null)); + if (!realChild || !isPathWithinRoot(path, realRoot, realChild)) { + continue; + } + queue.push(child); + } + } + + return [...new Set(artifacts)].toSorted(); + }); + + const pruneArtifacts = Effect.fn("WorktreeCleanup.pruneArtifacts")(function* ( + worktreePath: string, + ) { + const artifacts = yield* findArtifactDirectories(worktreePath); + for (const artifact of artifacts) { + yield* fileSystem.remove(artifact, { recursive: true, force: true }).pipe( + Effect.tap(() => + Effect.logInfo("worktree cleanup removed generated artifact", { artifact }), + ), + Effect.catchCause((cause) => + Effect.logWarning("worktree cleanup could not remove generated artifact", { + artifact, + cause, + }), + ), + ); + } + return artifacts.length; + }); + + const localRetirementBlocker = Effect.fn("WorktreeCleanup.localRetirementBlocker")(function* ( + worktreePath: string, + ) { + const result = yield* git.execute({ + operation: "WorktreeCleanup.retirementStatus", + cwd: worktreePath, + args: ["status", "--porcelain=v1", "--untracked-files=all", "--ignored=matching", "-z"], + }); + const records = result.stdout.split("\0").filter(Boolean); + if (records.some((record) => record.startsWith("!! "))) return "local-files" as const; + if (records.length > 0) return "local-changes" as const; + return null; + }); + + const verifiedRemoteStatus = Effect.fn("WorktreeCleanup.verifiedRemoteStatus")(function* ( + worktreePath: string, + ) { + const status = yield* git.statusDetailsRemote(worktreePath, { refreshUpstream: false }); + if (!status.hasUpstream || !status.branch) return status; + + const [remoteResult, mergeResult] = yield* Effect.all([ + git.execute({ + operation: "WorktreeCleanup.upstreamRemote", + cwd: worktreePath, + args: ["config", "--get", `branch.${status.branch}.remote`], + allowNonZeroExit: true, + }), + git.execute({ + operation: "WorktreeCleanup.upstreamMergeRef", + cwd: worktreePath, + args: ["config", "--get", `branch.${status.branch}.merge`], + allowNonZeroExit: true, + }), + ]); + const remoteName = remoteResult.stdout.trim(); + const mergeRef = mergeResult.stdout.trim(); + const headsPrefix = "refs/heads/"; + if ( + remoteResult.exitCode !== 0 || + mergeResult.exitCode !== 0 || + remoteName.length === 0 || + remoteName === "." || + !mergeRef.startsWith(headsPrefix) + ) { + return { ...status, hasUpstream: false, upstreamRef: null }; + } + + yield* git.fetchRemoteTrackingBranch({ + cwd: worktreePath, + remoteName, + remoteBranch: mergeRef.slice(headsPrefix.length), + }); + const refreshedStatus = yield* git.statusDetailsRemote(worktreePath, { + refreshUpstream: false, + }); + if (!refreshedStatus.upstreamRef) { + return { ...refreshedStatus, hasUpstream: false }; + } + const containsHead = yield* git.execute({ + operation: "WorktreeCleanup.verifyPushedHead", + cwd: worktreePath, + args: ["merge-base", "--is-ancestor", "HEAD", refreshedStatus.upstreamRef], + allowNonZeroExit: true, + }); + return containsHead.exitCode === 0 + ? refreshedStatus + : { ...refreshedStatus, aheadCount: Math.max(1, refreshedStatus.aheadCount) }; + }); + + const makeNotice = ( + group: WorktreeGroup, + reason: WorktreeCleanupNoticeReason, + createdAt: string, + previous: ReadonlyMap, + ): WorktreeCleanupNotice => { + const id = noticeId(group.path, reason); + return ( + previous.get(id) ?? { + id, + worktreePath: group.path, + projectTitle: group.project.title, + branch: group.threads.find((thread) => thread.branch !== null)?.branch ?? null, + reason, + createdAt, + } + ); + }; + + const runSweep = lock.withPermits(1)( + Effect.gen(function* () { + const cleanupAfterDays = (yield* settings.getSettings).worktreeCleanupAfterDays; + if (cleanupAfterDays === null) { + const currentState = yield* Ref.get(stateRef); + if (currentState.notices.length > 0) { + yield* publishState({ ...currentState, notices: [] }); + } + return; + } + + const [active, archived, terminalMetadata, currentState, now] = yield* Effect.all([ + projections.getShellSnapshot(), + projections.getArchivedShellSnapshot(), + terminals.listMetadata(), + Ref.get(stateRef), + DateTime.now, + ]); + const nowMs = DateTime.toEpochMillis(now); + const nowIso = DateTime.formatIso(now); + const groups = groupManagedWorktrees({ + projects: [...active.projects, ...archived.projects], + threads: [...active.threads, ...archived.threads], + }); + const runningTerminalThreadIds = new Set( + terminalMetadata + .filter((terminal) => terminal.status === "running" || terminal.hasRunningSubprocess) + .map((terminal) => terminal.threadId), + ); + const entries = new Map(currentState.entries.map((entry) => [entry.worktreePath, entry])); + const previousNotices = new Map(currentState.notices.map((notice) => [notice.id, notice])); + const notices: WorktreeCleanupNotice[] = []; + + for (const group of groups) { + const lastUpdatedMs = DateTime.toEpochMillis(DateTime.makeUnsafe(group.lastUpdatedAt)); + const inactiveMs = Math.max(0, nowMs - lastUpdatedMs); + if ( + inactiveMs < cleanupAfterDays * DAY_MS || + worktreeIsBusy(group, runningTerminalThreadIds) + ) { + continue; + } + + const managedPath = yield* managedExistingPath(group.path).pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (!managedPath) continue; + + const removedArtifactCount = yield* pruneArtifacts(managedPath); + if (removedArtifactCount > 0) { + const previousEntry = entries.get(group.path); + entries.set(group.path, { + worktreePath: group.path, + artifactsPrunedAt: previousEntry?.artifactsPrunedAt ?? nowIso, + worktreeRemovedAt: previousEntry?.worktreeRemovedAt ?? null, + }); + } + + if (inactiveMs < WORKTREE_RETIRE_AFTER_DAYS * DAY_MS) continue; + + const localBlocker = yield* localRetirementBlocker(managedPath).pipe( + Effect.catchCause((cause) => { + notices.push(makeNotice(group, "inspection-failed", nowIso, previousNotices)); + return Effect.logWarning("worktree cleanup could not inspect local state", { + worktreePath: group.path, + cause, + }).pipe(Effect.as("inspection-failed" as const)); + }), + ); + if (localBlocker !== null) { + if (localBlocker !== "inspection-failed") { + notices.push(makeNotice(group, localBlocker, nowIso, previousNotices)); + } + continue; + } + + const remote = yield* verifiedRemoteStatus(managedPath).pipe( + Effect.catchCause((cause) => { + notices.push(makeNotice(group, "inspection-failed", nowIso, previousNotices)); + return Effect.logWarning("worktree cleanup could not inspect upstream state", { + worktreePath: group.path, + cause, + }).pipe(Effect.as(null)); + }), + ); + if (!remote) continue; + if (!remote.hasUpstream) { + notices.push(makeNotice(group, "no-upstream", nowIso, previousNotices)); + continue; + } + if (remote.aheadCount > 0) { + notices.push(makeNotice(group, "unpushed-commits", nowIso, previousNotices)); + continue; + } + + const removed = yield* gitWorkflow + .removeWorktree({ cwd: group.project.workspaceRoot, path: managedPath }) + .pipe( + Effect.as(true), + Effect.catchCause((cause) => { + notices.push(makeNotice(group, "removal-failed", nowIso, previousNotices)); + return Effect.logWarning("worktree cleanup could not remove safe worktree", { + worktreePath: group.path, + cause, + }).pipe(Effect.as(false)); + }), + ); + if (!removed) continue; + entries.set(group.path, { + worktreePath: group.path, + artifactsPrunedAt: entries.get(group.path)?.artifactsPrunedAt ?? nowIso, + worktreeRemovedAt: nowIso, + }); + yield* Effect.logInfo("worktree cleanup retired inactive worktree", { + worktreePath: group.path, + lastUpdatedAt: group.lastUpdatedAt, + }); + } + + const knownPaths = new Set(groups.map((group) => group.path)); + const nextState: CleanupState = { + version: 1, + entries: [...entries.values()].filter((entry) => knownPaths.has(entry.worktreePath)), + notices, + }; + if (!cleanupStatesEqual(nextState, currentState)) { + yield* publishState(nextState); + } + }), + ); + + const runSweepSafely = runSweep.pipe( + Effect.catchCause((cause) => Effect.logWarning("worktree cleanup sweep failed", { cause })), + ); + + const prepareForTurn: WorktreeCleanup["Service"]["prepareForTurn"] = (input) => + lock.withPermits(1)( + Effect.gen(function* () { + if (!input.worktreePath) return; + const worktreePath = input.worktreePath; + const normalizedRoot = path.resolve(config.worktreesDir); + const normalizedWorktreePath = path.resolve(worktreePath); + if (!isPathWithinRoot(path, normalizedRoot, normalizedWorktreePath)) return; + + const currentState = yield* Ref.get(stateRef); + const entry = currentState.entries.find( + (candidate) => candidate.worktreePath === worktreePath, + ); + const exists = yield* fileSystem.exists(normalizedWorktreePath).pipe( + Effect.mapError( + (cause) => + new WorktreePreparationError({ + worktreePath, + operation: "inspect", + cause, + }), + ), + ); + let recreated = false; + if (!exists) { + if (!input.branch) { + return yield* new WorktreePreparationError({ + worktreePath, + operation: "recreate", + cause: new Error("The thread has no branch to recreate."), + }); + } + yield* gitWorkflow + .createWorktree({ + cwd: input.projectCwd, + refName: input.branch, + path: normalizedWorktreePath, + }) + .pipe( + Effect.mapError( + (cause) => + new WorktreePreparationError({ + worktreePath, + operation: "recreate", + cause, + }), + ), + ); + recreated = true; + } + + if (recreated || entry?.artifactsPrunedAt) { + yield* setupScripts + .runForThread({ + threadId: input.threadId, + projectId: input.projectId, + projectCwd: input.projectCwd, + worktreePath: normalizedWorktreePath, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("worktree cleanup could not restart the setup script", { + worktreePath, + threadId: input.threadId, + cause, + }), + ), + ); + } + + if ( + !entry && + !currentState.notices.some((notice) => notice.worktreePath === worktreePath) + ) { + return; + } + const nextState: CleanupState = { + version: 1, + entries: currentState.entries.filter( + (candidate) => candidate.worktreePath !== worktreePath, + ), + notices: currentState.notices.filter((notice) => notice.worktreePath !== worktreePath), + }; + yield* publishState(nextState).pipe( + Effect.catchCause((cause) => + Effect.logWarning("worktree cleanup could not clear restored worktree state", { + worktreePath, + cause, + }), + ), + ); + }), + ); + + const start: WorktreeCleanup["Service"]["start"] = () => + forkParked(runSweepSafely.pipe(Effect.repeat(Schedule.spaced(SWEEP_INTERVAL)))); + + return WorktreeCleanup.of({ + start, + runNow: runSweepSafely, + notices: Ref.get(stateRef).pipe(Effect.map((state) => state.notices)), + noticeChanges: Stream.fromPubSub(noticePubSub), + prepareForTurn, + }); +}); + +export const layer = Layer.effect(WorktreeCleanup, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 126222d214a2..f63dfb4b324a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -123,6 +123,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; +import * as WorktreeCleanup from "./worktreeCleanup.ts"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -416,6 +417,7 @@ const makeWsRpcLayer = ( const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; + const worktreeCleanup = yield* Effect.serviceOption(WorktreeCleanup.WorktreeCleanup); const relayClient = yield* RelayClient.RelayClient; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ @@ -1021,6 +1023,10 @@ const makeWsRpcLayer = ( otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, }, settings, + worktreeCleanupNotices: yield* Option.match(worktreeCleanup, { + onNone: () => Effect.succeed([]), + onSome: (cleanup) => cleanup.notices, + }), shellResumeCompletionMarker: true, threadResumeCompletionMarker: true, threadSnapshotPagination: true, @@ -2167,6 +2173,17 @@ const makeWsRpcLayer = ( payload: { settings }, })), ); + const worktreeCleanupUpdates = Option.match(worktreeCleanup, { + onNone: () => Stream.empty, + onSome: (cleanup) => + cleanup.noticeChanges.pipe( + Stream.map((notices) => ({ + version: 1 as const, + type: "worktreeCleanupUpdated" as const, + payload: { notices }, + })), + ), + }); yield* providerRegistry .refresh() @@ -2174,7 +2191,10 @@ const makeWsRpcLayer = ( const liveUpdates = Stream.merge( keybindingsUpdates, - Stream.merge(providerStatuses, settingsUpdates), + Stream.merge( + providerStatuses, + Stream.merge(settingsUpdates, worktreeCleanupUpdates), + ), ); return Stream.concat( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6743952ae26e..6ba0602a233b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -522,6 +522,9 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? ["New worktrees start from origin"] : []), + ...(settings.worktreeCleanupAfterDays !== DEFAULT_UNIFIED_SETTINGS.worktreeCleanupAfterDays + ? ["Inactive worktree cleanup"] + : []), ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), @@ -541,6 +544,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, + settings.worktreeCleanupAfterDays, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, settings.fontFamilyCode, @@ -644,6 +648,7 @@ export function useSettingsRestore(onRestored?: () => void) { providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, + worktreeCleanupAfterDays: DEFAULT_UNIFIED_SETTINGS.worktreeCleanupAfterDays, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, @@ -2076,6 +2081,57 @@ export function GeneralSettingsPanel() { /> ) : null} + + updateSettings({ + worktreeCleanupAfterDays: DEFAULT_UNIFIED_SETTINGS.worktreeCleanupAfterDays, + }) + } + /> + ) : null + } + control={ + + } + /> + pathname); const handledBootstrapThreadIdRef = useRef(null); + const shownWorktreeCleanupNoticeIdsRef = useRef(new Set()); const handledConfigEventRef = useRef(serverConfigEvent); const [keybindingsToastController] = useState(() => createKeybindingsUpdateToastController({}), @@ -444,6 +445,42 @@ function EventRouter() { setActiveEnvironmentId(serverConfig.environment.environmentId); }, [serverConfig]); + useEffect(() => { + const notices = serverConfig?.worktreeCleanupNotices ?? []; + const currentIds = new Set(notices.map((notice) => notice.id)); + shownWorktreeCleanupNoticeIdsRef.current = new Set( + [...shownWorktreeCleanupNoticeIdsRef.current].filter((id) => currentIds.has(id)), + ); + for (const notice of notices) { + if (shownWorktreeCleanupNoticeIdsRef.current.has(notice.id)) continue; + shownWorktreeCleanupNoticeIdsRef.current.add(notice.id); + const description = (() => { + switch (notice.reason) { + case "unpushed-commits": + return "This branch has commits that are not pushed. Push them before automatic cleanup can remove the worktree."; + case "no-upstream": + return "This branch has no upstream. Push it before automatic cleanup can remove the worktree."; + case "local-changes": + return "This worktree has uncommitted or untracked changes, so automatic cleanup left it untouched."; + case "local-files": + return "This worktree still has ignored local files, so automatic cleanup left it untouched."; + case "inspection-failed": + return "T3 Code could not verify that this worktree was safe to remove."; + case "removal-failed": + return "T3 Code verified this worktree but could not remove it."; + } + })(); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: `Worktree kept: ${notice.branch ?? notice.projectTitle}`, + description, + actionVariant: "outline", + }), + ); + } + }, [serverConfig?.worktreeCleanupNotices]); + useEffect(() => { handleWelcome(serverWelcome); }, [serverWelcome]); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index dc4a802329c8..d883715a9226 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -2,6 +2,21 @@ T3 Code connects to your Git hosting provider so you can create pull requests, review code, and manage repositories without leaving the app. +## Automatic Worktree Cleanup + +T3 Code can reclaim space from inactive worktrees without deleting their threads or branches. In +**Settings → General**, choose when cleanup starts, from one to seven days, or turn it off. + +Cleanup uses the most recent thread update for each worktree. It first removes ignored, +re-creatable dependencies and build output for detected project types, such as `node_modules`, +`.next`, Rust `target`, Composer or Rust `vendor`, Python `.venv`, Swift `.build`, CocoaPods, and +Gradle output. Go `vendor` directories are kept. + +After seven days of inactivity, T3 Code removes the worktree directory only when it has no local +changes, no remaining ignored local files, an upstream branch, and no commits ahead of that +upstream. If any safety check fails, the worktree stays and T3 Code shows a warning. Returning to +the thread recreates a retired worktree from its existing branch and runs the project setup script. + ## Supported Providers T3 Code works with the platforms your team already uses: diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 8edecae5646e..f7e4e17440df 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -282,6 +282,29 @@ describe("server state projection", () => { expect(result.latestEvent.type).toBe("settingsUpdated"); }); + it("projects worktree cleanup notices from the live config stream", () => { + const snapshot = applyServerConfigProjection(Option.none(), snapshotEvent(CONFIG)); + const notices = [ + { + id: "unpushed-commits:/worktrees/feature", + worktreePath: "/worktrees/feature", + projectTitle: "T3 Code", + branch: "feat/storage-cleanup", + reason: "unpushed-commits" as const, + createdAt: "2026-08-11T00:00:00.000Z", + }, + ]; + const projected = applyServerConfigProjection(snapshot, { + version: 1, + type: "worktreeCleanupUpdated", + payload: { notices }, + }); + + const result = Option.getOrThrow(projected); + expect(result.config.worktreeCleanupNotices).toEqual(notices); + expect(result.latestEvent.type).toBe("worktreeCleanupUpdated"); + }); + it("retains welcome when a ready event follows in the same stream chunk", () => { const welcome = { environment: {} as ServerLifecycleWelcomePayload["environment"], diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f579453c27fc..d8ab52c2c7ab 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -293,6 +293,15 @@ export function applyServerConfigProjection( latestEvent: event, source: "live", })); + case "worktreeCleanupUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + worktreeCleanupNotices: event.payload.notices, + }, + latestEvent: event, + source: "live", + })); } } diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index d7bc4c5c1898..22d68ae74c1e 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -417,6 +417,26 @@ export const ServerSignalProcessResult = Schema.Struct({ }); export type ServerSignalProcessResult = typeof ServerSignalProcessResult.Type; +export const WorktreeCleanupNoticeReason = Schema.Literals([ + "local-changes", + "local-files", + "no-upstream", + "unpushed-commits", + "inspection-failed", + "removal-failed", +]); +export type WorktreeCleanupNoticeReason = typeof WorktreeCleanupNoticeReason.Type; + +export const WorktreeCleanupNotice = Schema.Struct({ + id: TrimmedNonEmptyString, + worktreePath: TrimmedNonEmptyString, + projectTitle: TrimmedNonEmptyString, + branch: Schema.NullOr(TrimmedNonEmptyString), + reason: WorktreeCleanupNoticeReason, + createdAt: IsoDateTime, +}); +export type WorktreeCleanupNotice = typeof WorktreeCleanupNotice.Type; + export const ServerConfig = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, auth: ServerAuthDescriptor, @@ -430,6 +450,7 @@ export const ServerConfig = Schema.Struct({ availableEditors: ForwardCompatibleArray(EditorId), observability: ServerObservability, settings: ServerSettings, + worktreeCleanupNotices: Schema.optionalKey(Schema.Array(WorktreeCleanupNotice)), /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */ @@ -493,6 +514,12 @@ export const ServerConfigSettingsUpdatedPayload = Schema.Struct({ }); export type ServerConfigSettingsUpdatedPayload = typeof ServerConfigSettingsUpdatedPayload.Type; +export const ServerConfigWorktreeCleanupUpdatedPayload = Schema.Struct({ + notices: Schema.Array(WorktreeCleanupNotice), +}); +export type ServerConfigWorktreeCleanupUpdatedPayload = + typeof ServerConfigWorktreeCleanupUpdatedPayload.Type; + export const ServerConfigStreamSnapshotEvent = Schema.Struct({ version: Schema.Literal(1), type: Schema.Literal("snapshot"), @@ -524,11 +551,20 @@ export const ServerConfigStreamSettingsUpdatedEvent = Schema.Struct({ export type ServerConfigStreamSettingsUpdatedEvent = typeof ServerConfigStreamSettingsUpdatedEvent.Type; +export const ServerConfigStreamWorktreeCleanupUpdatedEvent = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("worktreeCleanupUpdated"), + payload: ServerConfigWorktreeCleanupUpdatedPayload, +}); +export type ServerConfigStreamWorktreeCleanupUpdatedEvent = + typeof ServerConfigStreamWorktreeCleanupUpdatedEvent.Type; + export const ServerConfigStreamEvent = Schema.Union([ ServerConfigStreamSnapshotEvent, ServerConfigStreamKeybindingsUpdatedEvent, ServerConfigStreamProviderStatusesEvent, ServerConfigStreamSettingsUpdatedEvent, + ServerConfigStreamWorktreeCleanupUpdatedEvent, ]); export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 46705837afa4..45e916e6bdf1 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -167,6 +167,23 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { }); }); +describe("ServerSettings worktree cleanup", () => { + it("defaults artifact cleanup to two days", () => { + expect(decodeServerSettings({}).worktreeCleanupAfterDays).toBe(2); + }); + + it("allows automatic cleanup to be disabled", () => { + expect(decodeServerSettings({ worktreeCleanupAfterDays: null }).worktreeCleanupAfterDays).toBe( + null, + ); + }); + + it.each([0, 8, 2.5])("rejects a cleanup threshold outside whole days 1..7: %s", (value) => { + expect(() => decodeServerSettings({ worktreeCleanupAfterDays: value })).toThrow(); + expect(() => decodeServerSettingsPatch({ worktreeCleanupAfterDays: value })).toThrow(); + }); +}); + describe("ServerSettings worktree defaults", () => { it("defaults start-from-origin on for legacy configs", () => { expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 521023f82211..a6f75d157c0c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -100,6 +100,17 @@ export const TerminalFontSize = Schema.Int.check( export type TerminalFontSize = typeof TerminalFontSize.Type; export const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12; +export const MIN_WORKTREE_CLEANUP_AFTER_DAYS = 1; +export const MAX_WORKTREE_CLEANUP_AFTER_DAYS = 7; +export const WorktreeCleanupAfterDays = Schema.Int.check( + Schema.isBetween({ + minimum: MIN_WORKTREE_CLEANUP_AFTER_DAYS, + maximum: MAX_WORKTREE_CLEANUP_AFTER_DAYS, + }), +); +export type WorktreeCleanupAfterDays = typeof WorktreeCleanupAfterDays.Type; +export const DEFAULT_WORKTREE_CLEANUP_AFTER_DAYS: WorktreeCleanupAfterDays = 2; + export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]); export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; @@ -586,6 +597,9 @@ export const ServerSettings = Schema.Struct({ newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + worktreeCleanupAfterDays: Schema.NullOr(WorktreeCleanupAfterDays).pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_WORKTREE_CLEANUP_AFTER_DAYS)), + ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( @@ -740,6 +754,7 @@ export const ServerSettingsPatch = Schema.Struct({ backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + worktreeCleanupAfterDays: Schema.optionalKey(Schema.NullOr(WorktreeCleanupAfterDays)), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), sourceControlWritingStyle: Schema.optionalKey( From 902ff53efb50413462d2714ebc9bcbb107879f65 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Tue, 11 Aug 2026 19:16:37 +0530 Subject: [PATCH 6/7] feat(web): make Vercel Dark the install default --- apps/web/index.html | 68 ++++++++++------ .../src/components/settings/ThemeSettings.tsx | 2 + apps/web/src/hooks/useTheme.test.ts | 10 +++ apps/web/src/hooks/useTheme.ts | 7 +- apps/web/src/themeBoot.test.ts | 54 ++++++++++--- apps/web/src/themePalette.test.ts | 47 ++++++++++- apps/web/src/themePalette.ts | 77 +++++++++++++++++++ 7 files changed, 226 insertions(+), 39 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index 5a4ccce76d6f..c6c64ca472e3 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -13,6 +13,7 @@ (() => { const LIGHT_BACKGROUND = "#ffffff"; const DARK_BACKGROUND = "#0a0a0a"; + const DEFAULT_THEME_ID = "vercel-dark"; const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode"; @@ -50,6 +51,14 @@ // Keep this small boot-time copy in sync with the built-in palettes so // the selected theme is visible before the app has mounted. const BUILT_IN_THEME_PALETTES = { + "vercel-dark": { + dark: { + background: "#000000", + foreground: "#ededed", + accent: "#62c073", + chrome: "#000000", + }, + }, "t3-chat": { light: { background: "#fdf7fd", @@ -133,6 +142,7 @@ "system", "light", "dark", + "vercel-dark", "t3-chat", "grove", "ocean", @@ -212,9 +222,27 @@ "t3-iris": "iris", }; const isLegacyT3ChatDark = storedTheme === "t3-chat-dark"; - const themeId = + const storedThemeId = storedTheme === null ? "" : (LEGACY_THEME_IDS[storedTheme] ?? storedTheme); const legacyMode = isLegacyT3ChatDark ? "dark" : null; + const storedBuiltInModes = BUILT_IN_THEME_MODES[storedThemeId] ?? null; + const storedCustomTheme = storedBuiltInModes ? null : findCustomTheme(storedThemeId); + const storedThemeModes = + storedBuiltInModes ?? + (storedCustomTheme + ? ["light", "dark"].filter( + (mode) => + mode === storedCustomTheme.appearance || storedCustomTheme.variants?.[mode], + ) + : null); + // Mirrors the runtime's isKnownThemePreference. + const isKnownStoredTheme = + storedTheme === "light" || + storedTheme === "dark" || + storedTheme === "system" || + storedThemeModes !== null; + const theme = isKnownStoredTheme && storedTheme ? storedTheme : DEFAULT_THEME_ID; + const themeId = LEGACY_THEME_IDS[theme] ?? theme; const builtInModes = BUILT_IN_THEME_MODES[themeId] ?? null; const customTheme = builtInModes ? null : findCustomTheme(themeId); const themeModes = @@ -224,15 +252,8 @@ (mode) => mode === customTheme.appearance || customTheme.variants?.[mode], ) : null); - // Mirrors the runtime's isKnownThemePreference. - const isKnownTheme = - storedTheme === "light" || - storedTheme === "dark" || - storedTheme === "system" || - themeModes !== null; - const theme = isKnownTheme && storedTheme ? storedTheme : "system"; - const isThemed = isKnownTheme && themeModes !== null; - const baseAppearance = customTheme ? customTheme.appearance : "light"; + const isThemed = themeModes !== null; + const baseAppearance = customTheme?.appearance ?? builtInModes?.[0] ?? "light"; const systemMode = prefersDark ? "dark" : "light"; const storedAppearanceMode = window.localStorage.getItem( THEME_APPEARANCE_MODE_STORAGE_KEY, @@ -342,7 +363,7 @@ } // A resolved half is a selection even when the base theme is gone // (deleting the base leaves the mix pointing at a real theme). - const hasSelection = halfThemeId !== null || (isKnownTheme && storedTheme !== null); + const hasSelection = halfThemeId !== null || isThemed; if (hasSelection) { document.documentElement.dataset.themeSelected = "true"; } else { @@ -367,19 +388,20 @@ } setThemeColor(chromeColor); } catch { - // Mirror the runtime's storage-failure fallback: follow the OS. - let prefersDark = true; - try { - prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - } catch { - // Keep the dark default when even matchMedia is unavailable. + // Storage failures still wear the install default. The runtime uses + // the same fallback when it mounts, so this path does not flash a + // system-colored splash first. + const fallback = BUILT_IN_THEME_PALETTES[DEFAULT_THEME_ID].dark; + document.documentElement.dataset.themeId = DEFAULT_THEME_ID; + document.documentElement.dataset.themeSelected = "true"; + document.documentElement.classList.add("dark"); + document.documentElement.style.backgroundColor = fallback.chrome; + for (const [name, value] of Object.entries(fallback)) { + if (name !== "chrome") { + document.documentElement.style.setProperty(`--boot-${name}`, value); + } } - delete document.documentElement.dataset.themeId; - delete document.documentElement.dataset.themeSelected; - document.documentElement.classList.toggle("dark", prefersDark); - const fallbackColor = prefersDark ? DARK_BACKGROUND : LIGHT_BACKGROUND; - document.documentElement.style.backgroundColor = fallbackColor; - setThemeColor(fallbackColor); + setThemeColor(fallback.chrome); } })(); diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index a2261e91f765..28d70c425633 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -17,6 +17,7 @@ import { type ThemeDefinition, type ThemeHalves, T3_CHAT_THEME, + VERCEL_DARK_THEME, EMBER_THEME, GROVE_THEME, IRIS_THEME, @@ -47,6 +48,7 @@ import { import { ThemeWireframe } from "./ThemeWireframe"; const MAINTAINER_THEMES: ReadonlyArray = [ + VERCEL_DARK_THEME, T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts index ab87388ff298..09bd5f5b8c4f 100644 --- a/apps/web/src/hooks/useTheme.test.ts +++ b/apps/web/src/hooks/useTheme.test.ts @@ -82,6 +82,16 @@ describe("theme failure handling", () => { expect(readThemePreference()).toBe("t3-chat"); }); + it("defaults fresh installs to Vercel Dark", async () => { + vi.stubGlobal("window", { + localStorage: createStorage(), + }); + + const { readThemePreference } = await import("./useTheme"); + + expect(readThemePreference()).toBe("vercel-dark"); + }); + it("falls back during initial theme application and logs only safe attributes", async () => { const cause = new Error("private browsing storage failure"); const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 68de5d474cae..497cd52052aa 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useSyncExternalStore } from "react"; import { applyThemePalette, CUSTOM_THEMES_STORAGE_KEY, + DEFAULT_THEME_PREFERENCE, invalidateCustomThemes, canonicalThemePreference, isKnownThemePreference, @@ -36,10 +37,10 @@ type DesktopThemeBridge = Pick; const STORAGE_KEY = "t3code:theme"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { - theme: "system", + theme: DEFAULT_THEME_PREFERENCE, systemDark: false, - followSystem: true, - appearanceMode: "system", + followSystem: false, + appearanceMode: "dark", themeHalves: null, }; diff --git a/apps/web/src/themeBoot.test.ts b/apps/web/src/themeBoot.test.ts index d6137e643818..b8d2be33081b 100644 --- a/apps/web/src/themeBoot.test.ts +++ b/apps/web/src/themeBoot.test.ts @@ -3,12 +3,14 @@ import { describe, expect, it, vi } from "vite-plus/test"; import indexHtml from "../index.html?raw"; import { CUSTOM_THEMES_STORAGE_KEY, + DEFAULT_THEME_PREFERENCE, getDefaultThemeColors, getThemeColorsForMode, invalidateCustomThemes, isKnownThemePreference, resolveThemeAppearance, T3_CHAT_THEME, + VERCEL_DARK_THEME, EMBER_THEME, GROVE_THEME, IRIS_THEME, @@ -107,7 +109,7 @@ function runtimeResolvedAppearance( invalidateCustomThemes(); try { const raw = storage[THEME_STORAGE_KEY] ?? null; - const theme = raw !== null && isKnownThemePreference(raw) ? raw : "system"; + const theme = raw !== null && isKnownThemePreference(raw) ? raw : DEFAULT_THEME_PREFERENCE; const followRaw = storage[THEME_FOLLOW_SYSTEM_STORAGE_KEY] ?? null; const appearanceRaw = storage[THEME_APPEARANCE_MODE_STORAGE_KEY] ?? null; const appearanceMode = @@ -149,6 +151,7 @@ describe("index.html boot script", () => { prefersDark: boolean; }> = [ { name: "no stored preference on a dark OS", storage: {}, prefersDark: true }, + { name: "no stored preference on a light OS", storage: {}, prefersDark: false }, { name: "T3 Chat follows a dark OS", storage: { [THEME_STORAGE_KEY]: "t3-chat", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" }, @@ -275,6 +278,19 @@ describe("index.html boot script", () => { expect(aurora.metaContent).toBe(DEFAULT_DARK_CHROME); }); + it("uses Vercel Dark as the fresh-install default", () => { + const boot = runBootScript({ storage: {}, prefersDark: false }); + + expect(boot.themeId).toBe(VERCEL_DARK_THEME.id); + expect(boot.themeSelected).toBe("true"); + expect(boot.isDark).toBe(true); + expect(boot.bootVariables["--boot-background"]).toBe(VERCEL_DARK_THEME.colors.canvas); + expect(boot.bootVariables["--boot-foreground"]).toBe(VERCEL_DARK_THEME.colors.text); + expect(boot.bootVariables["--boot-accent"]).toBe(VERCEL_DARK_THEME.colors.accent); + expect(boot.backgroundColor).toBe(VERCEL_DARK_THEME.colors.chrome); + expect(boot.metaContent).toBe(VERCEL_DARK_THEME.colors.chrome); + }); + // Asserting against the real palette definitions (not literals) turns the // boot script's hand-maintained copy into a CI-enforced contract: any // palette change breaks this test until the copy in index.html is updated. @@ -301,6 +317,18 @@ describe("index.html boot script", () => { expect(boot.metaContent).toBe(colors!.chrome); } } + + const boot = runBootScript({ + storage: { [THEME_STORAGE_KEY]: VERCEL_DARK_THEME.id }, + prefersDark: false, + }); + expect(boot.themeId).toBe(VERCEL_DARK_THEME.id); + expect(boot.isDark).toBe(true); + expect(boot.bootVariables["--boot-background"]).toBe(VERCEL_DARK_THEME.colors.canvas); + expect(boot.bootVariables["--boot-foreground"]).toBe(VERCEL_DARK_THEME.colors.text); + expect(boot.bootVariables["--boot-accent"]).toBe(VERCEL_DARK_THEME.colors.accent); + expect(boot.backgroundColor).toBe(VERCEL_DARK_THEME.colors.chrome); + expect(boot.metaContent).toBe(VERCEL_DARK_THEME.colors.chrome); }); it("applies the matching half of an automatic mix to the splash", () => { @@ -417,7 +445,7 @@ describe("index.html boot script", () => { expect(boot.metaContent).toBe(DEFAULT_DARK_CHROME); }); - it("ignores malformed custom theme entries before applying a splash", () => { + it("falls back to Vercel Dark for malformed custom theme entries", () => { const boot = runBootScript({ storage: { [THEME_STORAGE_KEY]: "broken", @@ -428,28 +456,30 @@ describe("index.html boot script", () => { prefersDark: false, }); - expect(boot.themeId).toBeUndefined(); - expect(boot.themeSelected).toBeUndefined(); - expect(boot.backgroundColor).toBe("#ffffff"); - expect(boot.metaContent).toBe("#ffffff"); + expect(boot.themeId).toBe(VERCEL_DARK_THEME.id); + expect(boot.themeSelected).toBe("true"); + expect(boot.backgroundColor).toBe(VERCEL_DARK_THEME.colors.chrome); + expect(boot.metaContent).toBe(VERCEL_DARK_THEME.colors.chrome); }); - it("leaves unknown preferences unthemed so the runtime default applies", () => { + it("falls back to Vercel Dark for unknown preferences", () => { const boot = runBootScript({ storage: { [THEME_STORAGE_KEY]: "gone-theme" }, prefersDark: true, }); - expect(boot.themeId).toBeUndefined(); - expect(boot.themeSelected).toBeUndefined(); + expect(boot.themeId).toBe(VERCEL_DARK_THEME.id); + expect(boot.themeSelected).toBe("true"); expect(boot.isDark).toBe(true); }); - it("follows the OS appearance when storage is unavailable", () => { + it("uses Vercel Dark when storage is unavailable", () => { const light = runBootScript({ storageThrows: true, prefersDark: false }); - expect(light.isDark).toBe(false); - expect(light.themeId).toBeUndefined(); + expect(light.isDark).toBe(true); + expect(light.themeId).toBe(VERCEL_DARK_THEME.id); + expect(light.themeSelected).toBe("true"); const dark = runBootScript({ storageThrows: true, prefersDark: true }); expect(dark.isDark).toBe(true); + expect(dark.themeId).toBe(VERCEL_DARK_THEME.id); }); }); diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 0e47cfdd9dc9..c830cdf3d2c5 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -21,6 +21,7 @@ import { subscribeToThemePreview, subscribeToCustomThemes, T3_CHAT_THEME, + VERCEL_DARK_THEME, EMBER_THEME, GROVE_THEME, IRIS_THEME, @@ -321,6 +322,43 @@ describe("theme files", () => { } }); + it("keeps Vercel Dark on the exact source palette", () => { + expect(VERCEL_DARK_THEME).toMatchObject({ + id: "vercel-dark", + label: "Vercel Dark", + appearance: "dark", + colors: { + canvas: "#000000", + chrome: "#000000", + surface: "#101010", + surfaceRaised: "#1f1f1f", + text: "#ededed", + textMuted: "#a1a1a1", + border: "#1f1f1f", + focus: "#676767", + accent: "#62c073", + secondaryForeground: "#c472fb", + error: "#f75f8f", + errorSurface: "#1e0b11", + warning: "#ff9907", + messageAction: "#52a8ff", + codeBackground: "#101010", + terminalBackground: "#000000", + }, + }); + expect(getThemeDefinition(VERCEL_DARK_THEME.id)).toBe(VERCEL_DARK_THEME); + expect(getThemeModes(VERCEL_DARK_THEME)).toEqual(["dark"]); + expect( + contrastRatio(VERCEL_DARK_THEME.colors.text, VERCEL_DARK_THEME.colors.canvas), + ).toBeGreaterThanOrEqual(7); + expect( + contrastRatio( + VERCEL_DARK_THEME.colors.messageActionForeground, + VERCEL_DARK_THEME.colors.messageAction, + ), + ).toBeGreaterThanOrEqual(4.5); + }); + it("includes the dual-mode maintainer themes", () => { for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) { expect(getThemeDefinition(theme.id)).toBe(theme); @@ -524,7 +562,14 @@ describe("stored theme preferences", () => { }); it("recognizes only preferences the runtime can render", () => { - for (const preference of ["light", "dark", "system", T3_CHAT_THEME.id, GROVE_THEME.id]) { + for (const preference of [ + "light", + "dark", + "system", + VERCEL_DARK_THEME.id, + T3_CHAT_THEME.id, + GROVE_THEME.id, + ]) { expect(isKnownThemePreference(preference)).toBe(true); } expect(isKnownThemePreference(`${GROVE_THEME.id}:dark`)).toBe(false); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index d1db01ffeb4e..f05050705c80 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -2,6 +2,9 @@ import * as Schema from "effect/Schema"; export const T3_CHAT_THEME_ID = "t3-chat" as const; export const T3_CHAT_THEME_LABEL = "T3 Chat"; +export const VERCEL_DARK_THEME_ID = "vercel-dark" as const; +export const VERCEL_DARK_THEME_LABEL = "Vercel Dark"; +export const DEFAULT_THEME_PREFERENCE = VERCEL_DARK_THEME_ID; export const GROVE_THEME_ID = "grove" as const; export const GROVE_THEME_LABEL = "Grove"; export const OCEAN_THEME_ID = "ocean" as const; @@ -118,6 +121,7 @@ const RESERVED_THEME_IDS = new Set([ "light", "dark", T3_CHAT_THEME_ID, + VERCEL_DARK_THEME_ID, GROVE_THEME_ID, OCEAN_THEME_ID, EMBER_THEME_ID, @@ -467,6 +471,71 @@ const T3_CHAT_DARK_COLORS: ThemeColors = { terminalScrollbarHover: "#423a45", }; +/** + * Vercel's dark palette, mapped directly from the equivalent OpenCode roles. + * Every value is one of the source palette's fixed colors so the theme does + * not drift through generated tints or opacity blends. + */ +const VERCEL_DARK_COLORS: ThemeColors = { + canvas: "#000000", + chrome: "#000000", + toolbar: "#000000", + toolbarForeground: "#ededed", + toolbarBorder: "#1f1f1f", + toolbarControl: "#101010", + toolbarControlForeground: "#ededed", + toolbarControlHover: "#1f1f1f", + surface: "#101010", + surfaceRaised: "#1f1f1f", + surfaceOverlay: "#1f1f1f", + text: "#ededed", + textMuted: "#a1a1a1", + border: "#1f1f1f", + input: "#1f1f1f", + focus: "#676767", + accent: "#62c073", + accentForeground: "#000000", + secondary: "#1f1f1f", + secondaryForeground: "#c472fb", + muted: "#1f1f1f", + mutedForeground: "#a1a1a1", + placeholder: "#a1a1a1", + secondaryLabel: "#a1a1a1", + iconMuted: "#a1a1a1", + error: "#f75f8f", + errorForeground: "#f75f8f", + errorSurface: "#1e0b11", + warning: "#ff9907", + warningForeground: "#ff9907", + warningSurface: "#101010", + update: "#52a8ff", + updateForeground: "#52a8ff", + updateSurface: "#101010", + accentSurface: "#1f1f1f", + accentSurfaceForeground: "#62c073", + messageSurface: "#101010", + messageForeground: "#ededed", + messageAction: "#52a8ff", + messageActionForeground: "#000000", + messageActionHover: "#52a8ff", + codeBackground: "#101010", + codeForeground: "#ededed", + sidebar: "#000000", + sidebarForeground: "#ededed", + sidebarMutedForeground: "#a1a1a1", + sidebarControlSurface: "#101010", + sidebarRowHover: "#101010", + sidebarRowActive: "#1f1f1f", + sidebarRowSelected: "#1f1f1f", + sidebarBorder: "#1f1f1f", + terminalBackground: "#000000", + terminalForeground: "#ededed", + terminalCursor: "#52a8ff", + terminalSelection: "#1f1f1f", + terminalScrollbar: "#676767", + terminalScrollbarHover: "#a1a1a1", +}; + /** * The palette T3 Code wears with no theme installed, captured from the app's * stock tokens (index.css) so a draft seeded from the default look paints the @@ -1301,6 +1370,13 @@ export const T3_CHAT_THEME: ThemeDefinition = { }, }; +export const VERCEL_DARK_THEME: ThemeDefinition = { + id: VERCEL_DARK_THEME_ID, + label: VERCEL_DARK_THEME_LABEL, + appearance: "dark", + colors: VERCEL_DARK_COLORS, +}; + /** Theme-file defaults follow the flagship palette for the requested mode. */ export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors { return appearance === "dark" ? T3_CHAT_DARK_COLORS : T3_CHAT_LIGHT_COLORS; @@ -1392,6 +1468,7 @@ export const IRIS_THEME: ThemeDefinition = { }; const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray = [ + VERCEL_DARK_THEME, T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, From 432561a6b4d5076f936f9a21d28ad147eb5d24ea Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Wed, 12 Aug 2026 16:43:16 +0530 Subject: [PATCH 7/7] feat(web): notify agent updates with Web Push --- apps/web/public/web-push-service-worker.js | 74 + .../settings/NotificationsSettingsPanel.tsx | 80 +- .../DesktopThreadNotifications.tsx | 36 +- .../desktopNotifications.logic.ts | 35 +- .../web/src/notifications/notificationCopy.ts | 125 +- apps/web/src/notifications/webPush.ts | 180 ++ docs/README.md | 1 + docs/user/notifications.md | 13 + .../migration.sql | 16 + .../snapshot.json | 1689 +++++++++++++++++ infra/relay/package.json | 1 + infra/relay/src/Config.ts | 7 + .../AgentActivityPublisher.test.ts | 33 + .../agentActivity/AgentActivityPublisher.ts | 38 +- .../src/agentActivity/ApnsDeliveries.test.ts | 5 + .../agentActivity/ApnsDeliveryQueue.test.ts | 5 + .../agentActivity/MobileRegistrations.test.ts | 9 + .../agentActivity/WebPushDeliveries.test.ts | 194 ++ .../src/agentActivity/WebPushDeliveries.ts | 173 ++ .../src/agentActivity/WebPushSubscriptions.ts | 174 ++ infra/relay/src/auth/RelayTokens.test.ts | 5 + .../environments/EnvironmentConnector.test.ts | 5 + .../environments/EnvironmentLinker.test.ts | 5 + .../EnvironmentPublishSignatures.test.ts | 5 + .../ManagedEndpointProvider.test.ts | 5 + infra/relay/src/http/Api.test.ts | 5 + infra/relay/src/http/Api.ts | 46 + infra/relay/src/persistence/schema.ts | 23 + infra/relay/src/worker.ts | 98 +- .../src/connection/resolver.test.ts | 3 + .../src/relay/discovery.test.ts | 6 + .../client-runtime/src/relay/managedRelay.ts | 103 + .../src/relay/managedRelayState.test.ts | 3 + packages/contracts/src/relay.test.ts | 41 +- packages/contracts/src/relay.ts | 79 + packages/contracts/src/settings.ts | 12 +- packages/shared/package.json | 4 + packages/shared/src/agentNotifications.ts | 109 ++ pnpm-lock.yaml | 8 + 39 files changed, 3249 insertions(+), 204 deletions(-) create mode 100644 apps/web/public/web-push-service-worker.js create mode 100644 apps/web/src/notifications/webPush.ts create mode 100644 docs/user/notifications.md create mode 100644 infra/relay/migrations/postgres/20260812094738_web_push_subscriptions/migration.sql create mode 100644 infra/relay/migrations/postgres/20260812094738_web_push_subscriptions/snapshot.json create mode 100644 infra/relay/src/agentActivity/WebPushDeliveries.test.ts create mode 100644 infra/relay/src/agentActivity/WebPushDeliveries.ts create mode 100644 infra/relay/src/agentActivity/WebPushSubscriptions.ts create mode 100644 packages/shared/src/agentNotifications.ts diff --git a/apps/web/public/web-push-service-worker.js b/apps/web/public/web-push-service-worker.js new file mode 100644 index 000000000000..fd29f928544a --- /dev/null +++ b/apps/web/public/web-push-service-worker.js @@ -0,0 +1,74 @@ +const PAYLOAD_VERSION = 1; + +function notificationPayload(value) { + if (typeof value !== "object" || value === null || value.version !== PAYLOAD_VERSION) { + return null; + } + const required = ["title", "body", "path", "tag", "updatedAt"]; + if (required.some((key) => typeof value[key] !== "string")) { + return null; + } + if (typeof value.silent !== "boolean" || !value.path.startsWith("/")) { + return null; + } + return value; +} + +self.addEventListener("push", (event) => { + event.waitUntil( + (async () => { + let data; + try { + data = event.data?.json(); + } catch { + return; + } + const payload = notificationPayload(data); + if (!payload) { + return; + } + + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + const watchingThread = windows.some((client) => { + const url = new URL(client.url); + return client.focused && url.pathname === payload.path; + }); + if (watchingThread) { + return; + } + + const timestamp = Date.parse(payload.updatedAt); + await self.registration.showNotification(payload.title, { + body: payload.body, + icon: "/apple-touch-icon.png", + badge: "/favicon-32x32.png", + data: { path: payload.path }, + tag: payload.tag, + renotify: true, + silent: payload.silent, + ...(Number.isNaN(timestamp) ? {} : { timestamp }), + ...(payload.silent ? {} : { vibrate: [120, 60, 180] }), + }); + })(), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const path = event.notification.data?.path; + if (typeof path !== "string" || !path.startsWith("/")) { + return; + } + event.waitUntil( + (async () => { + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + const existing = windows[0]; + if (existing) { + await existing.navigate(path); + await existing.focus(); + return; + } + await self.clients.openWindow(path); + })(), + ); +}); diff --git a/apps/web/src/components/settings/NotificationsSettingsPanel.tsx b/apps/web/src/components/settings/NotificationsSettingsPanel.tsx index ba63c7df89bf..613330054797 100644 --- a/apps/web/src/components/settings/NotificationsSettingsPanel.tsx +++ b/apps/web/src/components/settings/NotificationsSettingsPanel.tsx @@ -1,8 +1,16 @@ import { BellIcon } from "lucide-react"; +import { useAtomValue } from "@effect/atom-react"; +import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings"; +import { useState } from "react"; import { isElectron } from "~/env"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { + disableWebPushNotifications, + enableWebPushNotifications, + isWebPushSupported, +} from "~/notifications/webPush"; import { Switch } from "../ui/switch"; import { SettingResetButton, @@ -47,7 +55,7 @@ const NOTIFICATION_TOGGLES: ReadonlyArray<{ { key: "desktopNotificationSound", searchId: "notification-sound", - description: "Play the system notification sound.", + description: "Play an alert sound.", resetLabel: "notification sound", }, ]; @@ -55,47 +63,65 @@ const NOTIFICATION_TOGGLES: ReadonlyArray<{ export function NotificationsSettingsPanel() { const settings = useClientSettings(); const updateSettings = useUpdateClientSettings(); + const relaySession = useAtomValue(managedRelaySessionAtom); + const [webPushError, setWebPushError] = useState(null); + const [webPushPending, setWebPushPending] = useState(false); + const enabledKey = isElectron + ? ("desktopNotificationsEnabled" as const) + : ("webPushNotificationsEnabled" as const); + const notificationsEnabled = settings[enabledKey]; + const notificationsDisabled = isElectron && !notificationsEnabled; + const webPushAvailable = isWebPushSupported() && relaySession !== null; - if (!isElectron) { - return ( - - }> -

- Native notifications are available in the desktop app. -

-
-
- ); - } - - const notificationsDisabled = !settings.desktopNotificationsEnabled; + const setNotificationsEnabled = async (checked: boolean) => { + if (isElectron) { + updateSettings({ desktopNotificationsEnabled: checked }); + return; + } + setWebPushPending(true); + setWebPushError(null); + try { + if (checked) { + await enableWebPushNotifications({ + ...settings, + webPushNotificationsEnabled: true, + }); + } else { + await disableWebPushNotifications(); + } + updateSettings({ webPushNotificationsEnabled: checked }); + } catch (error) { + setWebPushError(error instanceof Error ? error.message : "Web Push setup failed."); + } finally { + setWebPushPending(false); + } + }; return ( }> - updateSettings({ - desktopNotificationsEnabled: - DEFAULT_CLIENT_SETTINGS.desktopNotificationsEnabled, - }) - } + onClick={() => void setNotificationsEnabled(DEFAULT_CLIENT_SETTINGS[enabledKey])} /> ) : null } control={ - updateSettings({ desktopNotificationsEnabled: Boolean(checked) }) - } + checked={notificationsEnabled} + disabled={webPushPending || (!isElectron && !webPushAvailable)} + onCheckedChange={(checked) => void setNotificationsEnabled(Boolean(checked))} aria-label="Enable notifications" /> } diff --git a/apps/web/src/notifications/DesktopThreadNotifications.tsx b/apps/web/src/notifications/DesktopThreadNotifications.tsx index f6d4d3c53897..93fe0924004e 100644 --- a/apps/web/src/notifications/DesktopThreadNotifications.tsx +++ b/apps/web/src/notifications/DesktopThreadNotifications.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef } from "react"; +import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; +import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import type { ClientSettings } from "@t3tools/contracts/settings"; import type { ScopedThreadRef } from "@t3tools/contracts"; import type { @@ -7,7 +9,8 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import { getClientSettings } from "~/hooks/useSettings"; +import { isElectron } from "~/env"; +import { getClientSettings, useClientSettings } from "~/hooks/useSettings"; import { useActiveThreadRefFromRoute } from "~/hooks/useActiveThreadRef"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { environmentThreadDetails, environmentThreadShells } from "~/state/threads"; @@ -30,6 +33,7 @@ import { THREAD_ALERT_FOCUSED_TTL_MS, THREAD_ALERT_MAX_TTL_MS, } from "./threadAlertStore"; +import { reconcileWebPushRegistration } from "./webPush"; /** * Latest assistant text for a thread, but only when that thread's detail atom @@ -239,6 +243,29 @@ function DesktopThreadNotifications() { return null; } +function WebPushRegistrationHost() { + const settings = useClientSettings(); + const relaySession = useAtomValue(managedRelaySessionAtom); + + useEffect(() => { + if (isElectron) { + return; + } + void reconcileWebPushRegistration(settings).catch((error: unknown) => { + console.error("[WEB_PUSH] subscription reconciliation failed", { error }); + }); + }, [ + relaySession?.accountId, + settings.desktopNotificationSound, + settings.desktopNotifyApprovalNeeded, + settings.desktopNotifyTaskCompleted, + settings.desktopNotifyTaskFailed, + settings.webPushNotificationsEnabled, + ]); + + return null; +} + /** * Alerts for agent task transitions: the native OS banner on desktop, plus the * chime and sidebar highlights that survive Do Not Disturb. @@ -249,5 +276,10 @@ function DesktopThreadNotifications() { * discard them for no reason. */ export function DesktopThreadNotificationsHost() { - return ; + return ( + <> + + + + ); } diff --git a/apps/web/src/notifications/desktopNotifications.logic.ts b/apps/web/src/notifications/desktopNotifications.logic.ts index 52254d8d3a55..13c6144113a6 100644 --- a/apps/web/src/notifications/desktopNotifications.logic.ts +++ b/apps/web/src/notifications/desktopNotifications.logic.ts @@ -4,29 +4,10 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { projectThreadAwareness, type AgentAwarenessPhase } from "@t3tools/shared/agentAwareness"; +import { agentNotificationKind } from "@t3tools/shared/agentNotifications"; import { notificationBody, notificationTitle } from "./notificationCopy"; -/** - * Phases a thread must be leaving for its next phase to be worth announcing. - * Requiring an active predecessor is what makes a thread that is merely - * *observed* as finished (a cached snapshot, a resync, a reconnect) stay quiet: - * nothing happened, we just learned about it. - */ -const ACTIVE_PHASES: ReadonlySet = new Set([ - "starting", - "running", - "waiting_for_input", - "waiting_for_approval", -]); - -/** Phases that can precede an approval prompt and still count as progress. */ -const PRE_APPROVAL_PHASES: ReadonlySet = new Set([ - "starting", - "running", - "waiting_for_input", -]); - export interface ThreadNotificationSettings { readonly enabled: boolean; readonly taskCompleted: boolean; @@ -108,19 +89,7 @@ export function notifiableKind( previousPhase: AgentAwarenessPhase | null, nextPhase: AgentAwarenessPhase | null, ): DesktopNotificationKind | null { - if (previousPhase === null || nextPhase === null) { - return null; - } - if (nextPhase === "completed" && ACTIVE_PHASES.has(previousPhase)) { - return "task-completed"; - } - if (nextPhase === "failed" && ACTIVE_PHASES.has(previousPhase)) { - return "task-failed"; - } - if (nextPhase === "waiting_for_approval" && PRE_APPROVAL_PHASES.has(previousPhase)) { - return "approval-needed"; - } - return null; + return agentNotificationKind(previousPhase, nextPhase); } function isNotificationKindEnabled( diff --git a/apps/web/src/notifications/notificationCopy.ts b/apps/web/src/notifications/notificationCopy.ts index 16772b68fb11..1c0d1ddeabad 100644 --- a/apps/web/src/notifications/notificationCopy.ts +++ b/apps/web/src/notifications/notificationCopy.ts @@ -1,119 +1,6 @@ -import type { DesktopNotificationKind } from "@t3tools/contracts"; - -/** Notification titles. The kind leads, so the banner is scannable at a glance. */ -const NOTIFICATION_TITLES: Readonly> = { - "task-completed": "Completed", - "task-failed": "Failed", - "approval-needed": "Approval Required", -}; - -/** - * A title long enough to be elided by the OS defeats the point, and the - * project name is the part that gets cut. Long project names are shortened - * from the middle so both ends stay recognisable. - */ -const MAX_TITLE_PROJECT_LENGTH = 28; - -function shortenProjectName(projectTitle: string): string { - const trimmed = projectTitle.trim(); - if (trimmed.length <= MAX_TITLE_PROJECT_LENGTH) { - return trimmed; - } - const head = trimmed.slice(0, MAX_TITLE_PROJECT_LENGTH - 12).trimEnd(); - const tail = trimmed.slice(-9).trimStart(); - return `${head}…${tail}`; -} - -/** - * Notification title: what happened, then which project it happened in. - * - * The project name answers "which of my running agents is this?" without - * opening the app, which matters most when several are working at once. It is - * omitted rather than left dangling when the project title is unavailable. - */ -export function notificationTitle( - kind: DesktopNotificationKind, - projectTitle?: string | null, -): string { - const kindLabel = NOTIFICATION_TITLES[kind]; - const project = projectTitle === null || projectTitle === undefined ? "" : projectTitle.trim(); - return project.length > 0 ? `${kindLabel} - ${shortenProjectName(project)}` : kindLabel; -} - -/** macOS elides past roughly two lines; this also keeps the IPC payload small. */ -const MAX_BODY_LENGTH = 180; - -/** - * Flattens assistant markdown into the plain text a notification can render. - * - * Notification bodies are plain text: any markdown that survives shows up as - * literal `**` and backticks in the banner. This strips the syntax rather than - * the content, so the reader sees the sentence the agent wrote. - * - * Deliberately regex-based and not a full markdown parser: this runs on the - * notification path for a single leading excerpt, and pulling a parser in for - * it would cost far more than the fidelity is worth. - */ -export function toPlainNotificationText(markdown: string): string { - let text = markdown; - - // Fenced code blocks: keep a marker rather than dumping code into a banner. - text = text.replace(/```[\s\S]*?```/g, " (code) "); - text = text.replace(/~~~[\s\S]*?~~~/g, " (code) "); - - // Images before links: the image syntax is a link with a leading `!`. - text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1"); - text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1"); - - // Inline code, bold, italic, strikethrough. Emphasis runs last so `**x**` - // is not half-consumed by the single-character rules. - text = text.replace(/`([^`]+)`/g, "$1"); - text = text.replace(/~~([^~]+)~~/g, "$1"); - text = text.replace(/\*\*([^*]+)\*\*/g, "$1"); - text = text.replace(/__([^_]+)__/g, "$1"); - text = text.replace(/\*([^*]+)\*/g, "$1"); - text = text.replace(/(^|\s)_([^_]+)_(?=\s|$)/g, "$1$2"); - - // Block syntax at line starts: headings, quotes, list bullets, tasks. - text = text.replace(/^\s{0,3}#{1,6}\s+/gm, ""); - text = text.replace(/^\s{0,3}>\s?/gm, ""); - text = text.replace(/^\s{0,3}[-*+]\s+\[[ xX]\]\s+/gm, ""); - text = text.replace(/^\s{0,3}[-*+]\s+/gm, ""); - text = text.replace(/^\s{0,3}\d+\.\s+/gm, ""); - // Horizontal rules, once their characters can no longer read as emphasis. - text = text.replace(/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/gm, " "); - - // HTML comments, then any remaining tags. - text = text.replace(//g, " "); - text = text.replace(/<[^>]+>/g, ""); - - // Collapse all whitespace: a banner is one flowing line, so newlines from - // the original layout would otherwise leave ragged gaps. - return text.replace(/\s+/g, " ").trim(); -} - -export function truncateNotificationBody(value: string): string { - const trimmed = value.trim(); - if (trimmed.length <= MAX_BODY_LENGTH) { - return trimmed; - } - return `${trimmed.slice(0, MAX_BODY_LENGTH - 1).trimEnd()}…`; -} - -/** - * Body text for a notification: the start of what the agent actually said, - * falling back to the thread title when no response text is available (the - * thread was never opened this session, so its messages are not in memory). - */ -export function notificationBody(input: { - readonly responseText: string | null; - readonly threadTitle: string; - readonly fallbackHeadline: string; -}): string { - const plain = input.responseText === null ? "" : toPlainNotificationText(input.responseText); - if (plain.length > 0) { - return truncateNotificationBody(plain); - } - const title = input.threadTitle.trim(); - return truncateNotificationBody(title.length > 0 ? title : input.fallbackHeadline); -} +export { + notificationBody, + notificationTitle, + toPlainNotificationText, + truncateNotificationBody, +} from "@t3tools/shared/agentNotifications"; diff --git a/apps/web/src/notifications/webPush.ts b/apps/web/src/notifications/webPush.ts new file mode 100644 index 000000000000..3dad9efde004 --- /dev/null +++ b/apps/web/src/notifications/webPush.ts @@ -0,0 +1,180 @@ +import { ManagedRelay, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import type { ClientSettings } from "@t3tools/contracts/settings"; +import type { + RelayWebPushPreferences, + RelayWebPushRegistrationRequest, + RelayWebPushSubscription, +} from "@t3tools/contracts/relay"; +import * as Effect from "effect/Effect"; + +import { readManagedRelayClerkToken } from "~/cloud/managedAuth"; +import { runtime } from "~/lib/runtime"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; + +const WEB_PUSH_SERVICE_WORKER_PATH = "/web-push-service-worker.js"; +const WEB_PUSH_SERVICE_WORKER_SCOPE = "/"; + +export function isWebPushSupported(): boolean { + return ( + typeof window !== "undefined" && + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window && + window.isSecureContext + ); +} + +function decodeBase64Url(value: string): ArrayBuffer { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); + const decoded = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")); + return Uint8Array.from(decoded, (character) => character.charCodeAt(0)).buffer; +} + +function encodeBase64Url(value: ArrayBuffer): string { + let binary = ""; + for (const byte of new Uint8Array(value)) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); +} + +async function subscriptionId(endpoint: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(endpoint)); + return encodeBase64Url(digest); +} + +function webPushPreferences(settings: ClientSettings): RelayWebPushPreferences { + return { + notifyOnApproval: settings.desktopNotifyApprovalNeeded, + notifyOnCompletion: settings.desktopNotifyTaskCompleted, + notifyOnFailure: settings.desktopNotifyTaskFailed, + soundEnabled: settings.desktopNotificationSound, + }; +} + +function serializeSubscription(subscription: PushSubscription): RelayWebPushSubscription { + const p256dh = subscription.getKey("p256dh"); + const auth = subscription.getKey("auth"); + if (p256dh === null || auth === null) { + throw new Error("The browser returned a Web Push subscription without encryption keys."); + } + return { + endpoint: subscription.endpoint, + expirationTime: subscription.expirationTime, + keys: { + p256dh: encodeBase64Url(p256dh), + auth: encodeBase64Url(auth), + }, + }; +} + +async function relayToken(): Promise { + const token = await readManagedRelayClerkToken(); + if (!token) { + throw new Error("Sign in to T3 Connect before enabling Web Push."); + } + return token; +} + +async function registerSubscription( + subscription: PushSubscription, + settings: ClientSettings, +): Promise { + const payload: RelayWebPushRegistrationRequest = { + subscriptionId: await subscriptionId(subscription.endpoint), + label: navigator.platform ? `Web browser on ${navigator.platform}` : "Web browser", + subscription: serializeSubscription(subscription), + preferences: webPushPreferences(settings), + }; + const clerkToken = await relayToken(); + await runtime.runPromise( + ManagedRelay.ManagedRelayClient.pipe( + Effect.flatMap((client) => client.registerWebPush({ clerkToken, payload })), + ), + ); +} + +async function serviceWorkerRegistration(): Promise { + await navigator.serviceWorker.register(WEB_PUSH_SERVICE_WORKER_PATH, { + scope: WEB_PUSH_SERVICE_WORKER_SCOPE, + }); + return navigator.serviceWorker.ready; +} + +async function ensureSubscription( + registration: ServiceWorkerRegistration, +): Promise { + const existing = await registration.pushManager.getSubscription(); + if (existing) { + return existing; + } + const { publicKey } = await runtime.runPromise( + ManagedRelay.ManagedRelayClient.pipe( + Effect.flatMap((client) => client.getWebPushVapidPublicKey), + ), + ); + return registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: decodeBase64Url(publicKey), + }); +} + +export async function enableWebPushNotifications(settings: ClientSettings): Promise { + if (!isWebPushSupported()) { + throw new Error("This browser does not support Web Push."); + } + await relayToken(); + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + throw new Error("Notification permission was not granted."); + } + const registration = await serviceWorkerRegistration(); + const subscription = await ensureSubscription(registration); + try { + await registerSubscription(subscription, settings); + } catch (error) { + await subscription.unsubscribe().catch(() => false); + throw error; + } +} + +export async function disableWebPushNotifications(): Promise { + if (!isWebPushSupported()) { + return; + } + const registration = await navigator.serviceWorker.getRegistration(WEB_PUSH_SERVICE_WORKER_SCOPE); + const subscription = await registration?.pushManager.getSubscription(); + if (!subscription) { + return; + } + try { + const clerkToken = await readManagedRelayClerkToken(); + if (clerkToken) { + const id = await subscriptionId(subscription.endpoint); + await runtime.runPromise( + ManagedRelay.ManagedRelayClient.pipe( + Effect.flatMap((client) => client.unregisterWebPush({ clerkToken, subscriptionId: id })), + ), + ); + } + } finally { + await subscription.unsubscribe(); + } +} + +export async function reconcileWebPushRegistration(settings: ClientSettings): Promise { + if (!isWebPushSupported()) { + return; + } + const session = appAtomRegistry.get(managedRelaySessionAtom); + if (!session || !settings.webPushNotificationsEnabled) { + await disableWebPushNotifications(); + return; + } + if (Notification.permission !== "granted") { + return; + } + const registration = await serviceWorkerRegistration(); + const subscription = await ensureSubscription(registration); + await registerSubscription(subscription, settings); +} diff --git a/docs/README.md b/docs/README.md index 51277fd73d28..6c9cb71fe5f5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ - [Install and first run](./user/install.md) - [Permission modes](./user/permission-modes.md) - [Keyboard shortcuts](./user/keybindings.md) +- [Notifications](./user/notifications.md) - [Organizing threads](./user/thread-sidebar.md) - [Customize a project icon](./user/project-settings.md) - [Remote access](./user/remote-access.md) diff --git a/docs/user/notifications.md b/docs/user/notifications.md new file mode 100644 index 000000000000..1d3632cfc4eb --- /dev/null +++ b/docs/user/notifications.md @@ -0,0 +1,13 @@ +# Notifications + +T3 Code can notify you when an agent finishes, fails, or needs approval. Choose the events and sound under **Settings → Notifications**. + +## Web Push + +In a browser, sign in to T3 Connect and enable **Web Push**. Your browser will ask for notification permission. T3 Code then uses the browser's standard Web Push subscription, so notifications can arrive when the tab is in the background or closed. Firebase is not involved. + +The browser must support Web Push and the page must use a secure connection. Background delivery also requires the environment to be linked to the same T3 Connect account. + +When the page is running, T3 Code plays its alert chime. When the page is closed, the browser and operating system control the notification sound. Silent mode, Do Not Disturb, and browser notification settings can still suppress it. + +Turning off **Web Push** unsubscribes that browser. Opening a notification returns you to the affected thread. diff --git a/infra/relay/migrations/postgres/20260812094738_web_push_subscriptions/migration.sql b/infra/relay/migrations/postgres/20260812094738_web_push_subscriptions/migration.sql new file mode 100644 index 000000000000..e73c91aeee55 --- /dev/null +++ b/infra/relay/migrations/postgres/20260812094738_web_push_subscriptions/migration.sql @@ -0,0 +1,16 @@ +CREATE TABLE "relay_web_push_subscriptions" ( + "user_id" varchar(255), + "subscription_id" varchar(255), + "label" text DEFAULT 'Web browser' NOT NULL, + "endpoint" text NOT NULL, + "expiration_time" varchar(64), + "p256dh" text NOT NULL, + "auth" text NOT NULL, + "preferences_json" jsonb NOT NULL, + "created_at" varchar(64) NOT NULL, + "updated_at" varchar(64) NOT NULL, + CONSTRAINT "relay_web_push_subscriptions_pkey" PRIMARY KEY("user_id","subscription_id") +); +--> statement-breakpoint +CREATE UNIQUE INDEX "idx_relay_web_push_subscriptions_endpoint" ON "relay_web_push_subscriptions" ("endpoint");--> statement-breakpoint +CREATE INDEX "idx_relay_web_push_subscriptions_user" ON "relay_web_push_subscriptions" ("user_id"); \ No newline at end of file diff --git a/infra/relay/migrations/postgres/20260812094738_web_push_subscriptions/snapshot.json b/infra/relay/migrations/postgres/20260812094738_web_push_subscriptions/snapshot.json new file mode 100644 index 000000000000..2dc394046514 --- /dev/null +++ b/infra/relay/migrations/postgres/20260812094738_web_push_subscriptions/snapshot.json @@ -0,0 +1,1689 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "48c30df7-b7b8-4833-af7c-2e6bd1acb67d", + "prevIds": ["2374caff-40bf-423c-9255-55e76dddbc2a"], + "ddl": [ + { + "isRlsEnabled": false, + "name": "relay_agent_activity_rows", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_delivery_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_dpop_proofs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_credentials", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_links", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_live_activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_endpoint_allocations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_tunnel_limits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_mobile_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_web_push_subscriptions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state_json", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_job_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_suffix", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_status", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_reason", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "transport_error", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbprint", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "jti", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iat", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_hash", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'T3 Environment'", + "generated": null, + "identity": null, + "name": "environment_label", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_http_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_ws_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_provider_kind", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notifications_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "live_activities_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "managed_tunnels_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by_device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_start_queued_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_started_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_aggregate_json", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_live_activity_delivery_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_name", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dns_record_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ready_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "max_tunnels", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'iOS device'", + "generated": null, + "identity": null, + "name": "label", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ios_major_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "app_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bundle_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aps_environment", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_to_start_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preferences_json", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_web_push_subscriptions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subscription_id", + "entityType": "columns", + "schema": "public", + "table": "relay_web_push_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'Web browser'", + "generated": null, + "identity": null, + "name": "label", + "entityType": "columns", + "schema": "public", + "table": "relay_web_push_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint", + "entityType": "columns", + "schema": "public", + "table": "relay_web_push_subscriptions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiration_time", + "entityType": "columns", + "schema": "public", + "table": "relay_web_push_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "p256dh", + "entityType": "columns", + "schema": "public", + "table": "relay_web_push_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "auth", + "entityType": "columns", + "schema": "public", + "table": "relay_web_push_subscriptions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preferences_json", + "entityType": "columns", + "schema": "public", + "table": "relay_web_push_subscriptions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_web_push_subscriptions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_web_push_subscriptions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_agent_activity_rows_updated", + "entityType": "indexes", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "thread_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "source_job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_source_job", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_dpop_proofs_expires_at", + "entityType": "indexes", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "credential_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_hash", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "environment_public_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment_key", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_links_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_links" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "activity_push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_activity_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hostname", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_hostname", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tunnel_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_tunnel_name", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_to_start_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_to_start_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "endpoint", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_web_push_subscriptions_endpoint", + "entityType": "indexes", + "schema": "public", + "table": "relay_web_push_subscriptions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_web_push_subscriptions_user", + "entityType": "indexes", + "schema": "public", + "table": "relay_web_push_subscriptions" + }, + { + "columns": ["environment_id", "environment_public_key", "thread_id"], + "nameExplicit": false, + "name": "relay_agent_activity_rows_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "columns": ["thumbprint", "jti"], + "nameExplicit": false, + "name": "relay_dpop_proofs_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_environment_links_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_environment_links" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_live_activities_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_live_activities" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_managed_endpoint_allocations_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_mobile_devices_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["user_id", "subscription_id"], + "nameExplicit": false, + "name": "relay_web_push_subscriptions_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_web_push_subscriptions" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "relay_delivery_attempts_pkey", + "schema": "public", + "table": "relay_delivery_attempts", + "entityType": "pks" + }, + { + "columns": ["credential_id"], + "nameExplicit": false, + "name": "relay_environment_credentials_pkey", + "schema": "public", + "table": "relay_environment_credentials", + "entityType": "pks" + }, + { + "columns": ["user_id"], + "nameExplicit": false, + "name": "relay_managed_tunnel_limits_pkey", + "schema": "public", + "table": "relay_managed_tunnel_limits", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/infra/relay/package.json b/infra/relay/package.json index 4e9519671f22..bb711261e033 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -11,6 +11,7 @@ "dependencies": { "@clerk/backend": "catalog:", "@effect/sql-pg": "catalog:", + "@mmmike/web-push": "0.1.0", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@t3tools/client-runtime": "workspace:*", diff --git a/infra/relay/src/Config.ts b/infra/relay/src/Config.ts index e7c7d42f2ae1..65353963321d 100644 --- a/infra/relay/src/Config.ts +++ b/infra/relay/src/Config.ts @@ -14,11 +14,18 @@ export interface ApnsCredentials { readonly environment: ApnsEnvironment; } +export interface WebPushCredentials { + readonly subject: string; + readonly publicKey: string; + readonly privateKey: Redacted.Redacted; +} + export class RelayConfiguration extends Context.Service< RelayConfiguration, { readonly relayIssuer: string; readonly apns: ApnsCredentials; + readonly webPush: WebPushCredentials; readonly clerkSecretKey: Redacted.Redacted; readonly clerkPublishableKey: string; readonly clerkJwtAudience: string; diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 4215f8346544..e1d6031bbf40 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -8,6 +8,7 @@ import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; +import * as WebPushDeliveries from "./WebPushDeliveries.ts"; const state: RelayAgentActivityState = { environmentId: "env" as RelayAgentActivityState["environmentId"], @@ -128,6 +129,15 @@ function makeApnsDeliveries( }; } +function makeWebPushDeliveries( + overrides: Partial = {}, +): WebPushDeliveries.WebPushDeliveries["Service"] { + return { + sendForUser: () => Effect.void, + ...overrides, + }; +} + describe("AgentActivityPublisher", () => { it.effect("replays the latest aggregate when a Live Activity token registers", () => { const registeredTarget: LiveActivities.TargetRow = { @@ -160,6 +170,7 @@ describe("AgentActivityPublisher", () => { AgentActivityPublisher.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.succeed(WebPushDeliveries.WebPushDeliveries, makeWebPushDeliveries()), Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), Layer.succeed( @@ -218,6 +229,9 @@ describe("AgentActivityPublisher", () => { }> = []; const upserts: Array[0]> = []; + const webPushDeliveries: Array< + Parameters[0] + > = []; return Effect.gen(function* () { const result = yield* Effect.gen(function* () { @@ -233,6 +247,15 @@ describe("AgentActivityPublisher", () => { AgentActivityPublisher.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.succeed( + WebPushDeliveries.WebPushDeliveries, + makeWebPushDeliveries({ + sendForUser: (input) => + Effect.sync(() => { + webPushDeliveries.push(input); + }), + }), + ), Layer.succeed( AgentActivityRows.AgentActivityRows, makeAgentActivityRows({ @@ -296,6 +319,13 @@ describe("AgentActivityPublisher", () => { }, }, ]); + expect(webPushDeliveries).toEqual([ + { + userId: "dev:julius", + previousState: state, + nextState: state, + }, + ]); expect(result).toEqual({ ok: true, deliveries: [deliveryResult] }); }); }); @@ -327,6 +357,7 @@ describe("AgentActivityPublisher", () => { AgentActivityPublisher.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.succeed(WebPushDeliveries.WebPushDeliveries, makeWebPushDeliveries()), Layer.succeed( AgentActivityRows.AgentActivityRows, makeAgentActivityRows({ @@ -433,6 +464,7 @@ describe("AgentActivityPublisher", () => { AgentActivityPublisher.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.succeed(WebPushDeliveries.WebPushDeliveries, makeWebPushDeliveries()), Layer.succeed( AgentActivityRows.AgentActivityRows, makeAgentActivityRows({ @@ -545,6 +577,7 @@ describe("AgentActivityPublisher", () => { AgentActivityPublisher.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.succeed(WebPushDeliveries.WebPushDeliveries, makeWebPushDeliveries()), Layer.succeed( AgentActivityRows.AgentActivityRows, makeAgentActivityRows({ diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 9b3bcf2298dc..69384f404980 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -22,6 +22,7 @@ import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; +import * as WebPushDeliveries from "./WebPushDeliveries.ts"; export type AgentActivityPublishError = | AgentActivityRows.AgentActivityRowUpsertPersistenceError @@ -52,9 +53,11 @@ export const make = Effect.gen(function* () { const links = yield* EnvironmentLinks.EnvironmentLinks; const liveActivities = yield* LiveActivities.LiveActivities; const apnsDeliveries = yield* ApnsDeliveries.ApnsDeliveries; + const webPushDeliveries = yield* WebPushDeliveries.WebPushDeliveries; const publishForDeliveryUser = Effect.fnUntraced(function* (input: { readonly deliveryUser: EnvironmentLinks.AgentAwarenessDeliveryUserRecord; + readonly previousState: RelayAgentActivityState | null; readonly state: RelayAgentActivityState | null; readonly nowMs: number; }) { @@ -98,6 +101,23 @@ export const make = Effect.gen(function* () { ), { concurrency: 4 }, ); + if (input.deliveryUser.notificationsEnabled) { + yield* webPushDeliveries + .sendForUser({ + userId: input.deliveryUser.userId, + previousState: input.previousState, + nextState: input.state, + }) + .pipe( + Effect.tapError((error) => + Effect.logWarning("Web Push fan-out failed", { + errorTag: error._tag, + userId: input.deliveryUser.userId, + }), + ), + Effect.ignore, + ); + } return deliveriesByTarget.flat(); }); @@ -138,6 +158,19 @@ export const make = Effect.gen(function* () { "relay.thread_id": input.threadId, "relay.agent_activity.phase": input.state?.phase ?? "deleted", }); + const deliveryUsers = yield* links.listDeliveryUsersForEnvironment({ + environmentId: input.environmentId, + environmentPublicKey: input.environmentPublicKey, + }); + const firstDeliveryUser = deliveryUsers[0]; + const previousState = firstDeliveryUser + ? yield* rows.getForUserThread({ + userId: firstDeliveryUser.userId, + environmentId: input.environmentId, + threadId: input.threadId, + }) + : null; + if (input.state) { // Terminal states are persisted too (pruned by the cron after they // age out) so a thread that finishes while other agents are active @@ -155,16 +188,13 @@ export const make = Effect.gen(function* () { }); } - const deliveryUsers = yield* links.listDeliveryUsersForEnvironment({ - environmentId: input.environmentId, - environmentPublicKey: input.environmentPublicKey, - }); const now = yield* DateTime.now; const deliveriesByUser = yield* Effect.forEach( deliveryUsers, (deliveryUser) => publishForDeliveryUser({ deliveryUser, + previousState, state: input.state, nowMs: now.epochMilliseconds, }), diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 1c89b56da5c9..b67d5eeba5f7 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -33,6 +33,11 @@ import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; const config = RelayConfiguration.RelayConfiguration.of({ relayIssuer: "https://relay.example.test", + webPush: { + subject: "https://relay.example.test", + publicKey: "web-push-public-key", + privateKey: Redacted.make("web-push-private-key"), + }, apns: { environment: "sandbox", teamId: "team-id", diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts index 171a81f5090c..953c3513558f 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts @@ -11,6 +11,11 @@ import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; const config: RelayConfiguration.RelayConfiguration["Service"] = { relayIssuer: "https://relay.example.com", + webPush: { + subject: "https://relay.example.com", + publicKey: "web-push-public-key", + privateKey: Redacted.make("web-push-private-key"), + }, apns: { teamId: "team-1", keyId: "key-1", diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index ca39484373e9..993754571cf3 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -23,6 +23,7 @@ import * as ApnsClient from "./ApnsClient.ts"; import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; import * as MobileRegistrations from "./MobileRegistrations.ts"; +import * as WebPushDeliveries from "./WebPushDeliveries.ts"; const device: RelayDeviceRegistrationRequest = { deviceId: "device-1" as RelayDeviceRegistrationRequest["deviceId"], @@ -126,6 +127,11 @@ function makeDeliveryAttempts( const config = RelayConfiguration.RelayConfiguration.of({ relayIssuer: "https://relay.example.test", + webPush: { + subject: "https://relay.example.test", + publicKey: "web-push-public-key", + privateKey: Redacted.make("web-push-private-key"), + }, apns: { environment: "sandbox", teamId: "team-id", @@ -159,6 +165,9 @@ function makeRegistrationReplayLayer(input: { Layer.provide( Layer.mergeAll( Layer.succeed(Devices.Devices, input.devices), + Layer.succeed(WebPushDeliveries.WebPushDeliveries, { + sendForUser: () => Effect.void, + }), Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), Layer.succeed(LiveActivities.LiveActivities, input.liveActivities), diff --git a/infra/relay/src/agentActivity/WebPushDeliveries.test.ts b/infra/relay/src/agentActivity/WebPushDeliveries.test.ts new file mode 100644 index 000000000000..6a668ab3ea43 --- /dev/null +++ b/infra/relay/src/agentActivity/WebPushDeliveries.test.ts @@ -0,0 +1,194 @@ +import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeCrypto from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; + +import * as RelayConfiguration from "../Config.ts"; +import { makeWebPushNotification, WebPushDeliveries, layer } from "./WebPushDeliveries.ts"; +import * as WebPushSubscriptions from "./WebPushSubscriptions.ts"; + +const runningState: RelayAgentActivityState = { + environmentId: "environment" as RelayAgentActivityState["environmentId"], + threadId: "thread" as RelayAgentActivityState["threadId"], + projectTitle: "T3 Code", + threadTitle: "Add Web Push", + modelTitle: "gpt-5.6", + phase: "running", + headline: "Working", + updatedAt: "2026-08-12T09:00:00.000Z", + deepLink: "/threads/environment/thread", +}; + +const target: WebPushSubscriptions.WebPushTarget = { + userId: "user", + subscriptionId: "subscription", + endpoint: "https://push.example.test/subscription", + expirationTime: null, + p256dh: "p256dh", + auth: "auth", + preferences: { + notifyOnApproval: true, + notifyOnCompletion: true, + notifyOnFailure: true, + soundEnabled: true, + }, +}; + +describe("makeWebPushNotification", () => { + it("builds the same completion copy, destination, and sound preference as desktop", () => { + const notification = makeWebPushNotification({ + previousState: runningState, + nextState: { + ...runningState, + phase: "completed", + detail: "## Done\n\nAdded **standards-based** Web Push.", + updatedAt: "2026-08-12T09:01:00.000Z", + }, + target, + }); + + expect(notification).toEqual({ + version: 1, + title: "Completed - T3 Code", + body: "Done Added standards-based Web Push.", + path: "/environment/thread", + tag: "t3-agent-environment-thread", + updatedAt: "2026-08-12T09:01:00.000Z", + silent: false, + }); + }); + + it("does not replay terminal state observed without an active transition", () => { + expect( + makeWebPushNotification({ + previousState: null, + nextState: { ...runningState, phase: "completed" }, + target, + }), + ).toBeNull(); + }); + + it("respects event and sound preferences", () => { + expect( + makeWebPushNotification({ + previousState: runningState, + nextState: { ...runningState, phase: "failed" }, + target: { + ...target, + preferences: { ...target.preferences, notifyOnFailure: false }, + }, + }), + ).toBeNull(); + + expect( + makeWebPushNotification({ + previousState: runningState, + nextState: { ...runningState, phase: "waiting_for_approval" }, + target: { + ...target, + preferences: { ...target.preferences, soundEnabled: false }, + }, + }), + ).toMatchObject({ + title: "Approval Required - T3 Code", + silent: true, + }); + }); +}); + +describe("WebPushDeliveries", () => { + it.effect("sends an encrypted Web Push request and removes an expired subscription", () => { + const vapid = NodeCrypto.createECDH("prime256v1"); + vapid.generateKeys(); + const subscriber = NodeCrypto.createECDH("prime256v1"); + subscriber.generateKeys(); + const vapidPublicKey = vapid.getPublicKey(); + const vapidPrivateKey = vapid.getPrivateKey().toString("base64url"); + const pushTarget = { + ...target, + p256dh: subscriber.getPublicKey().toString("base64url"), + auth: NodeCrypto.randomBytes(16).toString("base64url"), + }; + const requests: Array = []; + const invalidated: Array<{ readonly userId: string; readonly subscriptionId: string }> = []; + const subscriptions = WebPushSubscriptions.WebPushSubscriptions.of({ + register: () => Effect.void, + unregister: () => Effect.void, + listForUser: () => Effect.succeed([pushTarget]), + invalidate: (input) => + Effect.sync(() => { + invalidated.push(input); + }), + }); + const config = RelayConfiguration.RelayConfiguration.of({ + relayIssuer: "https://relay.example.test", + webPush: { + subject: "https://relay.example.test", + publicKey: vapidPublicKey.toString("base64url"), + privateKey: Redacted.make(vapidPrivateKey), + }, + apns: { + environment: "sandbox", + teamId: "team", + keyId: "key", + bundleId: "codes.t3.mobile", + privateKey: Redacted.make("private-key"), + }, + clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", + clerkJwtAudience: "t3-code-relay", + apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), + cloudMintPrivateKey: Redacted.make("cloud-private-key"), + cloudMintPublicKey: "cloud-public-key", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, + }); + const fakeFetch = async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push(new Request(input, init)); + return new Response("", { status: 410 }); + }; + + return Effect.acquireUseRelease( + Effect.sync(() => { + const originalFetch = globalThis.fetch; + globalThis.fetch = fakeFetch as typeof fetch; + return originalFetch; + }), + () => + Effect.gen(function* () { + const deliveries = yield* WebPushDeliveries; + yield* deliveries.sendForUser({ + userId: target.userId, + previousState: runningState, + nextState: { ...runningState, phase: "completed" }, + }); + + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.url).toBe(target.endpoint); + expect(requests[0]?.headers.get("authorization")).toMatch(/^vapid /u); + expect(requests[0]?.headers.get("content-encoding")).toBe("aes128gcm"); + expect(invalidated).toEqual([ + { userId: target.userId, subscriptionId: target.subscriptionId }, + ]); + }), + (originalFetch) => + Effect.sync(() => { + globalThis.fetch = originalFetch; + }), + ).pipe( + Effect.provide( + layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(WebPushSubscriptions.WebPushSubscriptions, subscriptions), + Layer.succeed(RelayConfiguration.RelayConfiguration, config), + ), + ), + ), + ), + ); + }); +}); diff --git a/infra/relay/src/agentActivity/WebPushDeliveries.ts b/infra/relay/src/agentActivity/WebPushDeliveries.ts new file mode 100644 index 000000000000..b12f44c69292 --- /dev/null +++ b/infra/relay/src/agentActivity/WebPushDeliveries.ts @@ -0,0 +1,173 @@ +import { sendPushNotification } from "@mmmike/web-push/send"; +import type { DesktopNotificationKind } from "@t3tools/contracts"; +import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { + agentNotificationKind, + notificationBody, + notificationTitle, +} from "@t3tools/shared/agentNotifications"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; + +import * as RelayConfiguration from "../Config.ts"; +import * as WebPushSubscriptions from "./WebPushSubscriptions.ts"; + +export interface WebPushNotificationPayload { + readonly version: 1; + readonly title: string; + readonly body: string; + readonly path: string; + readonly tag: string; + readonly updatedAt: string; + readonly silent: boolean; +} + +export class WebPushDeliveryError extends Schema.TaggedErrorClass()( + "WebPushDeliveryError", + { + userId: Schema.String, + subscriptionId: Schema.String, + stage: Schema.Literal("send"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Web Push delivery failed during ${this.stage} for subscription ${this.subscriptionId}.`; + } +} + +function kindEnabled( + kind: DesktopNotificationKind, + preferences: WebPushSubscriptions.WebPushTarget["preferences"], +): boolean { + switch (kind) { + case "task-completed": + return preferences.notifyOnCompletion; + case "task-failed": + return preferences.notifyOnFailure; + case "approval-needed": + return preferences.notifyOnApproval; + } +} + +export function makeWebPushNotification(input: { + readonly previousState: RelayAgentActivityState | null; + readonly nextState: RelayAgentActivityState | null; + readonly target: WebPushSubscriptions.WebPushTarget; +}): WebPushNotificationPayload | null { + if (input.nextState === null) { + return null; + } + const kind = agentNotificationKind(input.previousState?.phase ?? null, input.nextState.phase); + if (kind === null || !kindEnabled(kind, input.target.preferences)) { + return null; + } + const state = input.nextState; + return { + version: 1, + title: notificationTitle(kind, state.projectTitle), + body: notificationBody({ + responseText: state.detail ?? null, + threadTitle: state.threadTitle, + fallbackHeadline: state.headline, + }), + path: `/${encodeURIComponent(state.environmentId)}/${encodeURIComponent(state.threadId)}`, + tag: `t3-agent-${state.environmentId}-${state.threadId}`, + updatedAt: state.updatedAt, + silent: !input.target.preferences.soundEnabled, + }; +} + +export class WebPushDeliveries extends Context.Service< + WebPushDeliveries, + { + readonly sendForUser: (input: { + readonly userId: string; + readonly previousState: RelayAgentActivityState | null; + readonly nextState: RelayAgentActivityState | null; + }) => Effect.Effect< + void, + WebPushDeliveryError | WebPushSubscriptions.WebPushSubscriptionPersistenceError + >; + } +>()("t3code-relay/agentActivity/WebPushDeliveries") {} + +export const make = Effect.gen(function* () { + const config = yield* RelayConfiguration.RelayConfiguration; + const subscriptions = yield* WebPushSubscriptions.WebPushSubscriptions; + + const send = Effect.fnUntraced(function* (input: { + readonly target: WebPushSubscriptions.WebPushTarget; + readonly notification: WebPushNotificationPayload; + }) { + const delivered = yield* Effect.tryPromise({ + try: () => + sendPushNotification( + { + endpoint: input.target.endpoint, + keys: { p256dh: input.target.p256dh, auth: input.target.auth }, + }, + input.notification, + { + subject: config.webPush.subject, + publicKey: config.webPush.publicKey, + privateKey: Redacted.value(config.webPush.privateKey), + }, + { ttl: 60 }, + ), + catch: (cause) => + new WebPushDeliveryError({ + userId: input.target.userId, + subscriptionId: input.target.subscriptionId, + stage: "send", + cause, + }), + }).pipe( + Effect.timeout("5 seconds"), + Effect.mapError( + (cause) => + new WebPushDeliveryError({ + userId: input.target.userId, + subscriptionId: input.target.subscriptionId, + stage: "send", + cause, + }), + ), + ); + if (!delivered) { + yield* subscriptions.invalidate({ + userId: input.target.userId, + subscriptionId: input.target.subscriptionId, + }); + } + }); + + return WebPushDeliveries.of({ + sendForUser: Effect.fn("relay.web_push_deliveries.send_for_user")(function* (input) { + const targets = yield* subscriptions.listForUser({ userId: input.userId }); + yield* Effect.forEach( + targets, + (target) => { + const notification = makeWebPushNotification({ ...input, target }); + return notification === null + ? Effect.void + : send({ target, notification }).pipe( + Effect.tapError((error) => + Effect.logWarning("Web Push notification delivery failed", { + errorTag: error._tag, + subscriptionId: target.subscriptionId, + }), + ), + Effect.ignore, + ); + }, + { concurrency: 4 }, + ); + }), + }); +}); + +export const layer = Layer.effect(WebPushDeliveries, make); diff --git a/infra/relay/src/agentActivity/WebPushSubscriptions.ts b/infra/relay/src/agentActivity/WebPushSubscriptions.ts new file mode 100644 index 000000000000..04f490b9c2e5 --- /dev/null +++ b/infra/relay/src/agentActivity/WebPushSubscriptions.ts @@ -0,0 +1,174 @@ +import type { + RelayWebPushPreferences, + RelayWebPushRegistrationRequest, +} from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { and, eq } from "drizzle-orm"; + +import * as RelayDb from "../db.ts"; +import { relayWebPushSubscriptions } from "../persistence/schema.ts"; + +export interface WebPushTarget { + readonly userId: string; + readonly subscriptionId: string; + readonly endpoint: string; + readonly expirationTime: number | null; + readonly p256dh: string; + readonly auth: string; + readonly preferences: RelayWebPushPreferences; +} + +export class WebPushSubscriptionPersistenceError extends Schema.TaggedErrorClass()( + "WebPushSubscriptionPersistenceError", + { + operation: Schema.Literals(["register", "unregister", "list", "invalidate"]), + userId: Schema.String, + subscriptionId: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} Web Push subscription for ${this.userId}.`; + } +} + +export class WebPushSubscriptions extends Context.Service< + WebPushSubscriptions, + { + readonly register: (input: { + readonly userId: string; + readonly registration: RelayWebPushRegistrationRequest; + }) => Effect.Effect; + readonly unregister: (input: { + readonly userId: string; + readonly subscriptionId: string; + }) => Effect.Effect; + readonly listForUser: (input: { + readonly userId: string; + }) => Effect.Effect, WebPushSubscriptionPersistenceError>; + readonly invalidate: (input: { + readonly userId: string; + readonly subscriptionId: string; + }) => Effect.Effect; + } +>()("t3code-relay/agentActivity/WebPushSubscriptions") {} + +export const make = Effect.gen(function* () { + const db = yield* RelayDb.RelayDb; + + const remove = Effect.fnUntraced(function* (input: { + readonly operation: "unregister" | "invalidate"; + readonly userId: string; + readonly subscriptionId: string; + }) { + yield* db + .delete(relayWebPushSubscriptions) + .where( + and( + eq(relayWebPushSubscriptions.userId, input.userId), + eq(relayWebPushSubscriptions.subscriptionId, input.subscriptionId), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new WebPushSubscriptionPersistenceError({ + operation: input.operation, + userId: input.userId, + subscriptionId: input.subscriptionId, + cause, + }), + ), + ); + }); + + return WebPushSubscriptions.of({ + register: Effect.fn("relay.web_push_subscriptions.register")(function* (input) { + const registration = input.registration; + const updatedAt = DateTime.formatIso(yield* DateTime.now); + + // A PushSubscription belongs to one browser profile. Conflict on its + // globally unique endpoint so an account switch atomically transfers + // ownership instead of continuing to notify the previous account. + yield* db + .insert(relayWebPushSubscriptions) + .values({ + userId: input.userId, + subscriptionId: registration.subscriptionId, + label: registration.label, + endpoint: registration.subscription.endpoint, + expirationTime: + registration.subscription.expirationTime === null + ? null + : String(registration.subscription.expirationTime), + p256dh: registration.subscription.keys.p256dh, + auth: registration.subscription.keys.auth, + preferencesJson: registration.preferences, + createdAt: updatedAt, + updatedAt, + }) + .onConflictDoUpdate({ + target: relayWebPushSubscriptions.endpoint, + set: { + userId: input.userId, + subscriptionId: registration.subscriptionId, + label: registration.label, + endpoint: registration.subscription.endpoint, + expirationTime: + registration.subscription.expirationTime === null + ? null + : String(registration.subscription.expirationTime), + p256dh: registration.subscription.keys.p256dh, + auth: registration.subscription.keys.auth, + preferencesJson: registration.preferences, + updatedAt, + }, + }) + .pipe( + Effect.mapError( + (cause) => + new WebPushSubscriptionPersistenceError({ + operation: "register", + userId: input.userId, + subscriptionId: registration.subscriptionId, + cause, + }), + ), + ); + }), + unregister: (input) => remove({ operation: "unregister", ...input }), + invalidate: (input) => remove({ operation: "invalidate", ...input }), + listForUser: Effect.fn("relay.web_push_subscriptions.list_for_user")(function* (input) { + const rows = yield* db + .select() + .from(relayWebPushSubscriptions) + .where(eq(relayWebPushSubscriptions.userId, input.userId)) + .pipe( + Effect.mapError( + (cause) => + new WebPushSubscriptionPersistenceError({ + operation: "list", + userId: input.userId, + subscriptionId: null, + cause, + }), + ), + ); + return rows.map((row) => ({ + userId: row.userId, + subscriptionId: row.subscriptionId, + endpoint: row.endpoint, + expirationTime: row.expirationTime === null ? null : Number(row.expirationTime), + p256dh: row.p256dh, + auth: row.auth, + preferences: row.preferencesJson, + })); + }), + }); +}); + +export const layer = Layer.effect(WebPushSubscriptions, make); diff --git a/infra/relay/src/auth/RelayTokens.test.ts b/infra/relay/src/auth/RelayTokens.test.ts index c4a65771e584..2534b7fe6ccf 100644 --- a/infra/relay/src/auth/RelayTokens.test.ts +++ b/infra/relay/src/auth/RelayTokens.test.ts @@ -16,6 +16,11 @@ const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { const config = RelayConfiguration.RelayConfiguration.of({ relayIssuer: "https://relay.example.test/", + webPush: { + subject: "https://relay.example.test", + publicKey: "web-push-public-key", + privateKey: Redacted.make("web-push-private-key"), + }, apns: { environment: "sandbox", teamId: "team-id", diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index 7f536bafb375..8a978a11f568 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -63,6 +63,11 @@ function requestBodyText(request: HttpClientRequest.HttpClientRequest): string { const settings = RelayConfiguration.RelayConfiguration.of({ relayIssuer: "https://relay.example.test", + webPush: { + subject: "https://relay.example.test", + publicKey: "web-push-public-key", + privateKey: Redacted.make("web-push-private-key"), + }, apns: { environment: "sandbox", teamId: "team-id", diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index c0811e82d923..ddda747aa413 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -30,6 +30,11 @@ const environmentKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { }); const config = RelayConfiguration.RelayConfiguration.of({ relayIssuer: "https://relay.example.test", + webPush: { + subject: "https://relay.example.test", + publicKey: "web-push-public-key", + privateKey: Redacted.make("web-push-private-key"), + }, apns: { environment: "sandbox", teamId: "team-id", diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index f61c5a27d5bc..7d4a2edd56e8 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -25,6 +25,11 @@ const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { }); const config = RelayConfiguration.RelayConfiguration.of({ relayIssuer: "https://relay.example.test", + webPush: { + subject: "https://relay.example.test", + publicKey: "web-push-public-key", + privateKey: Redacted.make("web-push-private-key"), + }, apns: { environment: "sandbox", teamId: "team-id", diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index 4d136658c8fd..ff410501b2f1 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -15,6 +15,11 @@ import * as ManagedTunnelLimits from "./ManagedTunnelLimits.ts"; const config = RelayConfiguration.RelayConfiguration.of({ relayIssuer: "https://relay.example.test", + webPush: { + subject: "https://relay.example.test", + publicKey: "web-push-public-key", + privateKey: Redacted.make("web-push-private-key"), + }, apns: { environment: "sandbox", teamId: "team-id", diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index daf756a2b7cc..919908dc0783 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -42,6 +42,11 @@ vi.mock("@clerk/backend", () => ({ const relaySettings: RelayConfiguration.RelayConfiguration["Service"] = { relayIssuer: "https://relay.example.test", + webPush: { + subject: "https://relay.example.test", + publicKey: "web-push-public-key", + privateKey: Redacted.make("web-push-private-key"), + }, apns: { teamId: "apns-team", keyId: "apns-key", diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 50bcff665a9b..10e3260b93c8 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -33,6 +33,7 @@ import { RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, RelayMobileRegistrationScope, + RelayWebPushRegistrationScope, RelayAuthInvalidError, type RelayAuthInvalidReason, RelayEnvironmentAuth, @@ -67,6 +68,7 @@ import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvide import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts"; import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts"; +import * as WebPushSubscriptions from "../agentActivity/WebPushSubscriptions.ts"; import { withSpanAttributes } from "../observability.ts"; import * as RelayDb from "../db.ts"; @@ -361,6 +363,7 @@ export const metadataApi = HttpApiBuilder.group( RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, RelayMobileRegistrationScope, + RelayWebPushRegistrationScope, ] as const; return handlers .handle("authorizationServer", () => @@ -381,6 +384,9 @@ export const metadataApi = HttpApiBuilder.group( dpop_bound_access_tokens_required: true, dpop_signing_alg_values_supported: ["ES256"], }), + ) + .handle("webPushVapidPublicKey", () => + Effect.succeed({ publicKey: settings.webPush.publicKey }), ); }), ); @@ -521,6 +527,45 @@ export const mobileApi = HttpApiBuilder.group( }), ); +export const webPushApi = HttpApiBuilder.group( + RelayApi, + "webPush", + Effect.fnUntraced(function* (handlers) { + const subscriptions = yield* WebPushSubscriptions.WebPushSubscriptions; + const dpopProofs = yield* DpopProofs.DpopProofReplay; + + const authorize = Effect.gen(function* () { + const { userId, token } = yield* RelayClientPrincipal; + const proofKeyThumbprint = yield* requireDpopPrincipalScope("web-push:registration"); + yield* requireDpopThumbprint(proofKeyThumbprint, { + expectedAccessToken: token, + }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + return userId; + }); + + return handlers + .handle( + "registerWebPush", + Effect.fn("relay.api.webPush.registerWebPush")(function* (args) { + const userId = yield* authorize; + yield* subscriptions.register({ userId, registration: args.payload }); + return { ok: true as const }; + }, mapRelayCommonApiErrors("invalid_dpop")), + ) + .handle( + "unregisterWebPush", + Effect.fn("relay.api.webPush.unregisterWebPush")(function* (args) { + const userId = yield* authorize; + yield* subscriptions.unregister({ + userId, + subscriptionId: args.params.subscriptionId, + }); + return { ok: true as const }; + }, mapRelayCommonApiErrors("invalid_dpop")), + ); + }), +); + export const clientApi = HttpApiBuilder.group( RelayApi, "client", @@ -1025,6 +1070,7 @@ const RelayCommonPersistenceError = Schema.Union([ AgentActivityRows.AgentActivityRowListPersistenceError, LiveActivities.LiveActivityDeliveryMarkPersistenceError, DeliveryAttempts.DeliveryAttemptRecordPersistenceError, + WebPushSubscriptions.WebPushSubscriptionPersistenceError, ]); type RelayCommonPersistenceError = typeof RelayCommonPersistenceError.Type; const isRelayCommonPersistenceError = Schema.is(RelayCommonPersistenceError); diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index 61b72f2df868..c4ae3f3b06f9 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -39,6 +39,29 @@ export const relayMobileDevices = pgTable( ], ); +export const relayWebPushSubscriptions = pgTable( + "relay_web_push_subscriptions", + { + userId: varchar("user_id", { length: 255 }).notNull(), + subscriptionId: varchar("subscription_id", { length: 255 }).notNull(), + label: text("label").notNull().default("Web browser"), + endpoint: text("endpoint").notNull(), + expirationTime: varchar("expiration_time", { length: 64 }), + p256dh: text("p256dh").notNull(), + auth: text("auth").notNull(), + preferencesJson: jsonb("preferences_json") + .notNull() + .$type(), + createdAt: varchar("created_at", { length: 64 }).notNull(), + updatedAt: varchar("updated_at", { length: 64 }).notNull(), + }, + (table) => [ + primaryKey({ columns: [table.userId, table.subscriptionId] }), + uniqueIndex("idx_relay_web_push_subscriptions_endpoint").on(table.endpoint), + index("idx_relay_web_push_subscriptions_user").on(table.userId), + ], +); + export const relayLiveActivities = pgTable( "relay_live_activities", { diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 77dfd845c5bc..605ec3718c1b 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -6,6 +6,7 @@ import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; import * as Stream from "effect/Stream"; import * as Etag from "effect/unstable/http/Etag"; import * as HttpPlatform from "effect/unstable/http/HttpPlatform"; @@ -21,6 +22,7 @@ import { healthApi, metadataApi, mobileApi, + webPushApi, relayClientAuthLayer, relayDpopClientAuthLayer, relayCors, @@ -57,6 +59,55 @@ import * as EnvironmentPublishSignatures from "./environments/EnvironmentPublish import * as ManagedEndpointProvider from "./environments/ManagedEndpointProvider.ts"; import * as ManagedTunnelLimits from "./environments/ManagedTunnelLimits.ts"; import * as MobileRegistrations from "./agentActivity/MobileRegistrations.ts"; +import * as WebPushSubscriptions from "./agentActivity/WebPushSubscriptions.ts"; +import * as WebPushDeliveries from "./agentActivity/WebPushDeliveries.ts"; + +function decodeBase64(value: string): Uint8Array { + return Uint8Array.from(atob(value), (character) => character.charCodeAt(0)); +} + +function decodeBase64Url(value: string): Uint8Array { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); + return decodeBase64(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")); +} + +function encodeBase64Url(value: Uint8Array): string { + let binary = ""; + for (const byte of value) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); +} + +async function webPushKeysFromPkcs8(privateKeyPem: string): Promise<{ + readonly publicKey: string; + readonly privateKey: string; +}> { + const der = decodeBase64(privateKeyPem.replace(/-----[^-]+-----/gu, "").replaceAll(/\s+/gu, "")); + const keyData = new Uint8Array(der.length); + keyData.set(der); + const privateKey = await globalThis.crypto.subtle.importKey( + "pkcs8", + keyData, + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign"], + ); + const jwk = await globalThis.crypto.subtle.exportKey("jwk", privateKey); + if (!jwk.x || !jwk.y || !jwk.d) { + throw new Error("Web Push P-256 key did not export complete JWK coordinates."); + } + const x = decodeBase64Url(jwk.x); + const y = decodeBase64Url(jwk.y); + const publicKey = new Uint8Array(1 + x.length + y.length); + publicKey[0] = 0x04; + publicKey.set(x, 1); + publicKey.set(y, 1 + x.length); + return { + publicKey: encodeBase64Url(publicKey), + privateKey: jwk.d, + }; +} const webcryptoLayer = Layer.succeed( Crypto.Crypto, @@ -85,6 +136,7 @@ const relayApiLayer = Layer.mergeAll( healthApi, metadataApi, mobileApi, + webPushApi, clientApi, tokenApi, dpopClientApi, @@ -92,6 +144,10 @@ const relayApiLayer = Layer.mergeAll( ); const CloudMintKeyPair = Alchemy.KeyPair("CloudMintKeyPair"); +const WebPushKeyPair = Alchemy.KeyPair("WebPushKeyPair", { + algorithm: "ec", + namedCurve: "P-256", +}); const ApnsDeliveryJobSigningSecret = Alchemy.makeRandom("ApnsDeliveryJobSigningSecret", { bytes: 32, }); @@ -118,6 +174,7 @@ export const ApiLive = Api.make( const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue; const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue; const cloudMintKeyPair = yield* CloudMintKeyPair; + const webPushKeyPair = yield* WebPushKeyPair; const relayApiZone = yield* RelayApiZone; const managedEndpointZone = yield* ManagedEndpointZone; const randomApnsDeliveryJobSigningSecret = yield* ApnsDeliveryJobSigningSecret; @@ -147,6 +204,11 @@ export const ApiLive = Api.make( const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey; const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey; + const webPushPrivateKeyAccessor = yield* webPushKeyPair.privateKey; + const webPushPrivateKey = yield* webPushPrivateKeyAccessor; + const webPushKeys = yield* Effect.promise(() => + webPushKeysFromPkcs8(Redacted.value(webPushPrivateKey)), + ); const hyperdrive = yield* Cloudflare.Hyperdrive.Connect(yield* RelayDb.RelayHyperdrive); const db = yield* Drizzle.Postgres(hyperdrive.connectionString); @@ -171,6 +233,11 @@ export const ApiLive = Api.make( bundleId: apnsBundleId, privateKey: apnsPrivateKey, }, + webPush: { + subject: relayPublicOrigin, + publicKey: webPushKeys.publicKey, + privateKey: Redacted.make(webPushKeys.privateKey), + }, apnsDeliveryJobSigningSecret: yield* apnsDeliveryJobSigningSecret, clerkSecretKey, clerkPublishableKey, @@ -190,19 +257,7 @@ export const ApiLive = Api.make( }).pipe(Effect.map(makeRelayTraceLayer)), ); - const runtimeLayer = Layer.empty.pipe( - Layer.provideMerge(MobileRegistrations.layer), - Layer.provideMerge(AgentActivityPublisher.layer), - Layer.provideMerge(EnvironmentConnector.layer), - Layer.provideMerge(EnvironmentLinker.layer), - Layer.provideMerge(EnvironmentPublishSignatures.layer), - Layer.provideMerge( - ManagedEndpointProvider.layerCloudflareBindings( - managedEndpointTunnelBinding, - managedEndpointDnsBinding, - alchemyRuntimeContext, - ), - ), + const foundationLayer = Layer.empty.pipe( Layer.provideMerge(DpopProofs.layer), Layer.provideMerge(ApnsDeliveries.layer), Layer.provideMerge(ApnsClient.layer.pipe(Layer.provideMerge(ApnsProviderTokens.layer))), @@ -211,6 +266,7 @@ export const ApiLive = Api.make( ), Layer.provideMerge(AgentActivityRows.layer), Layer.provideMerge(Devices.layer), + Layer.provideMerge(WebPushSubscriptions.layer), Layer.provideMerge(EnvironmentCredentials.layer), Layer.provideMerge( Layer.mergeAll( @@ -230,6 +286,22 @@ export const ApiLive = Api.make( Layer.provideMerge(Layer.effect(RelayConfiguration.RelayConfiguration, loadSettings)), Layer.provideMerge(webcryptoLayer), ); + const runtimeLayer = Layer.empty.pipe( + Layer.provideMerge(MobileRegistrations.layer), + Layer.provideMerge(AgentActivityPublisher.layer), + Layer.provideMerge(WebPushDeliveries.layer), + Layer.provideMerge(EnvironmentConnector.layer), + Layer.provideMerge(EnvironmentLinker.layer), + Layer.provideMerge(EnvironmentPublishSignatures.layer), + Layer.provideMerge( + ManagedEndpointProvider.layerCloudflareBindings( + managedEndpointTunnelBinding, + managedEndpointDnsBinding, + alchemyRuntimeContext, + ), + ), + Layer.provideMerge(foundationLayer), + ); const appLayer = relayApiLayer.pipe( Layer.provideMerge(relayClientAuthLayer), diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index d0375e555561..2dba5156d6d3 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -85,6 +85,9 @@ function relayClient( unregisterDevice: () => unsupported("unregisterDevice"), registerLiveActivity: () => unsupported("registerLiveActivity"), getAgentActivitySnapshot: () => unsupported("getAgentActivitySnapshot"), + getWebPushVapidPublicKey: unsupported("getWebPushVapidPublicKey"), + registerWebPush: () => unsupported("registerWebPush"), + unregisterWebPush: () => unsupported("unregisterWebPush"), resetTokenCache: Effect.void, }); } diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index 8cb963000bd3..dd0f6e89a541 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -113,6 +113,9 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { unregisterDevice: () => Effect.die("unused"), registerLiveActivity: () => Effect.die("unused"), getAgentActivitySnapshot: () => Effect.die("unused"), + getWebPushVapidPublicKey: Effect.die("unused"), + registerWebPush: () => Effect.die("unused"), + unregisterWebPush: () => Effect.die("unused"), resetTokenCache: Effect.void, } satisfies ManagedRelay.ManagedRelayClient["Service"]); const connectivity = Connectivity.Connectivity.of({ @@ -275,6 +278,9 @@ describe("RelayEnvironmentDiscovery", () => { unregisterDevice: () => Effect.die("unused"), registerLiveActivity: () => Effect.die("unused"), getAgentActivitySnapshot: () => Effect.die("unused"), + getWebPushVapidPublicKey: Effect.die("unused"), + registerWebPush: () => Effect.die("unused"), + unregisterWebPush: () => Effect.die("unused"), resetTokenCache: Effect.void, } satisfies ManagedRelay.ManagedRelayClient["Service"]); const layer = RelayEnvironmentDiscovery.layer.pipe( diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index 06769a4372f1..8634eb2fc5cf 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -28,6 +28,11 @@ import { RelayProtectedError, type RelayProtectedError as RelayProtectedErrorType, RelayUnregisterDeviceEndpoint, + RelayRegisterWebPushEndpoint, + RelayUnregisterWebPushEndpoint, + RelayWebPushRegistrationScope, + type RelayWebPushRegistrationRequest, + type RelayWebPushVapidPublicKeyResponse, } from "@t3tools/contracts/relay"; import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; @@ -95,6 +100,9 @@ export const ManagedRelayRequestAction = Schema.Literals([ "unregister relay mobile device", "register relay live activity", "read relay agent activity snapshot", + "read relay Web Push VAPID key", + "register relay Web Push subscription", + "unregister relay Web Push subscription", ]); export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type; @@ -111,6 +119,9 @@ export const ManagedRelayRequestActivity = Schema.Literals([ "Relay mobile device unregistration", "Relay Live Activity registration", "Relay agent activity snapshot", + "Relay Web Push VAPID key lookup", + "Relay Web Push subscription registration", + "Relay Web Push subscription unregistration", ]); export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type; @@ -297,6 +308,18 @@ export class ManagedRelayClient extends Context.Service< readonly getAgentActivitySnapshot: (input: { readonly clerkToken: string; }) => Effect.Effect; + readonly getWebPushVapidPublicKey: Effect.Effect< + RelayWebPushVapidPublicKeyResponse, + ManagedRelayClientError + >; + readonly registerWebPush: (input: { + readonly clerkToken: string; + readonly payload: RelayWebPushRegistrationRequest; + }) => Effect.Effect; + readonly unregisterWebPush: (input: { + readonly clerkToken: string; + readonly subscriptionId: string; + }) => Effect.Effect; readonly resetTokenCache: Effect.Effect; } >()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {} @@ -418,6 +441,9 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Servi unregisterDevice: unavailable("clientRuntime.managedRelay.unregisterDevice"), registerLiveActivity: unavailable("clientRuntime.managedRelay.registerLiveActivity"), getAgentActivitySnapshot: unavailable("clientRuntime.managedRelay.getAgentActivitySnapshot"), + getWebPushVapidPublicKey: unavailable("clientRuntime.managedRelay.getWebPushVapidPublicKey")(), + registerWebPush: unavailable("clientRuntime.managedRelay.registerWebPush"), + unregisterWebPush: unavailable("clientRuntime.managedRelay.unregisterWebPush"), resetTokenCache: Effect.void.pipe( Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), ), @@ -473,6 +499,14 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( method: RelayRegisterLiveActivityEndpoint.method, url: urlBuilder.mobile.registerLiveActivity(), }), + registerWebPush: (): DpopProofTarget => ({ + method: RelayRegisterWebPushEndpoint.method, + url: urlBuilder.webPush.registerWebPush(), + }), + unregisterWebPush: (subscriptionId: string): DpopProofTarget => ({ + method: RelayUnregisterWebPushEndpoint.method, + url: urlBuilder.webPush.unregisterWebPush({ params: { subscriptionId } }), + }), }; const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")( @@ -691,6 +725,23 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( request, ); + const webPushRegistrationRequest = ( + input: { + readonly clerkToken: string; + readonly target: DpopProofTarget; + }, + request: ( + authorization: ManagedRelayAuthorization, + ) => Effect.Effect, + ) => + runDpopRequest( + { + ...input, + scopes: [RelayWebPushRegistrationScope], + }, + request, + ); + return ManagedRelayClient.of({ relayUrl, listEnvironments: Effect.fnUntraced( @@ -911,6 +962,58 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"), withRelayClientTracing, ), + getWebPushVapidPublicKey: client.metadata + .webPushVapidPublicKey() + .pipe( + Effect.mapError(relayRequestError("read relay Web Push VAPID key")), + timeoutRelayRequest("Relay Web Push VAPID key lookup"), + Effect.withSpan("clientRuntime.managedRelay.getWebPushVapidPublicKey"), + withRelayClientTracing, + ), + registerWebPush: Effect.fnUntraced( + function* (input) { + return yield* webPushRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.registerWebPush(), + }, + (authorization) => + client.webPush + .registerWebPush({ + headers: dpopHeaders(authorization), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("register relay Web Push subscription")), + timeoutRelayRequest("Relay Web Push subscription registration"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.registerWebPush"), + withRelayClientTracing, + ), + unregisterWebPush: Effect.fnUntraced( + function* (input) { + return yield* webPushRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.unregisterWebPush(input.subscriptionId), + }, + (authorization) => + client.webPush + .unregisterWebPush({ + headers: dpopHeaders(authorization), + params: { subscriptionId: input.subscriptionId }, + }) + .pipe( + Effect.mapError(relayRequestError("unregister relay Web Push subscription")), + timeoutRelayRequest("Relay Web Push subscription unregistration"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.unregisterWebPush"), + withRelayClientTracing, + ), resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe( Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void), Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts index 905bbcc819c8..df9bbb0bda4f 100644 --- a/packages/client-runtime/src/relay/managedRelayState.test.ts +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -84,6 +84,9 @@ function createManager( unregisterDevice: () => Effect.die("unused"), registerLiveActivity: () => Effect.die("unused"), getAgentActivitySnapshot: () => Effect.die("unused"), + getWebPushVapidPublicKey: Effect.die("unused"), + registerWebPush: () => Effect.die("unused"), + unregisterWebPush: () => Effect.die("unused"), resetTokenCache: Effect.void, ...overrides, }); diff --git a/packages/contracts/src/relay.test.ts b/packages/contracts/src/relay.test.ts index 4ad600953b9e..390353354e87 100644 --- a/packages/contracts/src/relay.test.ts +++ b/packages/contracts/src/relay.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; import * as OpenApi from "effect/unstable/httpapi/OpenApi"; -import { RelayApi } from "./relay.ts"; +import { RelayApi, RelayWebPushRegistrationRequest } from "./relay.ts"; describe("RelayApi security", () => { it("describes DPoP access tokens using the HTTP DPoP authorization scheme", () => { @@ -14,3 +15,41 @@ describe("RelayApi security", () => { }); }); }); + +describe("RelayWebPushRegistrationRequest", () => { + const decode = Schema.decodeUnknownSync(RelayWebPushRegistrationRequest); + const registration = { + subscriptionId: "subscription", + label: "Web browser", + subscription: { + endpoint: "https://push.example.test/subscription", + expirationTime: null, + keys: { p256dh: "p256dh", auth: "auth" }, + }, + preferences: { + notifyOnApproval: true, + notifyOnCompletion: true, + notifyOnFailure: true, + soundEnabled: true, + }, + }; + + it("accepts secure standards-based push endpoints", () => { + expect(decode(registration)).toEqual(registration); + }); + + it("rejects insecure or malformed push endpoints", () => { + expect(() => + decode({ + ...registration, + subscription: { ...registration.subscription, endpoint: "http://localhost/push" }, + }), + ).toThrow(); + expect(() => + decode({ + ...registration, + subscription: { ...registration.subscription, endpoint: "not a URL" }, + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 52f7d7d43550..db762cec1ff9 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -92,6 +92,52 @@ export const RelayDeviceUnregistrationParams = Schema.Struct({ }); export type RelayDeviceUnregistrationParams = typeof RelayDeviceUnregistrationParams.Type; +export const RelayWebPushPreferences = Schema.Struct({ + notifyOnApproval: Schema.Boolean, + notifyOnCompletion: Schema.Boolean, + notifyOnFailure: Schema.Boolean, + soundEnabled: Schema.Boolean, +}); +export type RelayWebPushPreferences = typeof RelayWebPushPreferences.Type; + +const RelayWebPushEndpoint = TrimmedNonEmptyString.check(Schema.isMaxLength(4_096)).check( + Schema.makeFilter((value) => { + try { + return new URL(value).protocol === "https:" || "Web Push endpoint must use HTTPS."; + } catch { + return "Web Push endpoint must be a valid URL."; + } + }), +); + +export const RelayWebPushSubscription = Schema.Struct({ + endpoint: RelayWebPushEndpoint, + expirationTime: Schema.NullOr(Schema.Number), + keys: Schema.Struct({ + p256dh: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + auth: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + }), +}); +export type RelayWebPushSubscription = typeof RelayWebPushSubscription.Type; + +export const RelayWebPushRegistrationRequest = Schema.Struct({ + subscriptionId: TrimmedNonEmptyString.check(Schema.isMaxLength(128)), + label: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + subscription: RelayWebPushSubscription, + preferences: RelayWebPushPreferences, +}); +export type RelayWebPushRegistrationRequest = typeof RelayWebPushRegistrationRequest.Type; + +export const RelayWebPushUnregistrationParams = Schema.Struct({ + subscriptionId: TrimmedNonEmptyString.check(Schema.isMaxLength(128)), +}); +export type RelayWebPushUnregistrationParams = typeof RelayWebPushUnregistrationParams.Type; + +export const RelayWebPushVapidPublicKeyResponse = Schema.Struct({ + publicKey: TrimmedNonEmptyString, +}); +export type RelayWebPushVapidPublicKeyResponse = typeof RelayWebPushVapidPublicKeyResponse.Type; + export const RelayAgentActivityState = Schema.Struct({ environmentId: EnvironmentId, threadId: ThreadId, @@ -653,10 +699,12 @@ export type RelayEnvironmentConnectRequest = typeof RelayEnvironmentConnectReque export const RelayEnvironmentConnectScope = "environment:connect" as const; export const RelayEnvironmentStatusScope = "environment:status" as const; export const RelayMobileRegistrationScope = "mobile:registration" as const; +export const RelayWebPushRegistrationScope = "web-push:registration" as const; export const RelayDpopAccessTokenScope = Schema.Literals([ RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, RelayMobileRegistrationScope, + RelayWebPushRegistrationScope, ]); export type RelayDpopAccessTokenScope = typeof RelayDpopAccessTokenScope.Type; @@ -882,6 +930,9 @@ export const RelayMetadataGroup = HttpApiGroup.make("metadata") HttpApiEndpoint.get("protectedResource", "/.well-known/oauth-protected-resource", { success: RelayProtectedResourceMetadata, }).annotate(OpenApi.Summary, "Read OAuth protected-resource metadata"), + HttpApiEndpoint.get("webPushVapidPublicKey", "/v1/web-push/vapid-public-key", { + success: RelayWebPushVapidPublicKeyResponse, + }).annotate(OpenApi.Summary, "Read the Web Push VAPID public key"), ) .annotate(OpenApi.Description, "OAuth and DPoP discovery metadata."); @@ -946,6 +997,33 @@ export const RelayMobileGroup = HttpApiGroup.make("mobile") .annotate(OpenApi.Description, "Mobile push-notification and Live Activity registration.") .middleware(RelayDpopClientAuth); +export const RelayRegisterWebPushEndpoint = HttpApiEndpoint.post( + "registerWebPush", + "/v1/web-push/subscriptions", + { + headers: RelayDpopRequestHeaders, + payload: RelayWebPushRegistrationRequest, + success: RelayOkResponse, + error: RelayAuthAndInternalErrors, + }, +).annotate(OpenApi.Summary, "Register or update a Web Push subscription"); + +export const RelayUnregisterWebPushEndpoint = HttpApiEndpoint.delete( + "unregisterWebPush", + "/v1/web-push/subscriptions/:subscriptionId", + { + headers: RelayDpopRequestHeaders, + params: RelayWebPushUnregistrationParams, + success: RelayOkResponse, + error: RelayAuthAndInternalErrors, + }, +).annotate(OpenApi.Summary, "Unregister a Web Push subscription"); + +export const RelayWebPushGroup = HttpApiGroup.make("webPush") + .add(RelayRegisterWebPushEndpoint, RelayUnregisterWebPushEndpoint) + .annotate(OpenApi.Description, "Standards-based Web Push subscription registration.") + .middleware(RelayDpopClientAuth); + export const RelayClientGroup = HttpApiGroup.make("client") .add( HttpApiEndpoint.get("listEnvironments", "/v1/environments", { @@ -1075,6 +1153,7 @@ export const RelayApi = HttpApi.make("RelayApi") RelayHealthGroup, RelayMetadataGroup, RelayMobileGroup, + RelayWebPushGroup, RelayClientGroup, RelayTokenGroup, RelayDpopClientGroup, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a6f75d157c0c..28db4b6425e0 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -188,14 +188,15 @@ export const ClientSettingsSchema = Schema.Struct({ // old keys, so everyone, including prior beta opt-outs, resets to the new // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - // Native OS notifications for agent tasks. Desktop-only: the settings section - // is hidden without a desktop bridge, and the keys are inert there. Default - // on because the OS still owns the real consent prompt (macOS asks on the - // first notification), and the trigger engine seeds silently on first - // observation, so a launch never produces a backlog of notifications. + // Native OS notifications for agent tasks. Desktop defaults on because the + // OS owns its consent prompt. Web push defaults off because browsers require + // an explicit user gesture before subscribing. desktopNotificationsEnabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + webPushNotificationsEnabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), desktopNotifyTaskCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), desktopNotifyTaskFailed: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), desktopNotifyApprovalNeeded: Schema.Boolean.pipe( @@ -827,6 +828,7 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), desktopNotificationsEnabled: Schema.optionalKey(Schema.Boolean), + webPushNotificationsEnabled: Schema.optionalKey(Schema.Boolean), desktopNotifyTaskCompleted: Schema.optionalKey(Schema.Boolean), desktopNotifyTaskFailed: Schema.optionalKey(Schema.Boolean), desktopNotifyApprovalNeeded: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/package.json b/packages/shared/package.json index f669bd0a452c..32f16278f186 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -19,6 +19,10 @@ "types": "./src/agentAwareness.ts", "import": "./src/agentAwareness.ts" }, + "./agentNotifications": { + "types": "./src/agentNotifications.ts", + "import": "./src/agentNotifications.ts" + }, "./git": { "types": "./src/git.ts", "import": "./src/git.ts" diff --git a/packages/shared/src/agentNotifications.ts b/packages/shared/src/agentNotifications.ts new file mode 100644 index 000000000000..e8b2377f60fd --- /dev/null +++ b/packages/shared/src/agentNotifications.ts @@ -0,0 +1,109 @@ +import type { DesktopNotificationKind } from "@t3tools/contracts"; + +import type { AgentAwarenessPhase } from "./agentAwareness.ts"; + +const ACTIVE_PHASES: ReadonlySet = new Set([ + "starting", + "running", + "waiting_for_input", + "waiting_for_approval", +]); + +const PRE_APPROVAL_PHASES: ReadonlySet = new Set([ + "starting", + "running", + "waiting_for_input", +]); + +export function agentNotificationKind( + previousPhase: AgentAwarenessPhase | null, + nextPhase: AgentAwarenessPhase | null, +): DesktopNotificationKind | null { + if (previousPhase === null || nextPhase === null) { + return null; + } + if (nextPhase === "completed" && ACTIVE_PHASES.has(previousPhase)) { + return "task-completed"; + } + if (nextPhase === "failed" && ACTIVE_PHASES.has(previousPhase)) { + return "task-failed"; + } + if (nextPhase === "waiting_for_approval" && PRE_APPROVAL_PHASES.has(previousPhase)) { + return "approval-needed"; + } + return null; +} + +const NOTIFICATION_TITLES: Readonly> = { + "task-completed": "Completed", + "task-failed": "Failed", + "approval-needed": "Approval Required", +}; + +const MAX_TITLE_PROJECT_LENGTH = 28; + +function shortenProjectName(projectTitle: string): string { + const trimmed = projectTitle.trim(); + if (trimmed.length <= MAX_TITLE_PROJECT_LENGTH) { + return trimmed; + } + const head = trimmed.slice(0, MAX_TITLE_PROJECT_LENGTH - 12).trimEnd(); + const tail = trimmed.slice(-9).trimStart(); + return `${head}…${tail}`; +} + +export function notificationTitle( + kind: DesktopNotificationKind, + projectTitle?: string | null, +): string { + const kindLabel = NOTIFICATION_TITLES[kind]; + const project = projectTitle === null || projectTitle === undefined ? "" : projectTitle.trim(); + return project.length > 0 ? `${kindLabel} - ${shortenProjectName(project)}` : kindLabel; +} + +const MAX_BODY_LENGTH = 180; + +export function toPlainNotificationText(markdown: string): string { + let text = markdown; + + text = text.replace(/```[\s\S]*?```/g, " (code) "); + text = text.replace(/~~~[\s\S]*?~~~/g, " (code) "); + text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1"); + text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1"); + text = text.replace(/`([^`]+)`/g, "$1"); + text = text.replace(/~~([^~]+)~~/g, "$1"); + text = text.replace(/\*\*([^*]+)\*\*/g, "$1"); + text = text.replace(/__([^_]+)__/g, "$1"); + text = text.replace(/\*([^*]+)\*/g, "$1"); + text = text.replace(/(^|\s)_([^_]+)_(?=\s|$)/g, "$1$2"); + text = text.replace(/^\s{0,3}#{1,6}\s+/gm, ""); + text = text.replace(/^\s{0,3}>\s?/gm, ""); + text = text.replace(/^\s{0,3}[-*+]\s+\[[ xX]\]\s+/gm, ""); + text = text.replace(/^\s{0,3}[-*+]\s+/gm, ""); + text = text.replace(/^\s{0,3}\d+\.\s+/gm, ""); + text = text.replace(/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/gm, " "); + text = text.replace(//g, " "); + text = text.replace(/<[^>]+>/g, ""); + return text.replace(/\s+/g, " ").trim(); +} + +export function truncateNotificationBody(value: string): string { + const trimmed = value.trim(); + if (trimmed.length <= MAX_BODY_LENGTH) { + return trimmed; + } + return `${trimmed.slice(0, MAX_BODY_LENGTH - 1).trimEnd()}…`; +} + +export function notificationBody(input: { + readonly responseText: string | null; + readonly threadTitle: string; + readonly fallbackHeadline: string; +}): string { + const plain = input.responseText === null ? "" : toPlainNotificationText(input.responseText); + if (plain.length > 0) { + return truncateNotificationBody(plain); + } + const title = input.threadTitle.trim(); + return truncateNotificationBody(title.length > 0 ? title : input.fallbackHeadline); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b9999931837..7d2b5af444b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -666,6 +666,9 @@ importers: '@effect/sql-pg': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@mmmike/web-push': + specifier: 0.1.0 + version: 0.1.0 '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -3101,6 +3104,9 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@mmmike/web-push@0.1.0': + resolution: {integrity: sha512-Laoa95uZso8lTi+jGlfJ3Z11ZAOXvBeTzX1go+UXHVs0AbF4Q7WA8FuYFR1Ca69hY1JWrsfBnstFMOwXf+UVhw==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -13232,6 +13238,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@mmmike/web-push@0.1.0': {} + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.27)