Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 67 additions & 13 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
ipcMain,
net,
nativeImage,
Notification as ElectronNotification,
protocol,
shell,
WebContentsView,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>,
setBadge: (count: number) => ipcRenderer.invoke("notifications:setBadge", count) as Promise<void>,
devBounce: () => ipcRenderer.invoke("notifications:devBounce") as Promise<void>,
onClick: (listener: (id: string) => void) => {
const wrapped = (_event: Electron.IpcRendererEvent, id: string) => listener(id);
ipcRenderer.on("notifications:click", wrapped);
Expand Down
95 changes: 57 additions & 38 deletions frontend/src/renderer/components/NotificationCenter.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<NotificationDTO[]>(unreadNotificationsQueryKey) ?? [];
Expand Down Expand Up @@ -92,7 +96,11 @@ export function NotificationCenter({ style }: NotificationCenterProps) {
};

return (
<DropdownMenu>
<DropdownMenu
onOpenChange={(open) => {
if (open) setActionError(null);
}}
>
<DropdownMenuTrigger asChild>
<button
aria-label={unreadCount > 0 ? `${unreadCount} unread notifications` : "Notifications"}
Expand All @@ -108,29 +116,29 @@ export function NotificationCenter({ style }: NotificationCenterProps) {
) : null}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[380px] p-0" sideOffset={8}>
<div className="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
<DropdownMenuLabel className="px-0 py-0">Notifications</DropdownMenuLabel>
<DropdownMenuContent align="end" className="w-[320px] p-0" sideOffset={8}>
<div className="flex items-center justify-between gap-3 border-b border-border px-2.5 py-1.5">
<DropdownMenuLabel className="px-0 py-0 text-[12px]">Notifications</DropdownMenuLabel>
<button
aria-label="Mark all notifications read"
className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-[12px] text-muted-foreground hover:bg-surface hover:text-foreground disabled:pointer-events-none disabled:opacity-45"
className="inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-[11px] text-muted-foreground hover:bg-surface hover:text-foreground disabled:pointer-events-none disabled:opacity-45"
disabled={unreadCount === 0 || markAllRead.isPending}
onClick={() => void markAll()}
type="button"
>
<CheckCheck className="h-3.5 w-3.5" aria-hidden="true" />
<CheckCheck className="h-3 w-3" aria-hidden="true" />
Mark all
</button>
</div>
{actionError ? (
<div className="border-b border-border px-3 py-2 text-[12px] text-error">{actionError}</div>
<div className="border-b border-border px-2.5 py-1.5 text-[11px] text-error">{actionError}</div>
) : null}
{notificationsQuery.isError && unreadCount === 0 ? (
<div className="px-3 py-8 text-center text-[13px] text-muted-foreground">Could not load notifications.</div>
<div className="px-3 py-6 text-center text-[11.5px] text-muted-foreground">Could not load notifications.</div>
) : unreadCount === 0 ? (
<div className="px-3 py-8 text-center text-[13px] text-muted-foreground">No unread notifications.</div>
<div className="px-3 py-6 text-center text-[11.5px] text-muted-foreground">No unread notifications.</div>
) : (
<div className="max-h-[420px] overflow-y-auto p-1">
<div className="max-h-[360px] overflow-y-auto p-0.5">
{notifications.map((notification, index) => (
<div key={notification.id}>
<NotificationItem
Expand Down Expand Up @@ -160,65 +168,76 @@ function NotificationItem({
onMarkRead: (id: string) => Promise<void>;
onOpen: (notification: NotificationDTO) => void;
}) {
const Icon = notificationIcon(notification.type);
const icon = notificationIcon(notification.type);
return (
<div className="grid grid-cols-[26px_minmax(0,1fr)_auto] gap-2 rounded-md px-2 py-2.5">
<div
className={cn(
"mt-0.5 grid h-6 w-6 place-items-center rounded-md border",
notification.type === "needs_input" && "border-warning/40 text-warning",
notification.type === "ready_to_merge" && "border-success/40 text-success",
notification.type === "pr_merged" && "border-accent-dim text-accent",
notification.type === "pr_closed_unmerged" && "border-error/40 text-error",
<div className="grid grid-cols-[14px_minmax(0,1fr)_auto] gap-2 rounded-md px-2.5 py-2.5">
<div className="flex items-start justify-center pt-[5px]">
{icon.type === "dot" ? (
<span
className="h-[7px] w-[7px] shrink-0 rounded-full"
style={{ background: icon.color }}
aria-hidden="true"
/>
) : (
<icon.Component
className={cn(
"h-3.5 w-3.5 shrink-0",
notification.type === "ready_to_merge" && "text-success",
notification.type === "pr_merged" && "text-accent",
notification.type === "pr_closed_unmerged" && "text-error",
)}
aria-hidden="true"
/>
)}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-[13px] font-medium leading-5 text-foreground">{notification.title}</p>
<span className="shrink-0 text-[11px] text-passive">{formatTimeCompact(notification.createdAt)}</span>
<div className="flex min-w-0 items-center gap-1.5">
<p className="truncate text-[12px] font-semibold leading-[1.4] text-foreground">{notification.title}</p>
<span className="shrink-0 text-[10px] text-passive">{formatTimeCompact(notification.createdAt)}</span>
</div>
{notification.body ? (
<p className="mt-0.5 line-clamp-2 text-[12px] leading-5 text-muted-foreground">{notification.body}</p>
<p className="mt-0.5 line-clamp-2 text-[11.5px] leading-[1.5] text-muted-foreground">{notification.body}</p>
) : null}
</div>
<div className="flex items-start gap-1">
<div className="flex items-start gap-0.5">
<button
aria-label="Open notification target"
className="grid h-7 w-7 place-items-center rounded-md text-muted-foreground hover:bg-surface hover:text-foreground"
className="grid h-6 w-6 place-items-center rounded-md text-muted-foreground hover:bg-surface hover:text-foreground"
onClick={() => onOpen(notification)}
title="Open target"
type="button"
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</button>
<button
aria-label="Mark notification read"
className="grid h-7 w-7 place-items-center rounded-md text-muted-foreground hover:bg-surface hover:text-foreground disabled:pointer-events-none disabled:opacity-45"
className="grid h-6 w-6 place-items-center rounded-md text-muted-foreground hover:bg-surface hover:text-foreground disabled:pointer-events-none disabled:opacity-45"
disabled={disabled}
onClick={() => void onMarkRead(notification.id)}
title="Mark read"
type="button"
>
<Check className="h-3.5 w-3.5" aria-hidden="true" />
<Check className="h-3 w-3" aria-hidden="true" />
</button>
</div>
</div>
);
}

function notificationIcon(type: string) {
type IconSpec =
{ type: "dot"; color: string } | { type: "component"; Component: React.ComponentType<React.SVGProps<SVGSVGElement>> };

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 };
}
}
2 changes: 2 additions & 0 deletions frontend/src/renderer/lib/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export const aoBridge: AoBridge =
},
notifications: {
show: async () => undefined,
setBadge: async () => undefined,
devBounce: async () => undefined,
onClick: () => () => undefined,
},
appState: {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/renderer/lib/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});

Expand Down
1 change: 1 addition & 0 deletions frontend/src/renderer/lib/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export function createNotificationsTransport(queryClient: QueryClient) {
id: notification.id,
title: notification.title,
body: notification.body || undefined,
type: notification.type,
});
}
});
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/renderer/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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();

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/renderer/test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ if (typeof window !== "undefined") {
},
notifications: {
show: async () => undefined,
setBadge: async () => undefined,
devBounce: async () => undefined,
onClick: () => () => undefined,
},
appState: {
Expand Down
Loading