From 3ec121c8566db95f47841f8338dc68f0de265304 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Sun, 14 Jun 2026 16:57:52 +0530 Subject: [PATCH 1/6] fix: suppress notification spam on app re-open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add background_task notification category (default ON) so users can toggle off background-task pings via Settings → Notifications - Route notify-user builtin through notifyIfEnabled gate so the category toggle takes effect - Add 60s startup grace period: background-task notifications fired within 60s of launch are suppressed, killing the reopen flood where all queued agents complete at once - Suppress new_email notifications for emails older than 5 min so Gmail's startup backlog replay doesn't surface day-old mail Fixes both issues reported by Ramnique. Co-Authored-By: Claude Opus 4.8 --- apps/x/apps/main/src/main.ts | 7 +++- .../electron-notification-service.ts | 19 ++++++++++- .../src/components/settings-dialog.tsx | 7 +++- .../core/src/application/lib/builtin-tools.ts | 14 +++++++- .../application/notification/service.test.ts | 32 +++++++++++++++++++ .../src/application/notification/service.ts | 31 ++++++++++++++++++ .../core/src/knowledge/sync_gmail.test.ts | 30 +++++++++++++++++ .../packages/core/src/knowledge/sync_gmail.ts | 22 ++++++++++++- .../shared/src/notification-settings.ts | 4 +++ 9 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 apps/x/packages/core/src/application/notification/service.test.ts diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 8c70f6101..00f9a83e9 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -58,6 +58,11 @@ import { disconnectGoogleIfScopesStale } from "./oauth-handler.js"; const execAsync = promisify(exec); +// 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); @@ -337,7 +342,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/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 691b4da20..b77f6affd 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -1995,7 +1995,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 }[] = [ { @@ -2013,6 +2013,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 tasks", + description: "When a background task finishes and pings you. Bursts right after the app reopens are held back briefly.", + }, ] function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) { 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 fe4744633..f210429c5 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -56,6 +56,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. @@ -1573,7 +1574,18 @@ export const BuiltinTools: z.infer = { if (!service.isSupported()) { return { success: false, error: 'Notifications are not supported on this system' }; } - service.notify({ title, message, link, actionLabel, secondaryActions }); + // Route through the category gate so the user can toggle these + // off, and flag for startup-grace suppression so a reopen doesn't + // flood the user with every background task that completed at once + // while the app was closed. + await notifyIfEnabled('background_task', { + title, + message, + link, + actionLabel, + secondaryActions, + suppressDuringStartupGrace: true, + }); 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/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 ce2a17bec..a20578761 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -221,14 +221,34 @@ 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', { 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, }, }; From 3cd7c72b855c2199d210409194c28fa96e38695e Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Sun, 14 Jun 2026 20:02:39 +0530 Subject: [PATCH 2/6] fix: skip automatic chat_completion ping for background task agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background task runs were triggering "Response ready / Your agent finished responding" on every completion. Skip the automatic chat_completion notification when finalState.runUseCase === 'background_task_agent' — background tasks notify explicitly via notify-user when they have something worth surfacing. Co-Authored-By: Claude Opus 4.8 --- apps/x/packages/core/src/agents/runtime.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 0f2c22d54..8801083e7 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -448,6 +448,11 @@ export class AgentRuntime implements IAgentRuntime { finalState.ingest(event); } if (finalState.getPendingPermissions().length === 0) { + // Background tasks ping the user explicitly via the + // notify-user tool; skip the automatic completion ping so + // every background run doesn't fire "Response ready". (The + // finally block still runs on this early return.) + if (finalState.runUseCase === "background_task_agent") return; void notifyIfEnabled("chat_completion", { title: "Response ready", message: "Your agent finished responding.", From a8c731191cc04c07f2dc1223ab85069856c59c46 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Sun, 14 Jun 2026 22:02:33 +0530 Subject: [PATCH 3/6] fix: correct notification deeplinks and skip internal agent pings - runtime.ts: also skip chat_completion ping for knowledge_sync useCase (agent_notes_agent was opening notes view on click) - sync_gmail.ts: new_email notification now links to specific email thread (rowboat://open?type=email&threadId=...) - App.tsx: add email case to parseDeepLink, parse threadId param, wire threadId through applyViewState, update viewStatesEqual Co-Authored-By: Claude Opus 4.8 --- apps/x/apps/renderer/src/App.tsx | 16 ++++++++++++++-- apps/x/packages/core/src/agents/runtime.ts | 16 +++++++++++----- apps/x/packages/core/src/knowledge/sync_gmail.ts | 2 +- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index fcbc1ec78..f72f9745c 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -589,7 +589,7 @@ 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 } | { type: 'chat-history' } @@ -603,6 +603,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 ?? '') + if (a.type === 'email' && b.type === 'email') return (a.threadId ?? '') === (b.threadId ?? '') return true // both graph } @@ -610,7 +611,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 +619,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 +648,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 } @@ -3960,6 +3966,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': diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 8801083e7..b3fb11595 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -448,11 +448,17 @@ export class AgentRuntime implements IAgentRuntime { finalState.ingest(event); } if (finalState.getPendingPermissions().length === 0) { - // Background tasks ping the user explicitly via the - // notify-user tool; skip the automatic completion ping so - // every background run doesn't fire "Response ready". (The - // finally block still runs on this early return.) - if (finalState.runUseCase === "background_task_agent") return; + // Internal agents (background tasks, knowledge-sync / + // agent-notes) ping the user explicitly via the + // notify-user tool when they have something worth + // surfacing; they have no user-facing chat to "Open", so + // skip the automatic completion ping that would otherwise + // fire "Response ready" on every run. (The finally block + // still runs on this early return.) + if ( + finalState.runUseCase === "background_task_agent" || + finalState.runUseCase === "knowledge_sync" + ) return; void notifyIfEnabled("chat_completion", { title: "Response ready", message: "Your agent finished responding.", diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index a20578761..1960b4f4d 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -254,7 +254,7 @@ function notifyNewEmails(threads: SyncedThread[]): void { 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, }); From 370f60642fe97b1b804093e114f0d91f44db9ed5 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Mon, 15 Jun 2026 21:54:18 +0530 Subject: [PATCH 4/6] fix: distinguish background-agent notifications from auto knowledge-sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per team clarification, two background types are handled differently: - knowledge_sync (auto knowledge-graph generation): never notifies; skips the generic chat_completion ping entirely. - background_task_agent (user-configured agents): notifies via its own notify-user path, gated behind the toggleable "Background agents" category, deep-linking to the background-tasks page. - runtime.ts: skip the generic chat_completion completion ping for both knowledge_sync and background_task_agent (the latter notifies via notify-user, so the generic ping would duplicate it). - builtin-tools.ts: notify-user branches on getCurrentUseCase() — background agents route through notifyIfEnabled('background_task') with a bg-tasks deeplink default; chat agents notify directly. - App.tsx: add the bg-tasks deeplink target (ViewState, parseDeepLink, applyViewState, currentViewState). - settings-dialog.tsx: rename the category label to "Background agents". - runner.ts: wrap the background-task run in withUseCase so tools see the correct use-case context. --- apps/x/apps/renderer/src/App.tsx | 18 ++++++++++- .../src/components/settings-dialog.tsx | 4 +-- apps/x/packages/core/src/agents/runtime.ts | 21 +++++++------ .../core/src/application/lib/builtin-tools.ts | 31 ++++++++++++------- .../core/src/background-tasks/runner.ts | 14 ++++++++- 5 files changed, 63 insertions(+), 25 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f72f9745c..b72ee374e 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -595,6 +595,7 @@ type ViewState = | { type: 'chat-history' } | { type: 'home' } | { type: 'code' } + | { type: 'bg-tasks' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -666,6 +667,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 } @@ -3635,6 +3638,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 } @@ -4058,6 +4062,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) @@ -4086,7 +4102,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 b77f6affd..3244c1942 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2015,8 +2015,8 @@ const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; de }, { key: "background_task", - label: "Background tasks", - description: "When a background task finishes and pings you. Bursts right after the app reopens are held back briefly.", + 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.", }, ] diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index b3fb11595..da756b2cb 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -448,16 +448,19 @@ export class AgentRuntime implements IAgentRuntime { finalState.ingest(event); } if (finalState.getPendingPermissions().length === 0) { - // Internal agents (background tasks, knowledge-sync / - // agent-notes) ping the user explicitly via the - // notify-user tool when they have something worth - // surfacing; they have no user-facing chat to "Open", so - // skip the automatic completion ping that would otherwise - // fire "Response ready" on every run. (The finally block - // still runs on this early return.) + // 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 === "background_task_agent" || - finalState.runUseCase === "knowledge_sync" + finalState.runUseCase === "knowledge_sync" || + finalState.runUseCase === "background_task_agent" ) return; void notifyIfEnabled("chat_completion", { title: "Response ready", 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 f210429c5..5e7e259c4 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1574,18 +1574,25 @@ export const BuiltinTools: z.infer = { if (!service.isSupported()) { return { success: false, error: 'Notifications are not supported on this system' }; } - // Route through the category gate so the user can toggle these - // off, and flag for startup-grace suppression so a reopen doesn't - // flood the user with every background task that completed at once - // while the app was closed. - await notifyIfEnabled('background_task', { - title, - message, - link, - actionLabel, - secondaryActions, - suppressDuringStartupGrace: true, - }); + const uc = getCurrentUseCase()?.useCase; + 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, + }); + } 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/background-tasks/runner.ts b/apps/x/packages/core/src/background-tasks/runner.ts index 7fb73bdb9..aac6162e6 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'); @@ -142,7 +143,18 @@ export async function runBackgroundTask( }); try { - await createMessage(runId, buildMessage(slug, task, trigger, context)); + // 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)), + ); await waitForRunCompletion(runId, { throwOnError: true }); const summary = await extractAgentResponse(runId); From 7b760a4082c2a8d7a1c08b0348f21581d88329ce Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Wed, 17 Jun 2026 11:29:50 +0530 Subject: [PATCH 5/6] fix: route coding-session notifications through notifyIfEnabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-mode status-tracker was calling notificationService.notify() directly, bypassing the user's notification-category toggles. Routed both calls through notifyIfEnabled() with correct categories: - needs-you state → agent_permission - idle after 30s → chat_completion Removed now-redundant container/INotificationService plumbing. --- .../src/code-mode/sessions/status-tracker.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) 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.', }); From 341ff3d9e482ebf529d4962a9aa08cd48b89c138 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Wed, 17 Jun 2026 11:29:50 +0530 Subject: [PATCH 6/6] fix: fall back to persisted run useCase when ALS context is missing in notify-user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AsyncLocalStorage does not propagate across the background-task agent's async generator — getCurrentUseCase() returns undefined inside notify-user, causing the background_task_agent branch to be skipped and the Background agents toggle to be ignored. Fix: load persisted useCase from run record via fetchRun(ctx.runId) when ALS is falsy. Lazy dynamic import() avoids the known module-init cycle. Background-agent notifications now correctly respect the toggle and only fire when the app is in the background, with deep link to the bg-tasks page. --- .../core/src/application/lib/builtin-tools.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) 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 5e7e259c4..5b76dcfa0 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1568,13 +1568,25 @@ 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' }; } - const uc = getCurrentUseCase()?.useCase; + 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 @@ -1587,6 +1599,7 @@ export const BuiltinTools: z.infer = { actionLabel, secondaryActions, suppressDuringStartupGrace: true, + onlyWhenBackground: true, }); } else { // Regular chat (or any other) agent calling notify-user: