diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts
index 7cc9225d..d36e57a7 100644
--- a/electron/ipc/channels.ts
+++ b/electron/ipc/channels.ts
@@ -85,4 +85,8 @@ export enum IPC {
// Ask about code
AskAboutCode = 'ask_about_code',
CancelAskAboutCode = 'cancel_ask_about_code',
+
+ // Notifications
+ ShowNotification = 'show_notification',
+ NotificationClicked = 'notification_clicked',
}
diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts
index 1a054d6d..b12ce0aa 100644
--- a/electron/ipc/register.ts
+++ b/electron/ipc/register.ts
@@ -1,4 +1,4 @@
-import { ipcMain, dialog, shell, app, BrowserWindow } from 'electron';
+import { ipcMain, dialog, shell, app, BrowserWindow, Notification } from 'electron';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { IPC } from './channels.js';
@@ -323,6 +323,29 @@ export function registerAllHandlers(win: BrowserWindow): void {
cancelAskAboutCode(args.requestId);
});
+ // --- Notifications (fire-and-forget via ipcMain.on) ---
+ ipcMain.on(IPC.ShowNotification, (_e, args) => {
+ try {
+ assertString(args.title, 'title');
+ assertString(args.body, 'body');
+ assertStringArray(args.taskIds, 'taskIds');
+ const notification = new Notification({
+ title: args.title,
+ body: args.body,
+ });
+ notification.on('click', () => {
+ if (!win.isDestroyed()) {
+ win.show();
+ win.focus();
+ win.webContents.send(IPC.NotificationClicked, { taskIds: args.taskIds });
+ }
+ });
+ notification.show();
+ } catch (err) {
+ console.warn('ShowNotification failed:', err);
+ }
+ });
+
// --- Window management ---
ipcMain.handle(IPC.WindowIsFocused, () => win.isFocused());
ipcMain.handle(IPC.WindowIsMaximized, () => win.isMaximized());
diff --git a/electron/preload.cjs b/electron/preload.cjs
index eaf4f9eb..e2c8baf9 100644
--- a/electron/preload.cjs
+++ b/electron/preload.cjs
@@ -79,6 +79,9 @@ const ALLOWED_CHANNELS = new Set([
// Ask about code
'ask_about_code',
'cancel_ask_about_code',
+ // Notifications
+ 'show_notification',
+ 'notification_clicked',
]);
function isAllowedChannel(channel) {
@@ -91,6 +94,10 @@ contextBridge.exposeInMainWorld('electron', {
if (!isAllowedChannel(channel)) throw new Error(`Blocked IPC channel: ${channel}`);
return ipcRenderer.invoke(channel, ...args);
},
+ send: (channel, ...args) => {
+ if (!isAllowedChannel(channel)) throw new Error(`Blocked IPC channel: ${channel}`);
+ ipcRenderer.send(channel, ...args);
+ },
on: (channel, listener) => {
if (!isAllowedChannel(channel)) throw new Error(`Blocked IPC channel: ${channel}`);
const wrapped = (_event, ...eventArgs) => listener(...eventArgs);
diff --git a/src/App.tsx b/src/App.tsx
index deb9b50a..8024d53e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -51,6 +51,7 @@ import { setupAutosave } from './store/autosave';
import { isMac, mod } from './lib/platform';
import { createCtrlWheelZoomHandler } from './lib/wheelZoom';
import { ArenaOverlay } from './arena/ArenaOverlay';
+import { startDesktopNotificationWatcher } from './store/desktopNotifications';
const MIN_WINDOW_DIMENSION = 100;
@@ -307,6 +308,7 @@ function App() {
await captureWindowState();
setupAutosave();
startTaskStatusPolling();
+ const stopNotificationWatcher = startDesktopNotificationWatcher(windowFocused);
// Listen for plan content pushed from backend plan watcher
const offPlanContent = window.electron.ipcRenderer.on(IPC.PlanContent, (data: unknown) => {
@@ -571,6 +573,7 @@ function App() {
unlistenCloseRequested();
cleanupShortcuts();
stopTaskStatusPolling();
+ stopNotificationWatcher();
offPlanContent();
unlistenFocusChanged?.();
unlistenResized?.();
diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx
index 0502ddf1..09c8991e 100644
--- a/src/components/SettingsDialog.tsx
+++ b/src/components/SettingsDialog.tsx
@@ -9,6 +9,7 @@ import {
setThemePreset,
setAutoTrustFolders,
setShowPlans,
+ setDesktopNotificationsEnabled,
setInactiveColumnOpacity,
setEditorCommand,
} from '../store/store';
@@ -177,6 +178,31 @@ export function SettingsDialog(props: SettingsDialogProps) {
+
diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts
index c8f7c346..f5c68c7e 100644
--- a/src/lib/ipc.ts
+++ b/src/lib/ipc.ts
@@ -7,6 +7,7 @@ declare global {
electron: {
ipcRenderer: {
invoke: (channel: string, ...args: unknown[]) => Promise
;
+ send: (channel: string, ...args: unknown[]) => void;
on: (channel: string, listener: (...args: unknown[]) => void) => () => void;
removeAllListeners: (channel: string) => void;
};
diff --git a/src/store/core.ts b/src/store/core.ts
index fb7425a9..a960a334 100644
--- a/src/store/core.ts
+++ b/src/store/core.ts
@@ -41,6 +41,7 @@ export const [store, setStore] = createStore({
windowState: null,
autoTrustFolders: false,
showPlans: true,
+ desktopNotificationsEnabled: false,
inactiveColumnOpacity: 0.6,
editorCommand: '',
newTaskDropUrl: null,
diff --git a/src/store/desktopNotifications.ts b/src/store/desktopNotifications.ts
new file mode 100644
index 00000000..3eb6a25b
--- /dev/null
+++ b/src/store/desktopNotifications.ts
@@ -0,0 +1,127 @@
+import { createEffect, onCleanup, type Accessor } from 'solid-js';
+import { store } from './store';
+import { getTaskDotStatus, type TaskDotStatus } from './taskStatus';
+import { setActiveTask } from './navigation';
+import { IPC } from '../../electron/ipc/channels';
+
+const DEBOUNCE_MS = 3_000;
+
+interface PendingNotification {
+ type: 'ready' | 'waiting';
+ taskId: string;
+}
+
+export function startDesktopNotificationWatcher(windowFocused: Accessor): () => void {
+ const previousStatus = new Map();
+ let pending: PendingNotification[] = [];
+ let debounceTimer: ReturnType | undefined;
+
+ function flushNotifications(): void {
+ debounceTimer = undefined;
+ if (windowFocused() || pending.length === 0) {
+ pending = [];
+ return;
+ }
+
+ const ready = pending.filter((n) => n.type === 'ready');
+ const waiting = pending.filter((n) => n.type === 'waiting');
+ pending = [];
+
+ if (ready.length > 0) {
+ const taskIds = ready.map((n) => n.taskId);
+ const body =
+ ready.length === 1
+ ? `${taskName(taskIds[0])} is ready for review`
+ : `${ready.length} tasks ready for review`;
+ window.electron.ipcRenderer.send(IPC.ShowNotification, {
+ title: 'Task Ready',
+ body,
+ taskIds,
+ });
+ }
+
+ if (waiting.length > 0) {
+ const taskIds = waiting.map((n) => n.taskId);
+ const body =
+ waiting.length === 1
+ ? `${taskName(taskIds[0])} needs your attention`
+ : `${waiting.length} tasks need your attention`;
+ window.electron.ipcRenderer.send(IPC.ShowNotification, {
+ title: 'Task Waiting',
+ body,
+ taskIds,
+ });
+ }
+ }
+
+ function taskName(taskId: string): string {
+ return store.tasks[taskId]?.name ?? taskId;
+ }
+
+ function scheduleBatch(notification: PendingNotification): void {
+ if (!store.desktopNotificationsEnabled) return;
+ pending.push(notification);
+ if (debounceTimer === undefined) {
+ debounceTimer = setTimeout(flushNotifications, DEBOUNCE_MS);
+ }
+ }
+
+ // Track status transitions
+ createEffect(() => {
+ const allTaskIds = [...store.taskOrder, ...store.collapsedTaskOrder];
+ const seen = new Set();
+
+ for (const taskId of allTaskIds) {
+ seen.add(taskId);
+ const current = getTaskDotStatus(taskId);
+ const prev = previousStatus.get(taskId);
+ previousStatus.set(taskId, current);
+
+ // Skip initial population
+ if (prev === undefined) continue;
+ if (prev === current) continue;
+
+ if (current === 'ready' && prev !== 'ready') {
+ scheduleBatch({ type: 'ready', taskId });
+ } else if (current === 'waiting' && prev === 'busy') {
+ scheduleBatch({ type: 'waiting', taskId });
+ }
+ }
+
+ // Clean up removed tasks
+ for (const taskId of previousStatus.keys()) {
+ if (!seen.has(taskId)) previousStatus.delete(taskId);
+ }
+ });
+
+ // Clear pending when window regains focus
+ createEffect(() => {
+ if (windowFocused()) {
+ pending = [];
+ if (debounceTimer !== undefined) {
+ clearTimeout(debounceTimer);
+ debounceTimer = undefined;
+ }
+ }
+ });
+
+ // Listen for notification clicks from main process
+ const offNotificationClicked = window.electron.ipcRenderer.on(
+ IPC.NotificationClicked,
+ (data: unknown) => {
+ const msg = data as Record;
+ const taskIds = Array.isArray(msg?.taskIds) ? (msg.taskIds as string[]) : [];
+ if (taskIds.length) {
+ setActiveTask(taskIds[0]);
+ }
+ },
+ );
+
+ const cleanup = (): void => {
+ if (debounceTimer !== undefined) clearTimeout(debounceTimer);
+ offNotificationClicked();
+ };
+
+ onCleanup(cleanup);
+ return cleanup;
+}
diff --git a/src/store/persistence.ts b/src/store/persistence.ts
index dfe4babf..595d6701 100644
--- a/src/store/persistence.ts
+++ b/src/store/persistence.ts
@@ -40,6 +40,7 @@ export async function saveState(): Promise {
windowState: store.windowState ? { ...store.windowState } : undefined,
autoTrustFolders: store.autoTrustFolders,
showPlans: store.showPlans,
+ desktopNotificationsEnabled: store.desktopNotificationsEnabled,
inactiveColumnOpacity: store.inactiveColumnOpacity,
editorCommand: store.editorCommand || undefined,
customAgents: store.customAgents.length > 0 ? [...store.customAgents] : undefined,
@@ -171,6 +172,7 @@ interface LegacyPersistedState {
windowState?: unknown;
autoTrustFolders?: unknown;
showPlans?: unknown;
+ desktopNotificationsEnabled?: unknown;
inactiveColumnOpacity?: unknown;
editorCommand?: unknown;
customAgents?: unknown;
@@ -269,6 +271,10 @@ export async function loadState(): Promise {
s.windowState = parsePersistedWindowState(raw.windowState);
s.autoTrustFolders = typeof raw.autoTrustFolders === 'boolean' ? raw.autoTrustFolders : false;
s.showPlans = typeof raw.showPlans === 'boolean' ? raw.showPlans : true;
+ s.desktopNotificationsEnabled =
+ typeof raw.desktopNotificationsEnabled === 'boolean'
+ ? raw.desktopNotificationsEnabled
+ : false;
const rawOpacity = raw.inactiveColumnOpacity;
s.inactiveColumnOpacity =
typeof rawOpacity === 'number' &&
diff --git a/src/store/store.ts b/src/store/store.ts
index af9079d5..aeee5da9 100644
--- a/src/store/store.ts
+++ b/src/store/store.ts
@@ -96,6 +96,7 @@ export {
setThemePreset,
setAutoTrustFolders,
setShowPlans,
+ setDesktopNotificationsEnabled,
setInactiveColumnOpacity,
setEditorCommand,
setWindowState,
diff --git a/src/store/types.ts b/src/store/types.ts
index 6a5649b2..914ec4a5 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -114,6 +114,7 @@ export interface PersistedState {
windowState?: PersistedWindowState;
autoTrustFolders?: boolean;
showPlans?: boolean;
+ desktopNotificationsEnabled?: boolean;
inactiveColumnOpacity?: number;
editorCommand?: string;
customAgents?: AgentDef[];
@@ -176,6 +177,7 @@ export interface AppStore {
windowState: PersistedWindowState | null;
autoTrustFolders: boolean;
showPlans: boolean;
+ desktopNotificationsEnabled: boolean;
inactiveColumnOpacity: number;
editorCommand: string;
newTaskDropUrl: string | null;
diff --git a/src/store/ui.ts b/src/store/ui.ts
index a50a3e8f..4b23474d 100644
--- a/src/store/ui.ts
+++ b/src/store/ui.ts
@@ -87,6 +87,10 @@ export function setShowPlans(showPlans: boolean): void {
setStore('showPlans', showPlans);
}
+export function setDesktopNotificationsEnabled(enabled: boolean): void {
+ setStore('desktopNotificationsEnabled', enabled);
+}
+
export function setInactiveColumnOpacity(opacity: number): void {
setStore('inactiveColumnOpacity', Math.round(Math.max(0.3, Math.min(1.0, opacity)) * 100) / 100);
}