diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 0c7bc9b99..f28433592 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -56,6 +56,11 @@ import { } from "./deeplink.js"; import { disconnectGoogleIfScopesStale } from "./oauth-handler.js"; +// Captured as early as possible so it reflects actual process start. Used to +// gate grace-eligible notifications (e.g. the burst of background-task +// completions a reopen replays) — see ElectronNotificationService. +const APP_LAUNCHED_AT = Date.now(); + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -330,7 +335,7 @@ app.whenReady().then(async () => { }); registerBrowserControlService(new ElectronBrowserControlService()); - registerNotificationService(new ElectronNotificationService()); + registerNotificationService(new ElectronNotificationService(APP_LAUNCHED_AT)); setupIpcHandlers(); setupBrowserEventForwarding(); diff --git a/apps/x/apps/main/src/notification/electron-notification-service.ts b/apps/x/apps/main/src/notification/electron-notification-service.ts index d86a48982..226603437 100644 --- a/apps/x/apps/main/src/notification/electron-notification-service.ts +++ b/apps/x/apps/main/src/notification/electron-notification-service.ts @@ -1,5 +1,6 @@ import { BrowserWindow, Notification, shell } from "electron"; import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js"; +import { shouldSuppressDuringStartupGrace } from "@x/core/dist/application/notification/service.js"; import { dispatchUrl } from "../deeplink.js"; const HTTP_URL = /^https?:\/\//i; @@ -11,11 +12,27 @@ export class ElectronNotificationService implements INotificationService { // gets dropped and macOS clicks just focus the app silently. private active = new Set(); + // Timestamp the app launched, used to gate grace-eligible notifications (see + // NotifyInput.suppressDuringStartupGrace). Captured by the caller in main.ts + // so it reflects process start rather than whenever the first notify fires. + private readonly launchedAt: number; + + constructor(launchedAt: number = Date.now()) { + this.launchedAt = launchedAt; + } + isSupported(): boolean { return Notification.isSupported(); } - notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground }: NotifyInput): void { + notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground, suppressDuringStartupGrace }: NotifyInput): void { + // Startup grace: a reopen replays every background task that completed + // while the app was closed, so grace-eligible notifications fired in the + // first moments after launch are dropped to avoid a notification flood. + if (shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace }, this.launchedAt)) { + return; + } + // Ambient notifications are suppressed while the app is in the // foreground — the user is already looking at it. A window counts as // foreground only if it's actually focused (minimized / other-space diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f9019eded..e0db98d96 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -589,12 +589,13 @@ type ViewState = | { type: 'suggested-topics' } | { type: 'meetings' } | { type: 'live-notes' } - | { type: 'email' } + | { type: 'email'; threadId?: string } | { type: 'workspace'; path?: string } | { type: 'knowledge-view'; folderPath?: string; mode?: KnowledgeViewMode } | { type: 'chat-history' } | { type: 'home' } | { type: 'code' } + | { type: 'bg-tasks' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -603,6 +604,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type === 'task' && b.type === 'task') return a.name === b.name if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '') if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') && (a.mode ?? '') === (b.mode ?? '') + if (a.type === 'email' && b.type === 'email') return (a.threadId ?? '') === (b.threadId ?? '') return true // both graph } @@ -610,7 +612,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { * Parse a rowboat:// deep link into a ViewState. Returns null if the URL is * malformed or names an unknown target. * - * Shape: rowboat://open?type=&... + * Shape: rowboat://open?type=&... * file: ?type=file&path=knowledge/foo.md * chat: ?type=chat&runId=abc123 (runId optional) * graph: ?type=graph @@ -618,6 +620,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { * suggested-topics: ?type=suggested-topics * meetings: ?type=meetings * live-notes: ?type=live-notes + * email: ?type=email */ function parseDeepLink(input: string): ViewState | null { const SCHEME = 'rowboat://' @@ -646,6 +649,10 @@ function parseDeepLink(input: string): ViewState | null { return { type: 'meetings' } case 'live-notes': return { type: 'live-notes' } + case 'email': { + const threadId = params.get('threadId') + return { type: 'email', threadId: threadId || undefined } + } case 'workspace': { const path = params.get('path') return { type: 'workspace', path: path ?? undefined } @@ -665,6 +672,8 @@ function parseDeepLink(input: string): ViewState | null { return { type: 'home' } case 'code': return { type: 'code' } + case 'bg-tasks': + return { type: 'bg-tasks' } default: return null } @@ -3637,6 +3646,7 @@ function App() { if (isChatHistoryOpen) return { type: 'chat-history' } if (isHomeOpen) return { type: 'home' } if (isCodeOpen) return { type: 'code' } + if (isBgTasksOpen) return { type: 'bg-tasks' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } @@ -3968,6 +3978,12 @@ function App() { setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) setIsHomeOpen(false) + // Deep links (e.g. a new-email notification) carry the thread to open; + // bump the version so EmailView re-selects it even if email is already open. + if (view.threadId) { + setEmailInitialThreadId(view.threadId) + setEmailThreadIdVersion((v) => v + 1) + } ensureEmailFileTab() return case 'workspace': @@ -4055,6 +4071,18 @@ function App() { setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) ensureCodeFileTab() return + case 'bg-tasks': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) + setIsBgTasksOpen(true) + ensureBgTasksFileTab() + return case 'chat': setSelectedPath(null) setIsGraphOpen(false) @@ -4083,7 +4111,7 @@ function App() { } return } - }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, ensureCodeFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, ensureCodeFileTab, ensureBgTasksFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 1319abb9b..52873761b 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2075,7 +2075,7 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { // --- Notification Settings --- -type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" +type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" | "background_task" const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; description: string }[] = [ { @@ -2093,6 +2093,11 @@ const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; de label: "Permission requests", description: "When an agent needs your approval to run a tool. Always shown, even when the app is focused.", }, + { + key: "background_task", + label: "Background agents", + description: "When a background agent you've set up has something to surface. Click to open it on the background tasks page.", + }, ] function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) { diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 0f2c22d54..da756b2cb 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -448,6 +448,20 @@ export class AgentRuntime implements IAgentRuntime { finalState.ingest(event); } if (finalState.getPendingPermissions().length === 0) { + // This generic completion ping is only for real user + // chats (copilot_chat). Skip it for: + // - knowledge_sync: an internal, auto-running agent + // (knowledge-graph generation) that never notifies at + // all and has no user-facing chat to "Open". + // - background_task_agent: a user-configured agent that + // DOES notify, but exclusively through its own + // notify-user path; firing this ping too would + // duplicate that notification. + // (The finally block still runs on this early return.) + if ( + finalState.runUseCase === "knowledge_sync" || + finalState.runUseCase === "background_task_agent" + ) return; void notifyIfEnabled("chat_completion", { title: "Response ready", message: "Your agent finished responding.", diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 4ec1eb1bc..3d80f0d24 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -99,6 +99,7 @@ import { getAccessToken } from "../../auth/tokens.js"; import { API_URL } from "../../config/env.js"; import type { IBrowserControlService } from "../browser-control/service.js"; import type { INotificationService } from "../notification/service.js"; +import { notifyIfEnabled } from "../notification/notifier.js"; // Parser libraries are loaded dynamically inside parseFile.execute() // to avoid pulling pdfjs-dist's DOM polyfills into the main bundle. // Import paths are computed so esbuild cannot statically resolve them. @@ -1659,13 +1660,44 @@ export const BuiltinTools: z.infer = { return false; } }, - execute: async ({ title, message, link, actionLabel, secondaryActions }: { title?: string; message: string; link?: string; actionLabel?: string; secondaryActions?: Array<{ label: string; link: string }> }) => { + execute: async ({ title, message, link, actionLabel, secondaryActions }: { title?: string; message: string; link?: string; actionLabel?: string; secondaryActions?: Array<{ label: string; link: string }> }, ctx?: ToolContext) => { try { const service = container.resolve('notificationService'); if (!service.isSupported()) { return { success: false, error: 'Notifications are not supported on this system' }; } - service.notify({ title, message, link, actionLabel, secondaryActions }); + let uc = getCurrentUseCase()?.useCase; + // ALS doesn't reliably propagate across the run's async generator, + // so when the in-context use-case is missing, fall back to the + // persisted use case on the run record via ctx.runId. + if (!uc && ctx?.runId) { + try { + const { fetchRun } = await import("../../runs/runs.js"); + const run = await fetchRun(ctx.runId); + uc = run.useCase; + } catch { + // best effort — fall through to the default branch + } + } + if (uc === 'background_task_agent') { + // User-configured background agent: gate behind the + // background_task category (toggleable), suppress the reopen + // flood, and default the deep-link to the background tasks + // page if the agent didn't supply its own link. + await notifyIfEnabled('background_task', { + title, + message, + link: link ?? 'rowboat://open?type=bg-tasks', + actionLabel, + secondaryActions, + suppressDuringStartupGrace: true, + onlyWhenBackground: true, + }); + } else { + // Regular chat (or any other) agent calling notify-user: + // notify directly as before. + service.notify({ title, message, link, actionLabel, secondaryActions }); + } return { success: true }; } catch (error) { return { diff --git a/apps/x/packages/core/src/application/notification/service.test.ts b/apps/x/packages/core/src/application/notification/service.test.ts new file mode 100644 index 000000000..038a5b029 --- /dev/null +++ b/apps/x/packages/core/src/application/notification/service.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { shouldSuppressDuringStartupGrace, STARTUP_GRACE_MS } from './service.js'; + +describe('shouldSuppressDuringStartupGrace (background-task reopen flood)', () => { + const launchedAt = 1_700_000_000_000; + + it('suppresses a grace-eligible notification fired inside the window', () => { + const now = launchedAt + STARTUP_GRACE_MS - 1; + expect(shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: true }, launchedAt, now)).toBe(true); + }); + + it('lets a grace-eligible notification through once the window has passed', () => { + const now = launchedAt + STARTUP_GRACE_MS; + expect(shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: true }, launchedAt, now)).toBe(false); + }); + + it('never suppresses a notification that is not grace-eligible', () => { + const now = launchedAt + 1; // well inside the window + expect(shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: false }, launchedAt, now)).toBe(false); + expect(shouldSuppressDuringStartupGrace({}, launchedAt, now)).toBe(false); + }); + + it('respects a custom grace window', () => { + const customWindow = 5_000; + expect( + shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: true }, launchedAt, launchedAt + 4_999, customWindow), + ).toBe(true); + expect( + shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: true }, launchedAt, launchedAt + 5_000, customWindow), + ).toBe(false); + }); +}); diff --git a/apps/x/packages/core/src/application/notification/service.ts b/apps/x/packages/core/src/application/notification/service.ts index 2d32878bb..4eabb2c03 100644 --- a/apps/x/packages/core/src/application/notification/service.ts +++ b/apps/x/packages/core/src/application/notification/service.ts @@ -12,9 +12,40 @@ export interface NotifyInput { * regardless of focus (e.g. an agent permission request that blocks a run). */ onlyWhenBackground?: boolean; + /** + * When true, the notification is suppressed if it fires within the startup + * grace window (see STARTUP_GRACE_MS). This exists for notifications that a + * just-launched app can emit in a burst — most notably background-task + * completions: when the app reopens after being closed, every task that was + * queued while it was down completes at once and would otherwise flood the + * user. Fresh, user-driven activity happens after the window closes. + */ + suppressDuringStartupGrace?: boolean; } export interface INotificationService { isSupported(): boolean; notify(input: NotifyInput): void; } + +/** + * How long after launch grace-eligible notifications stay suppressed. Long + * enough to swallow the reopen burst of queued background tasks, short enough + * that a task genuinely finishing right after launch still pings the user. + */ +export const STARTUP_GRACE_MS = 60_000; + +/** + * Pure decision for the startup grace gate, kept out of the Electron service so + * it can be unit-tested without an Electron runtime. Returns true when the + * notification should be dropped because it is grace-eligible and we are still + * inside the window measured from `launchedAt`. + */ +export function shouldSuppressDuringStartupGrace( + input: Pick, + launchedAt: number, + now: number = Date.now(), + graceWindowMs: number = STARTUP_GRACE_MS, +): boolean { + return Boolean(input.suppressDuringStartupGrace) && now - launchedAt < graceWindowMs; +} diff --git a/apps/x/packages/core/src/background-tasks/runner.ts b/apps/x/packages/core/src/background-tasks/runner.ts index 39450d2e2..dee147d8d 100644 --- a/apps/x/packages/core/src/background-tasks/runner.ts +++ b/apps/x/packages/core/src/background-tasks/runner.ts @@ -6,6 +6,7 @@ import { getBackgroundTaskAgentModel } from '../models/defaults.js'; import { extractAgentResponse, waitForRunCompletion } from '../agents/utils.js'; import { buildTriggerBlock } from '../agents/build-trigger-block.js'; import { backgroundTaskBus } from './bus.js'; +import { withUseCase } from '../analytics/use_case.js'; const log = new PrefixLogger('BgTask:Agent'); @@ -183,7 +184,18 @@ export async function runBackgroundTask( }); try { - await createMessage(runId, buildMessage(slug, task, trigger, context, codeProject)); + // Establish the use-case context for the whole run so every tool the + // agent calls (notably notify-user) reads `background_task_agent` via + // getCurrentUseCase(). createMessage synchronously fires + // agentRuntime.trigger(), so the detached run loop — and the tool + // calls within it — inherit this AsyncLocalStorage context. (The + // runtime's own enterUseCase runs inside an async generator and + // doesn't reliably propagate to tool execution, so we set it here at + // the trigger point instead.) + await withUseCase( + { useCase: 'background_task_agent', subUseCase: trigger }, + () => createMessage(runId, buildMessage(slug, task, trigger, context, codeProject)), + ); await waitForRunCompletion(runId, { throwOnError: true }); const summary = await extractAgentResponse(runId); diff --git a/apps/x/packages/core/src/code-mode/sessions/status-tracker.ts b/apps/x/packages/core/src/code-mode/sessions/status-tracker.ts index 92abdf807..f6dc0fe9a 100644 --- a/apps/x/packages/core/src/code-mode/sessions/status-tracker.ts +++ b/apps/x/packages/core/src/code-mode/sessions/status-tracker.ts @@ -2,9 +2,8 @@ import z from 'zod'; import { RunEvent } from '@x/shared/dist/runs.js'; import type { IBus } from '../../application/lib/bus.js'; import type { ICodeSessionsRepo } from './repo.js'; -import type { INotificationService } from '../../application/notification/service.js'; +import { notifyIfEnabled } from '../../application/notification/notifier.js'; import type { CodeSessionStatus, CodeSession } from '@x/shared/dist/code-sessions.js'; -import container from '../../di/container.js'; export type StatusListener = (sessionId: string, status: CodeSessionStatus) => void; @@ -107,17 +106,16 @@ export class CodeSessionStatusTracker { } private async notify(sessionId: string, previous: CodeSessionStatus, next: CodeSessionStatus): Promise { - let notificationService: INotificationService; - try { - notificationService = container.resolve('notificationService'); - } catch { - return; // not registered (e.g. tests) - } - if (!notificationService.isSupported()) return; + // Route through notifyIfEnabled so the user's notification-category + // toggles are honoured — a coding agent asking for approval maps to + // `agent_permission`, and one finishing its turn maps to + // `chat_completion`. notifyIfEnabled also resolves the service, checks + // platform support, and swallows errors, so a disabled toggle, missing + // service (e.g. tests), or unsupported platform all no-op safely. const session = await this.codeSessionsRepo.get(sessionId); const title = session?.title ?? 'Coding session'; if (next === 'needs-you') { - notificationService.notify({ + await notifyIfEnabled('agent_permission', { title, message: 'The coding agent needs your approval.', }); @@ -126,7 +124,7 @@ export class CodeSessionStatusTracker { // the user has plausibly moved on to something else. const since = this.busySince.get(sessionId); if (since !== undefined && Date.now() - since > 30_000) { - notificationService.notify({ + await notifyIfEnabled('chat_completion', { title, message: 'The coding agent finished its turn.', }); diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.test.ts b/apps/x/packages/core/src/knowledge/sync_gmail.test.ts index 5da55bbc8..8a0d307e8 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.test.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + isEmailTooOldToNotify, + NEW_EMAIL_MAX_AGE_MS, sanitizeReplyBodyForGmailReply, stripGmailQuotedReplyHtml, stripGmailQuotedReplyText, @@ -40,3 +42,31 @@ describe('Gmail reply body sanitization', () => { expect(result.bodyHtml).toBe('

