diff --git a/frontend/src/main.ts b/frontend/src/main.ts index cff9c50880..a385ab8161 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -6,7 +6,6 @@ import { ipcMain, net, nativeImage, - Notification as ElectronNotification, protocol, shell, WebContentsView, @@ -96,6 +95,8 @@ let daemonStatus: DaemonStatus = { state: "stopped" }; let browserViewHost: BrowserViewHost | null = null; // Held for the app lifetime. Dropping it (on any exit) triggers daemon self-stop. let supervisorLink: SupervisorLinkHandle | null = null; +// Guard: prevents stacking multiple flashFrame(true) calls when notifications arrive rapidly. +let isFlashing = false; const execFileAsync = promisify(execFile); @@ -1095,20 +1096,73 @@ ipcMain.handle("updates:install", () => { quitAndInstallUpdate(); }); -ipcMain.handle("notifications:show", (_event, notification: { id: string; title: string; body?: string }) => { - if (!notification.id || !notification.title || !ElectronNotification.isSupported()) return; - const toast = new ElectronNotification({ - title: notification.title, - body: notification.body, - }); - toast.on("click", () => { +ipcMain.handle( + "notifications:show", + (_event, notification: { id: string; title: string; body?: string; type?: string }) => { + if (!notification.id || !mainWindow) return; + // Never show OS banners/toasts. Only signal via dock (macOS) and taskbar (Windows/Linux). + if (mainWindow.isFocused()) return; + const type = notification.type; + if (type !== "needs_input" && type !== "ready_to_merge") return; + if (process.platform === "darwin" && app.dock) { + app.dock.bounce("informational"); + } else if (process.platform === "win32" || process.platform === "linux") { + if (!isFlashing) { + isFlashing = true; + mainWindow.flashFrame(true); + mainWindow.once("focus", () => { + isFlashing = false; + mainWindow?.flashFrame(false); + }); + } + } + }, +); + +// Dev-only: force attention signal regardless of window focus (for testing) +if (!app.isPackaged) { + ipcMain.handle("notifications:devBounce", () => { if (!mainWindow) return; - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.show(); - mainWindow.focus(); - mainWindow.webContents.send("notifications:click", notification.id); + if (process.platform === "darwin") { + const id = app.dock?.bounce("critical"); + setTimeout(() => { + if (id !== undefined) app.dock?.cancelBounce(id); + }, 2000); + } else if (process.platform === "win32" || process.platform === "linux") { + mainWindow.flashFrame(true); + setTimeout(() => { + mainWindow?.flashFrame(false); + }, 2000); + } }); - toast.show(); +} + +ipcMain.handle("notifications:setBadge", (_event, count: number) => { + if (!mainWindow) return { error: "no mainWindow" }; + const n = Number.isFinite(count) && count > 0 ? Math.floor(count) : 0; + if (process.platform === "darwin") { + const dock = app.dock; + if (!dock) return { error: "no app.dock" }; + try { + dock.setBadge(n > 0 ? String(n) : ""); + } catch (e) { + return { error: String(e) }; + } + } else if (process.platform === "win32") { + if (n > 0) { + // Pre-built red dot PNG overlay — indicates unread without needing Canvas. + const badgeDataUrl = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAdgAAAHYBTnsmCAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABnSURBVDiNY/j///9/BioDE0MfGGBgYGBgY/j//z8DAwMjAwMDA8P////+/v7+/n5+fn5AQEBASEhISkpKSkpKSkpKSkpKSkpKSoqKiouLi4uLi4uLi4uLi4uLi4uLi4uLi4uLi4uLi4tLSwAAAABJRU5ErkJggg=="; + const icon = nativeImage.createFromDataURL(badgeDataUrl); + mainWindow.setOverlayIcon(icon, `${n} unread`); + } else { + mainWindow.setOverlayIcon(null, ""); + } + } else if (process.platform === "linux") { + // Unity/KDE launchers display a numeric badge via setBadgeCount. + app.setBadgeCount(n); + } + return { ok: true }; }); // Auto-update only runs for packaged builds reading the GitHub Releases feed diff --git a/frontend/src/preload.ts b/frontend/src/preload.ts index 83cc2bef14..bb9f1e56f0 100644 --- a/frontend/src/preload.ts +++ b/frontend/src/preload.ts @@ -80,8 +80,10 @@ const api = { }, }, notifications: { - show: (notification: { id: string; title: string; body?: string }) => + show: (notification: { id: string; title: string; body?: string; type?: string }) => ipcRenderer.invoke("notifications:show", notification) as Promise, + setBadge: (count: number) => ipcRenderer.invoke("notifications:setBadge", count) as Promise, + devBounce: () => ipcRenderer.invoke("notifications:devBounce") as Promise, onClick: (listener: (id: string) => void) => { const wrapped = (_event: Electron.IpcRendererEvent, id: string) => listener(id); ipcRenderer.on("notifications:click", wrapped); diff --git a/frontend/src/renderer/components/NotificationCenter.tsx b/frontend/src/renderer/components/NotificationCenter.tsx index 90a0a444d0..6797b934e7 100644 --- a/frontend/src/renderer/components/NotificationCenter.tsx +++ b/frontend/src/renderer/components/NotificationCenter.tsx @@ -1,7 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { Bell, Check, CheckCheck, CircleAlert, ExternalLink, GitMerge, GitPullRequest, XCircle } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { Bell, Check, CheckCheck, ExternalLink, GitMerge, GitPullRequest, XCircle } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useMarkAllNotificationsReadMutation, useMarkNotificationReadMutation, @@ -59,6 +59,10 @@ export function NotificationCenter({ style }: NotificationCenterProps) { useEffect(() => createNotificationsTransport(queryClient).connect(), [queryClient]); + useEffect(() => { + void aoBridge.notifications.setBadge(unreadCount); + }, [unreadCount]); + useEffect(() => { return aoBridge.notifications.onClick((id) => { const current = queryClient.getQueryData(unreadNotificationsQueryKey) ?? []; @@ -92,7 +96,11 @@ export function NotificationCenter({ style }: NotificationCenterProps) { }; return ( - + { + if (open) setActionError(null); + }} + > - -
- Notifications + +
+ Notifications
{actionError ? ( -
{actionError}
+
{actionError}
) : null} {notificationsQuery.isError && unreadCount === 0 ? ( -
Could not load notifications.
+
Could not load notifications.
) : unreadCount === 0 ? ( -
No unread notifications.
+
No unread notifications.
) : ( -
+
{notifications.map((notification, index) => (
Promise; onOpen: (notification: NotificationDTO) => void; }) { - const Icon = notificationIcon(notification.type); + const icon = notificationIcon(notification.type); return ( -
-
+
+ {icon.type === "dot" ? ( +
-
-

{notification.title}

- {formatTimeCompact(notification.createdAt)} +
+

{notification.title}

+ {formatTimeCompact(notification.createdAt)}
{notification.body ? ( -

{notification.body}

+

{notification.body}

) : null}
-
+
); } -function notificationIcon(type: string) { +type IconSpec = + { type: "dot"; color: string } | { type: "component"; Component: React.ComponentType> }; + +function notificationIcon(type: string): IconSpec { switch (type) { case "needs_input": - return CircleAlert; + return { type: "dot", color: "var(--amber)" }; case "ready_to_merge": - return GitPullRequest; + return { type: "component", Component: GitPullRequest }; case "pr_merged": - return GitMerge; + return { type: "component", Component: GitMerge }; case "pr_closed_unmerged": - return XCircle; + return { type: "component", Component: XCircle }; default: - return Bell; + return { type: "component", Component: Bell }; } } diff --git a/frontend/src/renderer/lib/bridge.ts b/frontend/src/renderer/lib/bridge.ts index e0c13f8fbd..59d486c4a8 100644 --- a/frontend/src/renderer/lib/bridge.ts +++ b/frontend/src/renderer/lib/bridge.ts @@ -91,6 +91,8 @@ export const aoBridge: AoBridge = }, notifications: { show: async () => undefined, + setBadge: async () => undefined, + devBounce: async () => undefined, onClick: () => () => undefined, }, appState: { diff --git a/frontend/src/renderer/lib/notifications.test.ts b/frontend/src/renderer/lib/notifications.test.ts index f375dfd1f1..540879587c 100644 --- a/frontend/src/renderer/lib/notifications.test.ts +++ b/frontend/src/renderer/lib/notifications.test.ts @@ -135,6 +135,7 @@ describe("createNotificationsTransport", () => { id: "ntf_1", title: "checkout-flow needs input", body: "The agent is waiting for your response.", + type: "needs_input", }); }); diff --git a/frontend/src/renderer/lib/notifications.ts b/frontend/src/renderer/lib/notifications.ts index 0379d2f3ac..645894b582 100644 --- a/frontend/src/renderer/lib/notifications.ts +++ b/frontend/src/renderer/lib/notifications.ts @@ -95,6 +95,7 @@ export function createNotificationsTransport(queryClient: QueryClient) { id: notification.id, title: notification.title, body: notification.body || undefined, + type: notification.type, }); } }); diff --git a/frontend/src/renderer/main.tsx b/frontend/src/renderer/main.tsx index a575674db2..97545a7331 100644 --- a/frontend/src/renderer/main.tsx +++ b/frontend/src/renderer/main.tsx @@ -5,12 +5,47 @@ import { RouterProvider } from "@tanstack/react-router"; import "@xterm/xterm/css/xterm.css"; import "./styles.css"; import { queryClient } from "./lib/query-client"; +import { mergeUnreadNotification } from "./lib/notifications"; import { createAppRouter } from "./router"; import { TelemetryBoundary } from "./components/TelemetryBoundary"; import { initTelemetry } from "./lib/telemetry"; import { startDaemonFailureTelemetry } from "./lib/daemon-telemetry"; const router = createAppRouter(queryClient); + +if (import.meta.env.DEV) { + const w = window as never as Record; + w.__qc = queryClient; + // __testNotif("needs_input") — simulates a real notification: + // bell count + dock badge update immediately; dock bounces after 3s + // (gives you time to click away from AO so the bounce is visible) + w.__testNotif = async (type: "needs_input" | "ready_to_merge" = "needs_input") => { + const key = ["notifications", "unread"] as const; + const id = `test-${Date.now()}`; + // Freeze the query so window-focus refetch doesn't wipe test data + queryClient.setQueryDefaults(key, { staleTime: 60_000 }); + await queryClient.cancelQueries({ queryKey: key }); + mergeUnreadNotification(queryClient, { + id, + type, + title: type === "needs_input" ? "Agent needs your input" : "Ready to merge", + body: "Test notification", + createdAt: new Date().toISOString(), + sessionId: "", + projectId: "", + prUrl: "", + target: { kind: "session", sessionId: "" }, + status: "unread", + }); + console.log("[testNotif] bell updated - click away from AO now, bounce fires in 3s"); + setTimeout(() => { + void window.ao?.notifications.devBounce(); + // Restore normal stale time after bounce + queryClient.setQueryDefaults(key, { staleTime: 0 }); + }, 3000); + }; +} + void initTelemetry(); startDaemonFailureTelemetry(); diff --git a/frontend/src/renderer/test/setup.ts b/frontend/src/renderer/test/setup.ts index 98cee1a8ad..50c437fdba 100644 --- a/frontend/src/renderer/test/setup.ts +++ b/frontend/src/renderer/test/setup.ts @@ -136,6 +136,8 @@ if (typeof window !== "undefined") { }, notifications: { show: async () => undefined, + setBadge: async () => undefined, + devBounce: async () => undefined, onClick: () => () => undefined, }, appState: {