fix: suppress notification spam on app re-open (#623)

* fix: suppress notification spam on app re-open

- 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 <noreply@anthropic.com>

* fix: skip automatic chat_completion ping for background task agents

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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix: distinguish background-agent notifications from auto knowledge-sync

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.

* fix: route coding-session notifications through notifyIfEnabled

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.

* fix: fall back to persisted run useCase when ALS context is missing in notify-user

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.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
PRAKHAR PANDEY 2026-06-24 01:36:06 +05:30 committed by GitHub
parent c0b38d46d3
commit 1bfda94f48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 250 additions and 22 deletions

View file

@ -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();

View file

@ -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<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

View file

@ -627,12 +627,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
@ -641,6 +642,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
}
@ -648,7 +650,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=<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
@ -656,6 +658,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://'
@ -684,6 +687,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 }
@ -703,6 +710,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
}
@ -3758,6 +3767,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 }
@ -4089,6 +4099,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':
@ -4176,6 +4192,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)
@ -4204,7 +4232,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

View file

@ -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 }) {