Sounds good, thanks.

'); }); }); + +describe('isEmailTooOldToNotify (stale backlog suppression)', () => { + const now = 1_700_000_000_000; + + it('suppresses emails older than the freshness window', () => { + const old = now - NEW_EMAIL_MAX_AGE_MS - 1; + expect(isEmailTooOldToNotify(old, now)).toBe(true); + }); + + it('notifies for emails within the freshness window', () => { + const recent = now - (NEW_EMAIL_MAX_AGE_MS - 1); + expect(isEmailTooOldToNotify(recent, now)).toBe(false); + }); + + it('notifies for emails exactly at the window boundary', () => { + expect(isEmailTooOldToNotify(now - NEW_EMAIL_MAX_AGE_MS, now)).toBe(false); + }); + + it('notifies when the email date is unknown (dateMs === 0)', () => { + // 0 means snapshotDateMs could not parse a date; err toward notifying + // rather than silently dropping genuinely-new mail. + expect(isEmailTooOldToNotify(0, now)).toBe(false); + }); + + it('notifies for a brand-new email (dateMs === now)', () => { + expect(isEmailTooOldToNotify(now, now)).toBe(false); + }); +}); diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 52f8ca6f3..7868a34ed 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -221,20 +221,40 @@ function summarizeGmailSync(threads: SyncedThread[]): string { return lines.join('\n'); } +/** + * A "new email" notification for a message older than this is treated as a + * stale backlog item — e.g. Gmail replaying history after the app reopens from + * a long offline period — and suppressed, so a reopen doesn't surface day-old + * mail as if it just arrived. + */ +export const NEW_EMAIL_MAX_AGE_MS = 5 * 60 * 1000; + +/** + * True when an email is too old to be worth a "new email" ping. A `dateMs` of 0 + * means the age couldn't be determined, in which case we err toward notifying + * rather than risk silently dropping genuinely-new mail. + */ +export function isEmailTooOldToNotify(dateMs: number, now: number = Date.now()): boolean { + return dateMs > 0 && now - dateMs > NEW_EMAIL_MAX_AGE_MS; +} + /** * Fire one OS notification per genuinely-new email thread. Only ever called * from the partial-sync (incremental) path, so the first-time connect — which - * goes through fullSync — never notifies. Suppressed while the app is focused. + * goes through fullSync — never notifies. Suppressed while the app is focused, + * and for stale backlog (see isEmailTooOldToNotify). */ function notifyNewEmails(threads: SyncedThread[]): void { + const now = Date.now(); for (const { threadId } of threads) { const snapshot = readCachedSnapshot(threadId)?.snapshot; + if (snapshot && isEmailTooOldToNotify(snapshotDateMs(snapshot), now)) continue; const subject = snapshot?.subject?.trim() || '(no subject)'; const from = snapshot?.from?.trim(); void notifyIfEnabled('new_email', { title: from ? `New email from ${from}` : 'New email', message: subject, - link: 'rowboat://open?type=chat', + link: `rowboat://open?type=email&threadId=${threadId}`, actionLabel: 'Open', onlyWhenBackground: true, }); diff --git a/apps/x/packages/shared/src/notification-settings.ts b/apps/x/packages/shared/src/notification-settings.ts index 1ab7646b3..33608fb62 100644 --- a/apps/x/packages/shared/src/notification-settings.ts +++ b/apps/x/packages/shared/src/notification-settings.ts @@ -6,17 +6,20 @@ import { z } from 'zod'; * - chat_completion: an agent finished generating a response * - new_email: a new email arrived during incremental Gmail sync * - agent_permission: an agent is requesting permission to run a tool + * - background_task: a background task agent pinged via the notify-user tool */ export const NotificationCategorySchema = z.enum([ 'chat_completion', 'new_email', 'agent_permission', + 'background_task', ]); export const NotificationCategoriesSchema = z.object({ chat_completion: z.boolean(), new_email: z.boolean(), agent_permission: z.boolean(), + background_task: z.boolean(), }); export const NotificationSettingsSchema = z.object({ @@ -28,6 +31,7 @@ export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = { chat_completion: true, new_email: true, agent_permission: true, + background_task: true, }, };