From 4c0a250459b836cb1b99c0a9773687cf67a6aee7 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Fri, 13 Mar 2026 23:44:17 -0400 Subject: [PATCH 1/2] feat(notifications): add native macOS desktop notifications for task status changes Sends native desktop notifications when tasks transition to "ready" or "waiting" states while the app window is unfocused. Notifications are batched with a 3-second debounce to avoid spamming. Clicking a notification brings the window to focus and navigates to the relevant task. Co-Authored-By: Claude Opus 4.6 --- electron/ipc/channels.ts | 4 + electron/ipc/register.ts | 22 ++++- electron/preload.cjs | 3 + package-lock.json | 26 +++--- src/App.tsx | 3 + src/store/desktopNotifications.ts | 128 ++++++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 14 deletions(-) create mode 100644 src/store/desktopNotifications.ts 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..2e90c321 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,26 @@ export function registerAllHandlers(win: BrowserWindow): void { cancelAskAboutCode(args.requestId); }); + // --- Notifications --- + ipcMain.handle(IPC.ShowNotification, (_e, args) => { + assertString(args.title, 'title'); + assertString(args.body, 'body'); + assertString(args.threadId, 'threadId'); + 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(); + }); + // --- 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..cd6533d1 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) { diff --git a/package-lock.json b/package-lock.json index b0d21bde..08949856 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,6 +75,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -718,7 +719,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -740,7 +740,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -757,7 +756,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -772,7 +770,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2600,6 +2597,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -3008,6 +3006,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3041,6 +3040,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3520,6 +3520,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4139,8 +4140,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -4414,6 +4414,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -4722,7 +4723,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -4743,7 +4743,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -4975,6 +4974,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7438,7 +7438,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -7979,6 +7978,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8059,7 +8059,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -8077,7 +8076,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -8493,7 +8491,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -8663,6 +8660,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -8842,6 +8840,7 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.11.tgz", "integrity": "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", @@ -9124,7 +9123,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -9362,6 +9360,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9610,6 +9609,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", 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/store/desktopNotifications.ts b/src/store/desktopNotifications.ts new file mode 100644 index 00000000..b3878c6f --- /dev/null +++ b/src/store/desktopNotifications.ts @@ -0,0 +1,128 @@ +import { createEffect, onCleanup, type Accessor } from 'solid-js'; +import { store } from './store'; +import { getTaskDotStatus, type TaskDotStatus } from './taskStatus'; +import { setActiveTask } from './navigation'; +import { invoke } from '../lib/ipc'; +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`; + invoke(IPC.ShowNotification, { + title: 'Task Ready', + body, + threadId: 'pc-task-ready', + taskIds, + }).catch(console.warn); + } + + 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`; + invoke(IPC.ShowNotification, { + title: 'Task Waiting', + body, + threadId: 'pc-task-waiting', + taskIds, + }).catch(console.warn); + } + } + + function taskName(taskId: string): string { + return store.tasks[taskId]?.name ?? taskId; + } + + function scheduleBatch(notification: PendingNotification): void { + 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 { taskIds: string[] }; + if (msg.taskIds?.length) { + setActiveTask(msg.taskIds[0]); + } + }, + ); + + const cleanup = (): void => { + if (debounceTimer !== undefined) clearTimeout(debounceTimer); + offNotificationClicked(); + }; + + onCleanup(cleanup); + return cleanup; +} From a737bc3e6e76f33dc1c587e4ae5eb470a7b84369 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Mon, 16 Mar 2026 23:21:29 -0400 Subject: [PATCH 2/2] Addressed PR comments for notifications --- electron/ipc/register.ts | 39 +++++++++++++++++-------------- electron/preload.cjs | 4 ++++ package-lock.json | 26 ++++++++++----------- src/components/SettingsDialog.tsx | 26 +++++++++++++++++++++ src/lib/ipc.ts | 1 + src/store/core.ts | 1 + src/store/desktopNotifications.ts | 19 +++++++-------- src/store/persistence.ts | 6 +++++ src/store/store.ts | 1 + src/store/types.ts | 2 ++ src/store/ui.ts | 4 ++++ 11 files changed, 88 insertions(+), 41 deletions(-) diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 2e90c321..b12ce0aa 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -323,24 +323,27 @@ export function registerAllHandlers(win: BrowserWindow): void { cancelAskAboutCode(args.requestId); }); - // --- Notifications --- - ipcMain.handle(IPC.ShowNotification, (_e, args) => { - assertString(args.title, 'title'); - assertString(args.body, 'body'); - assertString(args.threadId, 'threadId'); - 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(); + // --- 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 --- diff --git a/electron/preload.cjs b/electron/preload.cjs index cd6533d1..e2c8baf9 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -94,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/package-lock.json b/package-lock.json index 08949856..b0d21bde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,7 +75,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -719,6 +718,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -740,6 +740,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -756,6 +757,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -770,6 +772,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2597,7 +2600,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -3006,7 +3008,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3040,7 +3041,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3520,7 +3520,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4140,7 +4139,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -4414,7 +4414,6 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -4723,6 +4722,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -4743,6 +4743,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -4974,7 +4975,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7438,6 +7438,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -7978,7 +7979,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8059,6 +8059,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -8076,6 +8077,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -8491,6 +8493,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -8660,7 +8663,6 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -8840,7 +8842,6 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.11.tgz", "integrity": "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", @@ -9123,6 +9124,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -9360,7 +9362,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9609,7 +9610,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", 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 index b3878c6f..3eb6a25b 100644 --- a/src/store/desktopNotifications.ts +++ b/src/store/desktopNotifications.ts @@ -2,7 +2,6 @@ import { createEffect, onCleanup, type Accessor } from 'solid-js'; import { store } from './store'; import { getTaskDotStatus, type TaskDotStatus } from './taskStatus'; import { setActiveTask } from './navigation'; -import { invoke } from '../lib/ipc'; import { IPC } from '../../electron/ipc/channels'; const DEBOUNCE_MS = 3_000; @@ -34,12 +33,11 @@ export function startDesktopNotificationWatcher(windowFocused: Accessor ready.length === 1 ? `${taskName(taskIds[0])} is ready for review` : `${ready.length} tasks ready for review`; - invoke(IPC.ShowNotification, { + window.electron.ipcRenderer.send(IPC.ShowNotification, { title: 'Task Ready', body, - threadId: 'pc-task-ready', taskIds, - }).catch(console.warn); + }); } if (waiting.length > 0) { @@ -48,12 +46,11 @@ export function startDesktopNotificationWatcher(windowFocused: Accessor waiting.length === 1 ? `${taskName(taskIds[0])} needs your attention` : `${waiting.length} tasks need your attention`; - invoke(IPC.ShowNotification, { + window.electron.ipcRenderer.send(IPC.ShowNotification, { title: 'Task Waiting', body, - threadId: 'pc-task-waiting', taskIds, - }).catch(console.warn); + }); } } @@ -62,6 +59,7 @@ export function startDesktopNotificationWatcher(windowFocused: Accessor } function scheduleBatch(notification: PendingNotification): void { + if (!store.desktopNotificationsEnabled) return; pending.push(notification); if (debounceTimer === undefined) { debounceTimer = setTimeout(flushNotifications, DEBOUNCE_MS); @@ -111,9 +109,10 @@ export function startDesktopNotificationWatcher(windowFocused: Accessor const offNotificationClicked = window.electron.ipcRenderer.on( IPC.NotificationClicked, (data: unknown) => { - const msg = data as { taskIds: string[] }; - if (msg.taskIds?.length) { - setActiveTask(msg.taskIds[0]); + const msg = data as Record; + const taskIds = Array.isArray(msg?.taskIds) ? (msg.taskIds as string[]) : []; + if (taskIds.length) { + setActiveTask(taskIds[0]); } }, ); 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); }