mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
c0b38d46d3
commit
1bfda94f48
13 changed files with 250 additions and 22 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue