diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 364442be..35112709 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -38,6 +38,7 @@ import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/ass import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; +import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js'; import * as composioHandler from './composio-handler.js'; import { consumePendingDeepLink } from './deeplink.js'; import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js'; @@ -1095,6 +1096,13 @@ export function setupIpcHandlers() { 'billing:getInfo': async () => { return await getBillingInfo(); }, + 'notifications:getSettings': async () => { + return loadNotificationSettings(); + }, + 'notifications:setSettings': async (_event, args) => { + saveNotificationSettings(args); + return { success: true }; + }, // Embedded browser handlers (WebContentsView + navigation) ...browserIpcHandlers, }); 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 dd37e37d..d86a4898 100644 --- a/apps/x/apps/main/src/notification/electron-notification-service.ts +++ b/apps/x/apps/main/src/notification/electron-notification-service.ts @@ -15,7 +15,15 @@ export class ElectronNotificationService implements INotificationService { return Notification.isSupported(); } - notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void { + notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground }: NotifyInput): void { + // 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 + // windows are not), so this correctly treats those as background. + if (onlyWhenBackground && BrowserWindow.getAllWindows().some((w) => w.isFocused())) { + return; + } + // Build the actions array AND a parallel index → link map. // macOS shows actions[0] inline (Banner) or all of them (Alert); // additional ones live behind the chevron menu. diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index c45ed64e..691b4da2 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback, useMemo } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell } from "lucide-react" import { Dialog, @@ -27,7 +27,7 @@ import { AccountSettings } from "@/components/settings/account-settings" import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings" import type { ApprovalPolicy } from "@x/shared/src/code-mode.js" -type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help" +type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help" interface TabConfig { id: ConfigTab @@ -83,6 +83,12 @@ const tabs: TabConfig[] = [ icon: Palette, description: "Customize the look and feel", }, + { + id: "notifications", + label: "Notifications", + icon: Bell, + description: "Choose which notifications you receive", + }, { id: "note-tagging", label: "Note Tagging", @@ -1987,6 +1993,99 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { ) } +// --- Notification Settings --- + +type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" + +const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; description: string }[] = [ + { + key: "chat_completion", + label: "Chat responses", + description: "When an agent finishes responding while the app is in the background.", + }, + { + key: "new_email", + label: "New email", + description: "When a new email arrives during sync while the app is in the background.", + }, + { + key: "agent_permission", + label: "Permission requests", + description: "When an agent needs your approval to run a tool. Always shown, even when the app is focused.", + }, +] + +function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) { + const [categories, setCategories] = useState | null>(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!dialogOpen) return + let cancelled = false + async function load() { + try { + const result = await window.ipc.invoke("notifications:getSettings", null) + if (!cancelled) setCategories(result.categories) + } catch { + if (!cancelled) toast.error("Failed to load notification settings") + } + } + load() + return () => { cancelled = true } + }, [dialogOpen]) + + const handleToggle = useCallback(async (key: NotificationCategoryKey, next: boolean) => { + // Optimistic update with rollback on failure. + const previous = categories + if (!previous) return + const updated = { ...previous, [key]: next } + setCategories(updated) + setSaving(true) + try { + await window.ipc.invoke("notifications:setSettings", { categories: updated }) + } catch { + setCategories(previous) + toast.error("Failed to update notification settings") + } finally { + setSaving(false) + } + }, [categories]) + + if (!categories) { + return ( +
+ + Loading... +
+ ) + } + + return ( +
+
+ Choose which desktop notifications Rowboat sends you. Ambient notifications are only shown + when the app is in the background. +
+ +
+ {NOTIFICATION_CATEGORIES.map((cat) => ( +
+
+
{cat.label}
+
{cat.description}
+
+ handleToggle(cat.key, next)} + disabled={saving} + /> +
+ ))} +
+
+ ) +} + // --- Main Settings Dialog --- export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) { @@ -2034,7 +2133,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control } const loadConfig = useCallback(async (tab: ConfigTab) => { - if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode") return + if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode" || tab === "notifications") return const tabConfig = tabs.find((t) => t.id === tab)! if (!tabConfig.path) return setLoading(true) @@ -2142,7 +2241,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control {/* Content */} -
+
{activeTab === "account" ? ( ) : activeTab === "connections" ? ( @@ -2165,6 +2264,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control ) : activeTab === "appearance" ? ( + ) : activeTab === "notifications" ? ( + ) : activeTab === "help" ? ( ) : activeTab === "code-mode" ? ( diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 6f563a07..b3d39716 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -17,6 +17,7 @@ import { isBlocked, extractCommandNames } from "../application/lib/command-execu import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js"; import { resolveFilePathForPermission } from "../filesystem/files.js"; import container from "../di/container.js"; +import { notifyIfEnabled } from "../application/notification/notifier.js"; import { IModelConfigRepo } from "../models/repo.js"; import { createProvider } from "../models/models.js"; import { resolveProviderConfig } from "../models/defaults.js"; @@ -377,6 +378,7 @@ export class AgentRuntime implements IAgentRuntime { type: "run-processing-start", subflow: [], }); + let totalEvents = 0; while (true) { // Check for abort before each iteration if (signal.aborted) { @@ -417,6 +419,7 @@ export class AgentRuntime implements IAgentRuntime { throw error; } + totalEvents += eventCount; // if no events, break if (!eventCount) { break; @@ -433,6 +436,27 @@ export class AgentRuntime implements IAgentRuntime { }; await this.runsRepo.appendEvents(runId, [stoppedEvent]); await this.bus.publish(stoppedEvent); + } else if (totalEvents > 0) { + // The run reached a natural stopping point and actually did + // something this cycle. Notify "chat completion" — unless it + // paused on a permission request, which surfaces its own + // notification (distinguish by inspecting the final state). + const finalRun = await this.runsRepo.fetch(runId); + if (finalRun) { + const finalState = new AgentState(); + for (const event of finalRun.log) { + finalState.ingest(event); + } + if (finalState.getPendingPermissions().length === 0) { + void notifyIfEnabled("chat_completion", { + title: "Response ready", + message: "Your agent finished responding.", + link: `rowboat://open?type=chat&runId=${runId}`, + actionLabel: "Open", + onlyWhenBackground: true, + }); + } + } } } catch (error) { console.error(`Run ${runId} failed:`, error); @@ -1545,6 +1569,16 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated } if (permissionCandidates.length > 0) { + // Permission prompts block the run, so they surface even when the + // app is focused (no onlyWhenBackground gate). + const notifyPermissionPrompt = (toolCall: typeof permissionCandidates[number]["toolCall"]) => { + void notifyIfEnabled("agent_permission", { + title: "Permission needed", + message: `${agent.name} wants to run "${toolCall.toolName}". Review to continue.`, + link: `rowboat://open?type=chat&runId=${runId}`, + actionLabel: "Review", + }); + }; if (state.permissionMode === "auto") { let decisionsByToolCallId = new Map(); try { @@ -1578,6 +1612,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated permission: candidate.permission, subflow: [], }); + notifyPermissionPrompt(candidate.toolCall); continue; } @@ -1609,6 +1644,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated permission: candidate.permission, subflow: [], }); + notifyPermissionPrompt(candidate.toolCall); } } } else { @@ -1621,6 +1657,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated permission: candidate.permission, subflow: [], }); + notifyPermissionPrompt(candidate.toolCall); } } } diff --git a/apps/x/packages/core/src/application/notification/notifier.ts b/apps/x/packages/core/src/application/notification/notifier.ts new file mode 100644 index 00000000..a1cacc76 --- /dev/null +++ b/apps/x/packages/core/src/application/notification/notifier.ts @@ -0,0 +1,29 @@ +import type { NotificationCategory } from '@x/shared/dist/notification-settings.js'; +import { isNotificationCategoryEnabled } from '../../config/notification_config.js'; +import type { INotificationService, NotifyInput } from './service.js'; + +/** + * Fire a notification for `category`, but only if the user has that category + * enabled and the platform supports notifications. + * + * Resolution of the notification service is done via a *dynamic* import of the + * DI container so that callers like the agent runtime — which the container + * itself imports — don't create a circular module dependency. The whole thing + * is wrapped so a missing service (very early startup), an unsupported + * platform, or a config read error can never disrupt the run/sync that + * triggered it. Callers should fire-and-forget (`void notifyIfEnabled(...)`). + */ +export async function notifyIfEnabled( + category: NotificationCategory, + input: NotifyInput, +): Promise { + try { + if (!isNotificationCategoryEnabled(category)) return; + const { default: container } = await import('../../di/container.js'); + const service = container.resolve('notificationService'); + if (!service.isSupported()) return; + service.notify(input); + } catch (err) { + console.error(`[notifier] failed to notify (category=${category}):`, err); + } +} diff --git a/apps/x/packages/core/src/application/notification/service.ts b/apps/x/packages/core/src/application/notification/service.ts index 195315b1..2d32878b 100644 --- a/apps/x/packages/core/src/application/notification/service.ts +++ b/apps/x/packages/core/src/application/notification/service.ts @@ -4,6 +4,14 @@ export interface NotifyInput { link?: string; actionLabel?: string; secondaryActions?: Array<{ label: string; link: string }>; + /** + * When true, the notification is suppressed if the app is currently in the + * foreground (any window focused). Use for ambient notifications the user + * doesn't need while actively looking at the app (e.g. chat completion, new + * email). Leave unset/false for notifications that must always surface + * regardless of focus (e.g. an agent permission request that blocks a run). + */ + onlyWhenBackground?: boolean; } export interface INotificationService { diff --git a/apps/x/packages/core/src/config/notification_config.ts b/apps/x/packages/core/src/config/notification_config.ts new file mode 100644 index 00000000..d8a23e56 --- /dev/null +++ b/apps/x/packages/core/src/config/notification_config.ts @@ -0,0 +1,52 @@ +import fs from 'fs'; +import path from 'path'; +import { + NotificationSettingsSchema, + DEFAULT_NOTIFICATION_SETTINGS, + type NotificationSettings, + type NotificationCategory, +} from '@x/shared/dist/notification-settings.js'; +import { WorkDir } from './config.js'; + +const NOTIFICATION_CONFIG_PATH = path.join(WorkDir, 'config', 'notification_settings.json'); + +/** + * Load notification settings, merging any persisted values over the defaults. + * + * Merging (rather than a strict parse) keeps the file forward/backward + * compatible: a category added in a newer build is filled in from defaults + * when an older file omits it, and a malformed file falls back to defaults + * instead of disabling notifications entirely. + */ +export function loadNotificationSettings(): NotificationSettings { + try { + if (fs.existsSync(NOTIFICATION_CONFIG_PATH)) { + const content = fs.readFileSync(NOTIFICATION_CONFIG_PATH, 'utf-8'); + const parsed = JSON.parse(content); + const categories = parsed?.categories ?? {}; + return NotificationSettingsSchema.parse({ + categories: { + ...DEFAULT_NOTIFICATION_SETTINGS.categories, + ...categories, + }, + }); + } + } catch (error) { + console.error('[NotificationConfig] Error loading notification settings:', error); + } + return DEFAULT_NOTIFICATION_SETTINGS; +} + +export function saveNotificationSettings(settings: NotificationSettings): void { + const dir = path.dirname(NOTIFICATION_CONFIG_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const validated = NotificationSettingsSchema.parse(settings); + fs.writeFileSync(NOTIFICATION_CONFIG_PATH, JSON.stringify(validated, null, 2)); +} + +/** Convenience: is a single notification category currently enabled? */ +export function isNotificationCategoryEnabled(category: NotificationCategory): boolean { + return loadNotificationSettings().categories[category]; +} diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 77055f37..ce2a17be 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -9,6 +9,7 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge import { limitEventItems } from './limit_event_items.js'; import { createEvent } from '../events/producer.js'; import { classifyThread, getUserEmail } from './classify_thread.js'; +import { notifyIfEnabled } from '../application/notification/notifier.js'; // Configuration const SYNC_DIR = path.join(WorkDir, 'gmail_sync'); @@ -220,6 +221,26 @@ function summarizeGmailSync(threads: SyncedThread[]): string { return lines.join('\n'); } +/** + * 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. + */ +function notifyNewEmails(threads: SyncedThread[]): void { + for (const { threadId } of threads) { + const snapshot = readCachedSnapshot(threadId)?.snapshot; + 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', + actionLabel: 'Open', + onlyWhenBackground: true, + }); + } +} + async function publishGmailSyncEvent(threads: SyncedThread[]): Promise { if (threads.length === 0) return; try { @@ -1260,6 +1281,9 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: const result = await processThread(auth, tid, syncDir, attachmentsDir); if (result) synced.push(result); } + // Notify for the history-derived new threads only — before the older + // backfilled threads are merged in below, so backfill stays silent. + notifyNewEmails(synced); const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays); synced.push(...backfilled); diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts index 02f980a2..eb907f5c 100644 --- a/apps/x/packages/shared/src/index.ts +++ b/apps/x/packages/shared/src/index.ts @@ -17,4 +17,5 @@ export * as frontmatter from './frontmatter.js'; export * as bases from './bases.js'; export * as browserControl from './browser-control.js'; export * as billing from './billing.js'; +export * as notificationSettings from './notification-settings.js'; export { PrefixLogger }; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index c2e5e8e7..6df8c8eb 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -20,6 +20,7 @@ import { BrowserStateSchema } from './browser-control.js'; import { BillingInfoSchema } from './billing.js'; import { EmailBlockSchema, GmailThreadSchema } from './blocks.js'; import { PermissionDecision, ApprovalPolicy } from './code-mode.js'; +import { NotificationSettingsSchema } from './notification-settings.js'; // ============================================================================ // Runtime Validation Schemas (Single Source of Truth) @@ -1028,6 +1029,17 @@ const ipcSchemas = { req: z.null(), res: BillingInfoSchema, }, + // Notification settings channels + 'notifications:getSettings': { + req: z.null(), + res: NotificationSettingsSchema, + }, + 'notifications:setSettings': { + req: NotificationSettingsSchema, + res: z.object({ + success: z.literal(true), + }), + }, } as const; // ============================================================================ diff --git a/apps/x/packages/shared/src/notification-settings.ts b/apps/x/packages/shared/src/notification-settings.ts new file mode 100644 index 00000000..1ab7646b --- /dev/null +++ b/apps/x/packages/shared/src/notification-settings.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +/** + * Notification categories the user can independently toggle. + * + * - 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 + */ +export const NotificationCategorySchema = z.enum([ + 'chat_completion', + 'new_email', + 'agent_permission', +]); + +export const NotificationCategoriesSchema = z.object({ + chat_completion: z.boolean(), + new_email: z.boolean(), + agent_permission: z.boolean(), +}); + +export const NotificationSettingsSchema = z.object({ + categories: NotificationCategoriesSchema, +}); + +export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = { + categories: { + chat_completion: true, + new_email: true, + agent_permission: true, + }, +}; + +export type NotificationCategory = z.infer; +export type NotificationCategories = z.infer; +export type NotificationSettings = z.infer; diff --git a/apps/x/pnpm-workspace.yaml b/apps/x/pnpm-workspace.yaml index f5cdd141..b4bc70d9 100644 --- a/apps/x/pnpm-workspace.yaml +++ b/apps/x/pnpm-workspace.yaml @@ -2,6 +2,15 @@ packages: - apps/* - packages/* +allowBuilds: + core-js: set this to true or false + electron: set this to true or false + electron-winstaller: set this to true or false + esbuild: set this to true or false + fs-xattr: set this to true or false + macos-alias: set this to true or false + protobufjs: set this to true or false + catalog: vitest: 4.1.7