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); }