From bfb596c4762ff439902969390831a00667223ee9 Mon Sep 17 00:00:00 2001 From: malekelkssas Date: Mon, 6 Apr 2026 12:03:06 +0200 Subject: [PATCH 1/2] Add OS notifications for turn completions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New setting to enable/disable notifications when turns finish - Tracker detects thread state transitions (running → idle/error/stopped) - Notifications only show when tab is unfocused; fallback to in-app toast when focused - Handles permission requests and restricted contexts gracefully --- .../components/settings/SettingsPanels.tsx | 32 +++ apps/web/src/routes/__root.tsx | 44 ++++ apps/web/src/turnCompletionNotifier.ts | 188 ++++++++++++++++++ packages/contracts/src/settings.ts | 1 + 4 files changed, 265 insertions(+) create mode 100644 apps/web/src/turnCompletionNotifier.ts diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d534eefaa47f..d3832cc64557 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -905,6 +905,38 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + enableTurnCompletionNotifications: + DEFAULT_UNIFIED_SETTINGS.enableTurnCompletionNotifications, + }) + } + /> + ) : null + } + control={ + { + const enabled = Boolean(checked); + if (enabled && "Notification" in window && Notification.permission === "default") { + void Notification.requestPermission(); + } + updateSettings({ enableTurnCompletionNotifications: enabled }); + }} + aria-label="Enable turn completion notifications" + /> + } + /> + Promise>(async () => undefined); const serverConfig = useServerConfig(); + // Turn-completion OS notifications + const { enableTurnCompletionNotifications } = useSettings(); + const notificationsEnabledRef = useRef(enableTurnCompletionNotifications); + notificationsEnabledRef.current = enableTurnCompletionNotifications; + const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { if (!payload) return; @@ -338,6 +350,10 @@ function EventRouter() { })), ); clearPromotedDraftThreads(threads.map((thread) => thread.id)); + // Seed the turn-completion tracker so we detect transitions that + // started before our event subscription (e.g. a running turn + // present in the snapshot after a page refresh). + seedRunningThreads(threads); const draftThreadIds = Object.keys( useComposerDraftStore.getState().draftThreadsByThreadId, ) as ThreadId[]; @@ -376,6 +392,17 @@ function EventRouter() { return; } + // Extract turn-completion transitions *before* the store update so + // the running-thread tracker sees the pre-update session state. Thread + // and project titles are read from the current store for display. + const turnCompletions = notificationsEnabledRef.current + ? extractTurnCompletions( + nextEvents, + (id) => useStore.getState().threads.find((t) => t.id === id), + (id) => useStore.getState().projects.find((p) => p.id === id), + ) + : []; + const batchEffects = deriveOrchestrationBatchEffects(nextEvents); const uiEvents = coalesceOrchestrationUiEvents(nextEvents); const needsProjectUiSync = nextEvents.some( @@ -418,6 +445,22 @@ function EventRouter() { for (const threadId of batchEffects.removeTerminalStateThreadIds) { removeTerminalState(threadId); } + + // Fire OS notifications for completed turns (deferred to end so store + // updates are already applied if the user clicks through). Falls + // back to an in-app toast when the tab is focused or when the + // browser blocked the OS notification. + for (const completion of turnCompletions) { + const { title, body, osNotificationSent } = showTurnCompletionNotification(completion); + if (!osNotificationSent) { + toastManager.add({ + type: completion.status === "error" ? "error" : "success", + title, + description: body, + data: { threadId: completion.threadId, dismissAfterVisibleMs: 5_000 }, + }); + } + } }; const flushPendingDomainEvents = () => { flushPendingDomainEventsScheduled = false; @@ -568,6 +611,7 @@ function EventRouter() { flushPendingDomainEventsScheduled = false; pendingDomainEvents.length = 0; queryInvalidationThrottler.cancel(); + resetRunningThreadTracker(); unsubDomainEvent(); unsubTerminalEvent(); }; diff --git a/apps/web/src/turnCompletionNotifier.ts b/apps/web/src/turnCompletionNotifier.ts new file mode 100644 index 000000000000..bd47a26ff07b --- /dev/null +++ b/apps/web/src/turnCompletionNotifier.ts @@ -0,0 +1,188 @@ +/** + * OS-level notifications for turn completions. + * + * Tracks which threads are currently "running" and fires a browser + * Notification when a thread transitions out of the running state + * (i.e. the agent finished, was interrupted, errored, or was stopped). + * + * Notifications are only shown when the tab is not focused so the user + * gets an ambient signal without being disrupted while actively watching. + */ +import type { OrchestrationEvent, OrchestrationSessionStatus, ThreadId } from "@t3tools/contracts"; +import type { Thread, Project } from "./types"; + +// ── Running-thread tracker ────────────────────────────────────────── + +const runningThreadIds = new Set(); + +export function resetRunningThreadTracker(): void { + runningThreadIds.clear(); +} + +/** + * Seed the tracker with threads that are already running in the store. + * Call after snapshot bootstrap so we can detect transitions that + * started before the current event subscription. + */ +export function seedRunningThreads(threads: readonly Thread[]): void { + for (const thread of threads) { + if (thread.session?.orchestrationStatus === "running") { + runningThreadIds.add(thread.id); + } + } +} + +// ── Permission helpers ────────────────────────────────────────────── + +export function notificationsSupported(): boolean { + return typeof window !== "undefined" && "Notification" in window; +} + +export function requestNotificationPermission(): Promise { + if (!notificationsSupported()) { + return Promise.resolve("denied" as NotificationPermission); + } + if (Notification.permission !== "default") { + return Promise.resolve(Notification.permission); + } + return Notification.requestPermission(); +} + +// ── Notification body derivation ──────────────────────────────────── + +interface TurnCompletionInfo { + threadId: ThreadId; + threadTitle: string; + projectName: string | null; + status: OrchestrationSessionStatus; +} + +const STATUS_LABELS: Partial> = { + idle: "completed", + ready: "completed", + interrupted: "interrupted", + stopped: "stopped", + error: "failed", +}; + +/** + * Scans an event batch for `thread.session-set` transitions that signal + * a thread leaving the "running" state. Returns one entry per such + * transition, enriched with thread/project metadata from the store. + * + * Detection uses two sources: + * 1. The in-memory `runningThreadIds` set (populated from earlier event + * batches and snapshot seeding). + * 2. The store's current `orchestrationStatus` for the thread (covers + * cases where the running state was set before we started tracking, + * e.g. snapshot bootstrap or same-batch transitions). + */ +export function extractTurnCompletions( + events: readonly OrchestrationEvent[], + getThread: (id: ThreadId) => Thread | undefined, + getProject: (id: string) => Project | undefined, +): TurnCompletionInfo[] { + const completions: TurnCompletionInfo[] = []; + + for (const event of events) { + if (event.type !== "thread.session-set") { + continue; + } + + const { threadId } = event.payload; + const status = event.payload.session.status; + + if (status === "running") { + runningThreadIds.add(threadId); + continue; + } + + // "starting" is a transient pre-run state — never a completion. + if (status === "starting") { + continue; + } + + // Check both the in-memory tracker AND the store's current state so + // we catch transitions that started before our subscription (e.g. + // snapshot-bootstrapped sessions or page refreshes). + const wasRunning = + runningThreadIds.has(threadId) || + getThread(threadId)?.session?.orchestrationStatus === "running"; + + if (!wasRunning) { + continue; + } + runningThreadIds.delete(threadId); + + const thread = getThread(threadId); + const project = thread ? getProject(thread.projectId) : undefined; + + completions.push({ + threadId, + threadTitle: thread?.title ?? "Thread", + projectName: project?.name ?? null, + status, + }); + } + + return completions; +} + +// ── Notification dispatch ─────────────────────────────────────────── + +function buildNotificationContent(info: TurnCompletionInfo): { title: string; body: string } { + const statusLabel = STATUS_LABELS[info.status] ?? info.status; + return { + title: `Turn ${statusLabel}`, + body: info.projectName ? `${info.threadTitle} — ${info.projectName}` : info.threadTitle, + }; +} + +/** + * Whether the user is actively looking at the page. + * + * Uses `document.hasFocus()` (detects if the browser window has OS-level + * focus) combined with `document.hidden` (detects if the tab is visible). + * This correctly identifies the case where the tab is visible but the + * user switched to a different application window. + */ +function isPageActivelyFocused(): boolean { + if (typeof document === "undefined") return false; + return document.hasFocus() && !document.hidden; +} + +/** + * Fires an OS-level notification when the user is not actively focused + * on the page, and returns the content so the caller can fall back to + * an in-app toast when the page is focused or the OS channel is + * unavailable. + */ +export function showTurnCompletionNotification(info: TurnCompletionInfo): { + title: string; + body: string; + osNotificationSent: boolean; +} { + const content = buildNotificationContent(info); + + if ( + isPageActivelyFocused() || + !notificationsSupported() || + Notification.permission !== "granted" + ) { + return { ...content, osNotificationSent: false }; + } + + try { + // eslint-disable-next-line no-new -- Notification is intentionally fire-and-forget. + new Notification(content.title, { + body: content.body, + tag: `t3-turn-${info.threadId}`, + icon: "/favicon.svg", + }); + return { ...content, osNotificationSent: true }; + } catch { + // Notification constructor can throw in restricted contexts (e.g. some + // sandboxed iframes). Fall back to in-app toast via caller. + return { ...content, osNotificationSent: false }; + } +} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6633ce42a6e5..595d4a985e04 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -27,6 +27,7 @@ export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + enableTurnCompletionNotifications: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), ), From 08b67ff8eaba6ab454364fb65830945974fbc6ac Mon Sep 17 00:00:00 2001 From: malekelkssas Date: Wed, 8 Apr 2026 10:38:51 +0200 Subject: [PATCH 2/2] Enhance turn completion notification settings - Update the tracker to replace running threads with those from the current snapshot, ensuring accurate state representation. - Integrate notification permission request into settings panel for turn completion notifications. - Adjust settings restoration to include the new turn completion notification option. --- apps/web/src/components/settings/SettingsPanels.tsx | 10 ++++++++-- apps/web/src/turnCompletionNotifier.ts | 8 +++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d3832cc64557..2a975f676dcb 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -46,6 +46,7 @@ import { resolveAppModelSelectionState, } from "../../modelSelection"; import { ensureNativeApi, readNativeApi } from "../../nativeApi"; +import { requestNotificationPermission } from "../../turnCompletionNotifier"; import { useStore } from "../../store"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "../../lib/utils"; @@ -469,6 +470,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), + ...(settings.enableTurnCompletionNotifications !== + DEFAULT_UNIFIED_SETTINGS.enableTurnCompletionNotifications + ? ["Turn completion notifications"] + : []), ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), @@ -489,6 +494,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, + settings.enableTurnCompletionNotifications, settings.timestampFormat, theme, ], @@ -927,8 +933,8 @@ export function GeneralSettingsPanel() { checked={settings.enableTurnCompletionNotifications} onCheckedChange={(checked) => { const enabled = Boolean(checked); - if (enabled && "Notification" in window && Notification.permission === "default") { - void Notification.requestPermission(); + if (enabled) { + void requestNotificationPermission(); } updateSettings({ enableTurnCompletionNotifications: enabled }); }} diff --git a/apps/web/src/turnCompletionNotifier.ts b/apps/web/src/turnCompletionNotifier.ts index bd47a26ff07b..9127afcbb301 100644 --- a/apps/web/src/turnCompletionNotifier.ts +++ b/apps/web/src/turnCompletionNotifier.ts @@ -20,11 +20,13 @@ export function resetRunningThreadTracker(): void { } /** - * Seed the tracker with threads that are already running in the store. - * Call after snapshot bootstrap so we can detect transitions that - * started before the current event subscription. + * Replace the tracker with threads that are running in the given snapshot. + * Call after each snapshot sync (bootstrap / recovery) so the set matches + * server state and stale IDs from before a disconnect cannot produce false + * completion transitions. */ export function seedRunningThreads(threads: readonly Thread[]): void { + runningThreadIds.clear(); for (const thread of threads) { if (thread.session?.orchestrationStatus === "running") { runningThreadIds.add(thread.id);