Skip to content
7 changes: 6 additions & 1 deletion apps/x/apps/main/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -330,7 +335,7 @@ app.whenReady().then(async () => {
});

registerBrowserControlService(new ElectronBrowserControlService());
registerNotificationService(new ElectronNotificationService());
registerNotificationService(new ElectronNotificationService(APP_LAUNCHED_AT));

setupIpcHandlers();
setupBrowserEventForwarding();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,11 +12,27 @@ export class ElectronNotificationService implements INotificationService {
// gets dropped and macOS clicks just focus the app silently.
private active = new Set<Notification>();

// 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
Expand Down
34 changes: 31 additions & 3 deletions apps/x/apps/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -603,21 +604,23 @@ 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
}

/**
* Parse a rowboat:// deep link into a ViewState. Returns null if the URL is
* malformed or names an unknown target.
*
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics|meetings|live-notes>&...
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics|meetings|live-notes|email>&...
* file: ?type=file&path=knowledge/foo.md
* chat: ?type=chat&runId=abc123 (runId optional)
* graph: ?type=graph
* task: ?type=task&name=daily-brief
* 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://'
Expand Down Expand Up @@ -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 }
Expand All @@ -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
}
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion apps/x/apps/renderer/src/components/settings-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[] = [
{
Expand All @@ -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 }) {
Expand Down
14 changes: 14 additions & 0 deletions apps/x/packages/core/src/agents/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
36 changes: 34 additions & 2 deletions apps/x/packages/core/src/application/lib/builtin-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1659,13 +1660,44 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
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<INotificationService>('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 {
Expand Down
32 changes: 32 additions & 0 deletions apps/x/packages/core/src/application/notification/service.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
31 changes: 31 additions & 0 deletions apps/x/packages/core/src/application/notification/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotifyInput, "suppressDuringStartupGrace">,
launchedAt: number,
now: number = Date.now(),
graceWindowMs: number = STARTUP_GRACE_MS,
): boolean {
return Boolean(input.suppressDuringStartupGrace) && now - launchedAt < graceWindowMs;
}
14 changes: 13 additions & 1 deletion apps/x/packages/core/src/background-tasks/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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);

Expand Down
Loading