From 45e71a0e73b368309544165e4dcf8b7fd014e8dd Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Fri, 13 Mar 2026 16:59:09 +0100 Subject: [PATCH] feat: introduce notification system and claude notifications (#450) * feat: introduce notification system and claude notifications * fix(sw): prevent caching of API requests and WebSocket upgrades * default to false for webpush notifications and translations for the button * fix: notifications orchestrator and add a notification when first enabled * fix: remove unused state update and dependency in settings controller hook * fix: show notifications settings tab * fix: add notifications for response completion for all providers * feat: show session name in notification and don't reload tab on clicking --- the notification --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Haileyesus --- package-lock.json | 210 ++++++++++++++-- package.json | 1 + public/sw.js | 62 ++++- server/claude-sdk.js | 80 +++++- server/cursor-cli.js | 42 +++- server/database/db.js | 143 ++++++++++- server/database/init.sql | 29 ++- server/gemini-cli.js | 50 +++- server/index.js | 13 +- server/middleware/auth.js | 4 +- server/openai-codex.js | 46 +++- server/routes/agent.js | 12 +- server/routes/settings.js | 100 +++++++- server/services/notification-orchestrator.js | 227 ++++++++++++++++++ server/services/vapid-keys.js | 35 +++ src/components/app/AppContent.tsx | 34 +++ .../chat/hooks/useChatComposerState.ts | 25 +- src/components/chat/tools/ToolRenderer.tsx | 2 +- .../settings/constants/constants.ts | 1 + .../settings/hooks/useSettingsController.ts | 55 ++++- src/components/settings/types/types.ts | 14 +- src/components/settings/view/Settings.tsx | 42 ++++ .../settings/view/SettingsMainTabs.tsx | 5 +- .../settings/view/SettingsSidebar.tsx | 3 +- .../view/tabs/NotificationsSettingsTab.tsx | 145 +++++++++++ .../sections/content/PermissionsContent.tsx | 1 + src/hooks/useWebPush.ts | 103 ++++++++ src/i18n/locales/en/common.json | 30 +++ src/i18n/locales/en/settings.json | 21 ++ src/i18n/locales/ja/common.json | 30 +++ src/i18n/locales/ja/settings.json | 21 ++ src/i18n/locales/ko/common.json | 30 +++ src/i18n/locales/ko/settings.json | 21 ++ src/i18n/locales/zh-CN/common.json | 30 +++ src/i18n/locales/zh-CN/settings.json | 21 ++ src/main.jsx | 10 +- 36 files changed, 1629 insertions(+), 69 deletions(-) create mode 100644 server/services/notification-orchestrator.js create mode 100644 server/services/vapid-keys.js create mode 100644 src/components/settings/view/tabs/NotificationsSettingsTab.tsx create mode 100644 src/hooks/useWebPush.ts diff --git a/package-lock.json b/package-lock.json index 1799af5..979d333 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,7 @@ "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "tailwind-merge": "^3.3.1", + "web-push": "^3.6.7", "ws": "^8.14.2" }, "bin": { @@ -1903,9 +1904,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", - "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -1920,9 +1921,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", - "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -2049,9 +2050,9 @@ } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", - "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -2067,13 +2068,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", - "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -2089,7 +2090,7 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { @@ -2113,9 +2114,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", - "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], @@ -4610,7 +4611,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -4916,6 +4916,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -5178,6 +5190,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -8952,6 +8970,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -8993,7 +9020,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -11751,6 +11777,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -14769,6 +14801,40 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", + "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", + "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/sharp/node_modules/@img/sharp-linux-arm": { "version": "0.34.3", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", @@ -14838,6 +14904,72 @@ "@img/sharp-libvips-linux-x64": "1.2.0" } }, + "node_modules/sharp/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", + "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", + "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + } + }, + "node_modules/sharp/node_modules/@img/sharp-win32-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", + "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/sharp/node_modules/@img/sharp-win32-x64": { "version": "0.34.3", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", @@ -17069,6 +17201,46 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/web-push/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index aad66d0..222f825 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "tailwind-merge": "^3.3.1", + "web-push": "^3.6.7", "ws": "^8.14.2" }, "devDependencies": { diff --git a/public/sw.js b/public/sw.js index 181c60d..f521dda 100755 --- a/public/sw.js +++ b/public/sw.js @@ -19,14 +19,17 @@ self.addEventListener('install', event => { // Fetch event self.addEventListener('fetch', event => { + // Never cache API requests or WebSocket upgrades + if (event.request.url.includes('/api/') || event.request.url.includes('/ws')) { + return; + } + event.respondWith( caches.match(event.request) .then(response => { - // Return cached response if found if (response) { return response; } - // Otherwise fetch from network return fetch(event.request); } ) @@ -46,4 +49,57 @@ self.addEventListener('activate', event => { ); }) ); -}); \ No newline at end of file + self.clients.claim(); +}); + +// Push notification event +self.addEventListener('push', event => { + if (!event.data) return; + + let payload; + try { + payload = event.data.json(); + } catch { + payload = { title: 'Claude Code UI', body: event.data.text() }; + } + + const options = { + body: payload.body || '', + icon: '/logo-256.png', + badge: '/logo-128.png', + data: payload.data || {}, + tag: payload.data?.tag || `${payload.data?.sessionId || 'global'}:${payload.data?.code || 'default'}`, + renotify: true + }; + + event.waitUntil( + self.registration.showNotification(payload.title || 'Claude Code UI', options) + ); +}); + +// Notification click event +self.addEventListener('notificationclick', event => { + event.notification.close(); + + const sessionId = event.notification.data?.sessionId; + const provider = event.notification.data?.provider || null; + const urlPath = sessionId ? `/session/${sessionId}` : '/'; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async clientList => { + for (const client of clientList) { + if (client.url.includes(self.location.origin)) { + await client.focus(); + client.postMessage({ + type: 'notification:navigate', + sessionId: sessionId || null, + provider, + urlPath + }); + return; + } + } + return self.clients.openWindow(urlPath); + }) + ); +}); diff --git a/server/claude-sdk.js b/server/claude-sdk.js index cdf41ff..2bc80b0 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -18,6 +18,12 @@ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import { CLAUDE_MODELS } from '../shared/modelConstants.js'; +import { + createNotificationEvent, + notifyRunFailed, + notifyRunStopped, + notifyUserIfEnabled +} from './services/notification-orchestrator.js'; const activeSessions = new Map(); const pendingToolApprovals = new Map(); @@ -461,12 +467,20 @@ async function loadMcpConfig(cwd) { * @returns {Promise} */ async function queryClaudeSDK(command, options = {}, ws) { - const { sessionId } = options; + const { sessionId, sessionSummary } = options; let capturedSessionId = sessionId; let sessionCreatedSent = false; let tempImagePaths = []; let tempDir = null; + const emitNotification = (event) => { + notifyUserIfEnabled({ + userId: ws?.userId || null, + writer: ws, + event + }); + }; + try { // Map CLI options to SDK format const sdkOptions = mapCliOptionsToSDK(options); @@ -483,6 +497,26 @@ async function queryClaudeSDK(command, options = {}, ws) { tempImagePaths = imageResult.tempImagePaths; tempDir = imageResult.tempDir; + sdkOptions.hooks = { + Notification: [{ + matcher: '', + hooks: [async (input) => { + const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.'; + emitNotification(createNotificationEvent({ + provider: 'claude', + sessionId: capturedSessionId || sessionId || null, + kind: 'action_required', + code: 'agent.notification', + meta: { message, sessionName: sessionSummary }, + severity: 'warning', + requiresUserAction: true, + dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}` + })); + return {}; + }] + }] + }; + sdkOptions.canUseTool = async (toolName, input, context) => { const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName); @@ -514,6 +548,16 @@ async function queryClaudeSDK(command, options = {}, ws) { input, sessionId: capturedSessionId || sessionId || null }); + emitNotification(createNotificationEvent({ + provider: 'claude', + sessionId: capturedSessionId || sessionId || null, + kind: 'action_required', + code: 'permission.required', + meta: { toolName, sessionName: sessionSummary }, + severity: 'warning', + requiresUserAction: true, + dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}` + })); const decision = await waitForToolApproval(requestId, { timeoutMs: requiresInteraction ? 0 : undefined, @@ -560,10 +604,22 @@ async function queryClaudeSDK(command, options = {}, ws) { const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000'; - const queryInstance = query({ - prompt: finalCommand, - options: sdkOptions - }); + let queryInstance; + try { + queryInstance = query({ + prompt: finalCommand, + options: sdkOptions + }); + } catch (hookError) { + // Older/newer SDK versions may not accept hook shapes yet. + // Keep notification behavior operational via runtime events even if hook registration fails. + console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError); + delete sdkOptions.hooks; + queryInstance = query({ + prompt: finalCommand, + options: sdkOptions + }); + } // Restore immediately — Query constructor already captured the value if (prevStreamTimeout !== undefined) { @@ -647,6 +703,13 @@ async function queryClaudeSDK(command, options = {}, ws) { exitCode: 0, isNewSession: !sessionId && !!command }); + notifyRunStopped({ + userId: ws?.userId || null, + provider: 'claude', + sessionId: capturedSessionId || sessionId || null, + sessionName: sessionSummary, + stopReason: 'completed' + }); console.log('claude-complete event sent'); } catch (error) { @@ -666,6 +729,13 @@ async function queryClaudeSDK(command, options = {}, ws) { error: error.message, sessionId: capturedSessionId || sessionId || null }); + notifyRunFailed({ + userId: ws?.userId || null, + provider: 'claude', + sessionId: capturedSessionId || sessionId || null, + sessionName: sessionSummary, + error + }); throw error; } diff --git a/server/cursor-cli.js b/server/cursor-cli.js index f5fe7db..d354723 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -1,5 +1,6 @@ import { spawn } from 'child_process'; import crossSpawn from 'cross-spawn'; +import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; // Use cross-spawn on Windows for better command execution const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; @@ -23,7 +24,7 @@ function isWorkspaceTrustPrompt(text = '') { async function spawnCursor(command, options = {}, ws) { return new Promise(async (resolve, reject) => { - const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options; + const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary } = options; let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event let hasRetriedWithTrust = false; @@ -81,6 +82,35 @@ async function spawnCursor(command, options = {}, ws) { const isTrustRetry = runReason === 'trust-retry'; let runSawWorkspaceTrustPrompt = false; let stdoutLineBuffer = ''; + let terminalNotificationSent = false; + + const notifyTerminalState = ({ code = null, error = null } = {}) => { + if (terminalNotificationSent) { + return; + } + + terminalNotificationSent = true; + + const finalSessionId = capturedSessionId || sessionId || processKey; + if (code === 0 && !error) { + notifyRunStopped({ + userId: ws?.userId || null, + provider: 'cursor', + sessionId: finalSessionId, + sessionName: sessionSummary, + stopReason: 'completed' + }); + return; + } + + notifyRunFailed({ + userId: ws?.userId || null, + provider: 'cursor', + sessionId: finalSessionId, + sessionName: sessionSummary, + error: error || `Cursor CLI exited with code ${code}` + }); + }; if (isTrustRetry) { console.log('Retrying Cursor CLI with --trust after workspace trust prompt'); @@ -255,7 +285,8 @@ async function spawnCursor(command, options = {}, ws) { ws.send({ type: 'cursor-error', error: stderrText, - sessionId: capturedSessionId || sessionId || null + sessionId: capturedSessionId || sessionId || null, + provider: 'cursor' }); }); @@ -287,12 +318,15 @@ async function spawnCursor(command, options = {}, ws) { type: 'claude-complete', sessionId: finalSessionId, exitCode: code, + provider: 'cursor', isNewSession: !sessionId && !!command // Flag to indicate this was a new session }); if (code === 0) { + notifyTerminalState({ code }); settleOnce(() => resolve()); } else { + notifyTerminalState({ code }); settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`))); } }); @@ -308,8 +342,10 @@ async function spawnCursor(command, options = {}, ws) { ws.send({ type: 'cursor-error', error: error.message, - sessionId: capturedSessionId || sessionId || null + sessionId: capturedSessionId || sessionId || null, + provider: 'cursor' }); + notifyTerminalState({ error }); settleOnce(() => reject(error)); }); diff --git a/server/database/db.js b/server/database/db.js index bb90c61..9ab0ad7 100644 --- a/server/database/db.js +++ b/server/database/db.js @@ -100,6 +100,35 @@ const runMigrations = () => { db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0'); } + db.exec(` + CREATE TABLE IF NOT EXISTS user_notification_preferences ( + user_id INTEGER PRIMARY KEY, + preferences_json TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS vapid_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + keys_p256dh TEXT NOT NULL, + keys_auth TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); // Create app_config table if it doesn't exist (for existing installations) db.exec(`CREATE TABLE IF NOT EXISTS app_config ( key TEXT PRIMARY KEY, @@ -376,6 +405,116 @@ const credentialsDb = { } }; +const DEFAULT_NOTIFICATION_PREFERENCES = { + channels: { + inApp: false, + webPush: false + }, + events: { + actionRequired: true, + stop: true, + error: true + } +}; + +const normalizeNotificationPreferences = (value) => { + const source = value && typeof value === 'object' ? value : {}; + + return { + channels: { + inApp: source.channels?.inApp === true, + webPush: source.channels?.webPush === true + }, + events: { + actionRequired: source.events?.actionRequired !== false, + stop: source.events?.stop !== false, + error: source.events?.error !== false + } + }; +}; + +const notificationPreferencesDb = { + getPreferences: (userId) => { + try { + const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId); + if (!row) { + const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES); + db.prepare( + 'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)' + ).run(userId, JSON.stringify(defaults)); + return defaults; + } + + let parsed; + try { + parsed = JSON.parse(row.preferences_json); + } catch { + parsed = DEFAULT_NOTIFICATION_PREFERENCES; + } + return normalizeNotificationPreferences(parsed); + } catch (err) { + throw err; + } + }, + + updatePreferences: (userId, preferences) => { + try { + const normalized = normalizeNotificationPreferences(preferences); + db.prepare( + `INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET + preferences_json = excluded.preferences_json, + updated_at = CURRENT_TIMESTAMP` + ).run(userId, JSON.stringify(normalized)); + return normalized; + } catch (err) { + throw err; + } + } +}; + +const pushSubscriptionsDb = { + saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => { + try { + db.prepare( + `INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth) + VALUES (?, ?, ?, ?) + ON CONFLICT(endpoint) DO UPDATE SET + user_id = excluded.user_id, + keys_p256dh = excluded.keys_p256dh, + keys_auth = excluded.keys_auth` + ).run(userId, endpoint, keysP256dh, keysAuth); + } catch (err) { + throw err; + } + }, + + getSubscriptions: (userId) => { + try { + return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId); + } catch (err) { + throw err; + } + }, + + removeSubscription: (endpoint) => { + try { + db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint); + } catch (err) { + throw err; + } + }, + + removeAllForUser: (userId) => { + try { + db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId); + } catch (err) { + throw err; + } + } +}; + // Session custom names database operations const sessionNamesDb = { // Set (insert or update) a custom session name @@ -482,8 +621,10 @@ export { userDb, apiKeysDb, credentialsDb, + notificationPreferencesDb, + pushSubscriptionsDb, sessionNamesDb, applyCustomSessionNames, appConfigDb, githubTokensDb // Backward compatibility -}; \ No newline at end of file +}; diff --git a/server/database/init.sql b/server/database/init.sql index 71ba1bb..9835151 100644 --- a/server/database/init.sql +++ b/server/database/init.sql @@ -51,6 +51,33 @@ CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type); CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active); +-- User notification preferences (backend-owned, provider-agnostic) +CREATE TABLE IF NOT EXISTS user_notification_preferences ( + user_id INTEGER PRIMARY KEY, + preferences_json TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- VAPID key pair for Web Push notifications +CREATE TABLE IF NOT EXISTS vapid_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Browser push subscriptions +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + keys_p256dh TEXT NOT NULL, + keys_auth TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + -- Session custom names (provider-agnostic display name overrides) CREATE TABLE IF NOT EXISTS session_names ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -69,4 +96,4 @@ CREATE TABLE IF NOT EXISTS app_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); \ No newline at end of file +); diff --git a/server/gemini-cli.js b/server/gemini-cli.js index 0c6506a..3a2c968 100644 --- a/server/gemini-cli.js +++ b/server/gemini-cli.js @@ -9,11 +9,12 @@ import os from 'os'; import { getSessions, getSessionMessages } from './projects.js'; import sessionManager from './sessionManager.js'; import GeminiResponseHandler from './gemini-response-handler.js'; +import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; let activeGeminiProcesses = new Map(); // Track active processes by session ID async function spawnGemini(command, options = {}, ws) { - const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options; + const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images, sessionSummary } = options; let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event let assistantBlocks = []; // Accumulate the full response blocks including tools @@ -172,6 +173,36 @@ async function spawnGemini(command, options = {}, ws) { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } // Inherit all environment variables }); + let terminalNotificationSent = false; + let terminalFailureReason = null; + + const notifyTerminalState = ({ code = null, error = null } = {}) => { + if (terminalNotificationSent) { + return; + } + + terminalNotificationSent = true; + + const finalSessionId = capturedSessionId || sessionId || processKey; + if (code === 0 && !error) { + notifyRunStopped({ + userId: ws?.userId || null, + provider: 'gemini', + sessionId: finalSessionId, + sessionName: sessionSummary, + stopReason: 'completed' + }); + return; + } + + notifyRunFailed({ + userId: ws?.userId || null, + provider: 'gemini', + sessionId: finalSessionId, + sessionName: sessionSummary, + error: error || terminalFailureReason || `Gemini CLI exited with code ${code}` + }); + }; // Attach temp file info to process for cleanup later geminiProcess.tempImagePaths = tempImagePaths; @@ -196,10 +227,12 @@ async function spawnGemini(command, options = {}, ws) { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => { const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey); + terminalFailureReason = `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`; ws.send({ type: 'gemini-error', sessionId: socketSessionId, - error: `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds` + error: terminalFailureReason, + provider: 'gemini' }); try { geminiProcess.kill('SIGTERM'); @@ -340,7 +373,8 @@ async function spawnGemini(command, options = {}, ws) { ws.send({ type: 'gemini-error', sessionId: socketSessionId, - error: errorMsg + error: errorMsg, + provider: 'gemini' }); }); @@ -367,6 +401,7 @@ async function spawnGemini(command, options = {}, ws) { type: 'claude-complete', // Use claude-complete for compatibility with UI sessionId: finalSessionId, exitCode: code, + provider: 'gemini', isNewSession: !sessionId && !!command // Flag to indicate this was a new session }); @@ -381,8 +416,13 @@ async function spawnGemini(command, options = {}, ws) { } if (code === 0) { + notifyTerminalState({ code }); resolve(); } else { + notifyTerminalState({ + code, + error: code === null ? 'Gemini CLI process was terminated or timed out' : null + }); reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`)); } }); @@ -397,8 +437,10 @@ async function spawnGemini(command, options = {}, ws) { ws.send({ type: 'gemini-error', sessionId: errorSessionId, - error: error.message + error: error.message, + provider: 'gemini' }); + notifyTerminalState({ error }); reject(error); }); diff --git a/server/index.js b/server/index.js index 169d042..27aae75 100755 --- a/server/index.js +++ b/server/index.js @@ -67,6 +67,7 @@ import geminiRoutes from './routes/gemini.js'; import pluginsRoutes from './routes/plugins.js'; import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js'; import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js'; +import { configureWebPush } from './services/vapid-keys.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { IS_PLATFORM } from './constants/config.js'; @@ -1406,7 +1407,7 @@ wss.on('connection', (ws, request) => { if (pathname === '/shell') { handleShellConnection(ws); } else if (pathname === '/ws') { - handleChatConnection(ws); + handleChatConnection(ws, request); } else { console.log('[WARN] Unknown WebSocket path:', pathname); ws.close(); @@ -1417,9 +1418,10 @@ wss.on('connection', (ws, request) => { * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface */ class WebSocketWriter { - constructor(ws) { + constructor(ws, userId = null) { this.ws = ws; this.sessionId = null; + this.userId = userId; this.isWebSocketWriter = true; // Marker for transport detection } @@ -1444,14 +1446,14 @@ class WebSocketWriter { } // Handle chat WebSocket connections -function handleChatConnection(ws) { +function handleChatConnection(ws, request) { console.log('[INFO] Chat WebSocket connected'); // Add to connected clients for project updates connectedClients.add(ws); // Wrap WebSocket with writer for consistent interface with SSEStreamWriter - const writer = new WebSocketWriter(ws); + const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null); ws.on('message', async (message) => { try { @@ -2500,6 +2502,9 @@ async function startServer() { // Initialize authentication database await initializeDatabase(); + // Configure Web Push (VAPID keys) + configureWebPush(); + // Check if running in production mode (dist folder exists) const distIndexPath = path.join(__dirname, '../dist/index.html'); const isProduction = fs.existsSync(distIndexPath); diff --git a/server/middleware/auth.js b/server/middleware/auth.js index bdfd0f5..7374979 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -95,7 +95,7 @@ const authenticateWebSocket = (token) => { try { const user = userDb.getFirstUser(); if (user) { - return { userId: user.id, username: user.username }; + return { id: user.id, userId: user.id, username: user.username }; } return null; } catch (error) { @@ -129,4 +129,4 @@ export { generateToken, authenticateWebSocket, JWT_SECRET -}; \ No newline at end of file +}; diff --git a/server/openai-codex.js b/server/openai-codex.js index bd368ff..a12f7e0 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -14,6 +14,7 @@ */ import { Codex } from '@openai/codex-sdk'; +import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; // Track active sessions const activeCodexSessions = new Map(); @@ -191,6 +192,7 @@ function mapPermissionModeToCodexOptions(permissionMode) { export async function queryCodex(command, options = {}, ws) { const { sessionId, + sessionSummary, cwd, projectPath, model, @@ -203,6 +205,7 @@ export async function queryCodex(command, options = {}, ws) { let codex; let thread; let currentSessionId = sessionId; + let terminalFailure = null; const abortController = new AbortController(); try { @@ -268,6 +271,17 @@ export async function queryCodex(command, options = {}, ws) { sessionId: currentSessionId }); + if (event.type === 'turn.failed' && !terminalFailure) { + terminalFailure = event.error || new Error('Turn failed'); + notifyRunFailed({ + userId: ws?.userId || null, + provider: 'codex', + sessionId: currentSessionId, + sessionName: sessionSummary, + error: terminalFailure + }); + } + // Extract and send token usage if available (normalized to match Claude format) if (event.type === 'turn.completed' && event.usage) { const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0); @@ -283,11 +297,21 @@ export async function queryCodex(command, options = {}, ws) { } // Send completion event - sendMessage(ws, { - type: 'codex-complete', - sessionId: currentSessionId, - actualSessionId: thread.id - }); + if (!terminalFailure) { + sendMessage(ws, { + type: 'codex-complete', + sessionId: currentSessionId, + actualSessionId: thread.id, + provider: 'codex' + }); + notifyRunStopped({ + userId: ws?.userId || null, + provider: 'codex', + sessionId: currentSessionId, + sessionName: sessionSummary, + stopReason: 'completed' + }); + } } catch (error) { const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null; @@ -301,8 +325,18 @@ export async function queryCodex(command, options = {}, ws) { sendMessage(ws, { type: 'codex-error', error: error.message, - sessionId: currentSessionId + sessionId: currentSessionId, + provider: 'codex' }); + if (!terminalFailure) { + notifyRunFailed({ + userId: ws?.userId || null, + provider: 'codex', + sessionId: currentSessionId, + sessionName: sessionSummary, + error + }); + } } } finally { diff --git a/server/routes/agent.js b/server/routes/agent.js index 8bc88c9..bf2d36d 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -450,9 +450,10 @@ async function cleanupProject(projectPath, sessionId = null) { * SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events */ class SSEStreamWriter { - constructor(res) { + constructor(res, userId = null) { this.res = res; this.sessionId = null; + this.userId = userId; this.isSSEStreamWriter = true; // Marker for transport detection } @@ -485,9 +486,10 @@ class SSEStreamWriter { * Non-streaming response collector */ class ResponseCollector { - constructor() { + constructor(userId = null) { this.messages = []; this.sessionId = null; + this.userId = userId; } send(data) { @@ -920,7 +922,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering - writer = new SSEStreamWriter(res); + writer = new SSEStreamWriter(res, req.user.id); // Send initial status writer.send({ @@ -930,7 +932,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { }); } else { // Non-streaming mode: collect messages - writer = new ResponseCollector(); + writer = new ResponseCollector(req.user.id); // Collect initial status message writer.send({ @@ -1219,7 +1221,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); - writer = new SSEStreamWriter(res); + writer = new SSEStreamWriter(res, req.user.id); } if (!res.writableEnded) { diff --git a/server/routes/settings.js b/server/routes/settings.js index d1c141b..7eee245 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -1,5 +1,7 @@ import express from 'express'; -import { apiKeysDb, credentialsDb } from '../database/db.js'; +import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js'; +import { getPublicKey } from '../services/vapid-keys.js'; +import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js'; const router = express.Router(); @@ -175,4 +177,100 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => { } }); +// =============================== +// Notification Preferences +// =============================== + +router.get('/notification-preferences', async (req, res) => { + try { + const preferences = notificationPreferencesDb.getPreferences(req.user.id); + res.json({ success: true, preferences }); + } catch (error) { + console.error('Error fetching notification preferences:', error); + res.status(500).json({ error: 'Failed to fetch notification preferences' }); + } +}); + +router.put('/notification-preferences', async (req, res) => { + try { + const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {}); + res.json({ success: true, preferences }); + } catch (error) { + console.error('Error saving notification preferences:', error); + res.status(500).json({ error: 'Failed to save notification preferences' }); + } +}); + +// =============================== +// Push Subscription Management +// =============================== + +router.get('/push/vapid-public-key', async (req, res) => { + try { + const publicKey = getPublicKey(); + res.json({ publicKey }); + } catch (error) { + console.error('Error fetching VAPID public key:', error); + res.status(500).json({ error: 'Failed to fetch VAPID public key' }); + } +}); + +router.post('/push/subscribe', async (req, res) => { + try { + const { endpoint, keys } = req.body; + if (!endpoint || !keys?.p256dh || !keys?.auth) { + return res.status(400).json({ error: 'Missing subscription fields' }); + } + pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth); + + // Enable webPush in preferences so the confirmation goes through the full pipeline + const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id); + if (!currentPrefs?.channels?.webPush) { + notificationPreferencesDb.updatePreferences(req.user.id, { + ...currentPrefs, + channels: { ...currentPrefs?.channels, webPush: true }, + }); + } + + res.json({ success: true }); + + // Send a confirmation push through the full notification pipeline + const event = createNotificationEvent({ + provider: 'system', + kind: 'info', + code: 'push.enabled', + meta: { message: 'Push notifications are now enabled!' }, + severity: 'info' + }); + notifyUserIfEnabled({ userId: req.user.id, event }); + } catch (error) { + console.error('Error saving push subscription:', error); + res.status(500).json({ error: 'Failed to save push subscription' }); + } +}); + +router.post('/push/unsubscribe', async (req, res) => { + try { + const { endpoint } = req.body; + if (!endpoint) { + return res.status(400).json({ error: 'Missing endpoint' }); + } + pushSubscriptionsDb.removeSubscription(endpoint); + + // Disable webPush in preferences to match subscription state + const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id); + if (currentPrefs?.channels?.webPush) { + notificationPreferencesDb.updatePreferences(req.user.id, { + ...currentPrefs, + channels: { ...currentPrefs.channels, webPush: false }, + }); + } + + res.json({ success: true }); + } catch (error) { + console.error('Error removing push subscription:', error); + res.status(500).json({ error: 'Failed to remove push subscription' }); + } +}); + export default router; diff --git a/server/services/notification-orchestrator.js b/server/services/notification-orchestrator.js new file mode 100644 index 0000000..bb573e1 --- /dev/null +++ b/server/services/notification-orchestrator.js @@ -0,0 +1,227 @@ +import webPush from 'web-push'; +import { notificationPreferencesDb, pushSubscriptionsDb, sessionNamesDb } from '../database/db.js'; + +const KIND_TO_PREF_KEY = { + action_required: 'actionRequired', + stop: 'stop', + error: 'error' +}; + +const PROVIDER_LABELS = { + claude: 'Claude', + cursor: 'Cursor', + codex: 'Codex', + gemini: 'Gemini', + system: 'System' +}; + +const recentEventKeys = new Map(); +const DEDUPE_WINDOW_MS = 20000; + +const cleanupOldEventKeys = () => { + const now = Date.now(); + for (const [key, timestamp] of recentEventKeys.entries()) { + if (now - timestamp > DEDUPE_WINDOW_MS) { + recentEventKeys.delete(key); + } + } +}; + +function shouldSendPush(preferences, event) { + const webPushEnabled = Boolean(preferences?.channels?.webPush); + const prefEventKey = KIND_TO_PREF_KEY[event.kind]; + const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true; + + return webPushEnabled && eventEnabled; +} + +function isDuplicate(event) { + cleanupOldEventKeys(); + const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`; + if (recentEventKeys.has(key)) { + return true; + } + recentEventKeys.set(key, Date.now()); + return false; +} + +function createNotificationEvent({ + provider, + sessionId = null, + kind = 'info', + code = 'generic.info', + meta = {}, + severity = 'info', + dedupeKey = null, + requiresUserAction = false +}) { + return { + provider, + sessionId, + kind, + code, + meta, + severity, + requiresUserAction, + dedupeKey, + createdAt: new Date().toISOString() + }; +} + +function normalizeErrorMessage(error) { + if (typeof error === 'string') { + return error; + } + + if (error && typeof error.message === 'string') { + return error.message; + } + + if (error == null) { + return 'Unknown error'; + } + + return String(error); +} + +function normalizeSessionName(sessionName) { + if (typeof sessionName !== 'string') { + return null; + } + + const normalized = sessionName.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return null; + } + + return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; +} + +function resolveSessionName(event) { + const explicitSessionName = normalizeSessionName(event.meta?.sessionName); + if (explicitSessionName) { + return explicitSessionName; + } + + if (!event.sessionId || !event.provider) { + return null; + } + + return normalizeSessionName(sessionNamesDb.getName(event.sessionId, event.provider)); +} + +function buildPushBody(event) { + const CODE_MAP = { + 'permission.required': event.meta?.toolName + ? `Action Required: Tool "${event.meta.toolName}" needs approval` + : 'Action Required: A tool needs your approval', + 'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped', + 'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error', + 'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification', + 'push.enabled': 'Push notifications are now enabled!' + }; + const providerLabel = PROVIDER_LABELS[event.provider] || 'Assistant'; + const sessionName = resolveSessionName(event); + const message = CODE_MAP[event.code] || 'You have a new notification'; + + return { + title: sessionName || 'Claude Code UI', + body: `${providerLabel}: ${message}`, + data: { + sessionId: event.sessionId || null, + code: event.code, + provider: event.provider || null, + sessionName, + tag: `${event.provider || 'assistant'}:${event.sessionId || 'none'}:${event.code}` + } + }; +} + +async function sendWebPush(userId, event) { + const subscriptions = pushSubscriptionsDb.getSubscriptions(userId); + if (!subscriptions.length) return; + + const payload = JSON.stringify(buildPushBody(event)); + + const results = await Promise.allSettled( + subscriptions.map((sub) => + webPush.sendNotification( + { + endpoint: sub.endpoint, + keys: { + p256dh: sub.keys_p256dh, + auth: sub.keys_auth + } + }, + payload + ) + ) + ); + + // Clean up gone subscriptions (410 Gone or 404) + results.forEach((result, index) => { + if (result.status === 'rejected') { + const statusCode = result.reason?.statusCode; + if (statusCode === 410 || statusCode === 404) { + pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint); + } + } + }); +} + +function notifyUserIfEnabled({ userId, event }) { + if (!userId || !event) { + return; + } + + const preferences = notificationPreferencesDb.getPreferences(userId); + if (!shouldSendPush(preferences, event)) { + return; + } + if (isDuplicate(event)) { + return; + } + + sendWebPush(userId, event).catch((err) => { + console.error('Web push send error:', err); + }); +} + +function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) { + notifyUserIfEnabled({ + userId, + event: createNotificationEvent({ + provider, + sessionId, + kind: 'stop', + code: 'run.stopped', + meta: { stopReason, sessionName }, + severity: 'info', + dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}` + }) + }); +} + +function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) { + const errorMessage = normalizeErrorMessage(error); + + notifyUserIfEnabled({ + userId, + event: createNotificationEvent({ + provider, + sessionId, + kind: 'error', + code: 'run.failed', + meta: { error: errorMessage, sessionName }, + severity: 'error', + dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}` + }) + }); +} + +export { + createNotificationEvent, + notifyUserIfEnabled, + notifyRunStopped, + notifyRunFailed +}; diff --git a/server/services/vapid-keys.js b/server/services/vapid-keys.js new file mode 100644 index 0000000..1abaeba --- /dev/null +++ b/server/services/vapid-keys.js @@ -0,0 +1,35 @@ +import webPush from 'web-push'; +import { db } from '../database/db.js'; + +let cachedKeys = null; + +function ensureVapidKeys() { + if (cachedKeys) return cachedKeys; + + const row = db.prepare('SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1').get(); + if (row) { + cachedKeys = { publicKey: row.public_key, privateKey: row.private_key }; + return cachedKeys; + } + + const keys = webPush.generateVAPIDKeys(); + db.prepare('INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)').run(keys.publicKey, keys.privateKey); + cachedKeys = keys; + return cachedKeys; +} + +function getPublicKey() { + return ensureVapidKeys().publicKey; +} + +function configureWebPush() { + const keys = ensureVapidKeys(); + webPush.setVapidDetails( + 'mailto:noreply@claudecodeui.local', + keys.publicKey, + keys.privateKey + ); + console.log('Web Push notifications configured'); +} + +export { ensureVapidKeys, getPublicKey, configureWebPush }; diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 5649c0c..ed14fdb 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -72,6 +72,40 @@ export default function AppContent() { }; }, [openSettings]); + useEffect(() => { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return undefined; + } + + const handleServiceWorkerMessage = (event: MessageEvent) => { + const message = event.data; + if (!message || message.type !== 'notification:navigate') { + return; + } + + if (typeof message.provider === 'string' && message.provider.trim()) { + localStorage.setItem('selected-provider', message.provider); + } + + setActiveTab('chat'); + setSidebarOpen(false); + void refreshProjectsSilently(); + + if (typeof message.sessionId === 'string' && message.sessionId) { + navigate(`/session/${message.sessionId}`); + return; + } + + navigate('/'); + }; + + navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage); + + return () => { + navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage); + }; + }, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]); + // Permission recovery: query pending permissions on WebSocket reconnect or session change useEffect(() => { const isReconnect = isConnected && !wasConnectedRef.current; diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index bdba41d..6aab9ee 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -82,6 +82,24 @@ const createFakeSubmitEvent = () => { const isTemporarySessionId = (sessionId: string | null | undefined) => Boolean(sessionId && sessionId.startsWith('new-session-')); +const getNotificationSessionSummary = ( + selectedSession: ProjectSession | null, + fallbackInput: string, +): string | null => { + const sessionSummary = selectedSession?.summary || selectedSession?.name || selectedSession?.title; + if (typeof sessionSummary === 'string' && sessionSummary.trim()) { + const normalized = sessionSummary.replace(/\s+/g, ' ').trim(); + return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; + } + + const normalizedFallback = fallbackInput.replace(/\s+/g, ' ').trim(); + if (!normalizedFallback) { + return null; + } + + return normalizedFallback.length > 80 ? `${normalizedFallback.slice(0, 77)}...` : normalizedFallback; +}; + export function useChatComposerState({ selectedProject, selectedSession, @@ -603,6 +621,7 @@ export function useChatComposerState({ const toolsSettings = getToolsSettings(); const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || ''; + const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput); if (provider === 'cursor') { sendMessage({ @@ -616,6 +635,7 @@ export function useChatComposerState({ resume: Boolean(effectiveSessionId), model: cursorModel, skipPermissions: toolsSettings?.skipPermissions || false, + sessionSummary, toolsSettings, }, }); @@ -630,6 +650,7 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: codexModel, + sessionSummary, permissionMode: permissionMode === 'plan' ? 'default' : permissionMode, }, }); @@ -644,6 +665,7 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: geminiModel, + sessionSummary, permissionMode, toolsSettings, }, @@ -660,6 +682,7 @@ export function useChatComposerState({ toolsSettings, permissionMode, model: claudeModel, + sessionSummary, images: uploadedImages, }, }); @@ -681,6 +704,7 @@ export function useChatComposerState({ safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); }, [ + selectedSession, attachedImages, claudeModel, codexModel, @@ -697,7 +721,6 @@ export function useChatComposerState({ resetCommandMenuState, scrollToBottom, selectedProject, - selectedSession?.id, sendMessage, setCanAbortSession, setChatMessages, diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx index 978723d..26524c5 100644 --- a/src/components/chat/tools/ToolRenderer.tsx +++ b/src/components/chat/tools/ToolRenderer.tsx @@ -80,7 +80,7 @@ export const ToolRenderer: React.FC = memo(({ } }, [displayConfig, parsedData, onFileOpen]); - // Route subagent containers to dedicated component (after hooks to keep call order stable) + // Route subagent containers to dedicated component (after hooks to satisfy Rules of Hooks) if (isSubagentContainer && subagentState) { if (mode === 'result') { return null; diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts index 3efd5c0..36f4539 100644 --- a/src/components/settings/constants/constants.ts +++ b/src/components/settings/constants/constants.ts @@ -18,6 +18,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [ 'git', 'api', 'tasks', + 'notifications', ]; export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex']; diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index f770190..293cbce 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -20,6 +20,7 @@ import type { McpServer, McpToolsResult, McpTestResult, + NotificationPreferencesState, ProjectSortOrder, SettingsMainTab, SettingsProject, @@ -96,9 +97,14 @@ type CodexSettingsStorage = { permissionMode?: CodexPermissionMode; }; +type NotificationPreferencesResponse = { + success?: boolean; + preferences?: NotificationPreferencesState; +}; + type ActiveLoginProvider = AgentProvider | ''; -const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'plugins']; +const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications', 'plugins']; const normalizeMainTab = (tab: string): SettingsMainTab => { // Keep backwards compatibility with older callers that still pass "tools". @@ -186,6 +192,18 @@ const createEmptyCursorPermissions = (): CursorPermissionsState => ({ ...DEFAULT_CURSOR_PERMISSIONS, }); +const createDefaultNotificationPreferences = (): NotificationPreferencesState => ({ + channels: { + inApp: true, + webPush: false, + }, + events: { + actionRequired: true, + stop: true, + error: true, + }, +}); + export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) { const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue; const closeTimerRef = useRef(null); @@ -204,6 +222,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: const [cursorPermissions, setCursorPermissions] = useState(() => ( createEmptyCursorPermissions() )); + const [notificationPreferences, setNotificationPreferences] = useState(() => ( + createDefaultNotificationPreferences() + )); const [codexPermissionMode, setCodexPermissionMode] = useState('default'); const [geminiPermissionMode, setGeminiPermissionMode] = useState('default'); @@ -670,6 +691,22 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: ); setGeminiPermissionMode(savedGeminiSettings.permissionMode || 'default'); + try { + const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences'); + if (notificationResponse.ok) { + const notificationData = await toResponseJson(notificationResponse); + if (notificationData.success && notificationData.preferences) { + setNotificationPreferences(notificationData.preferences); + } else { + setNotificationPreferences(createDefaultNotificationPreferences()); + } + } else { + setNotificationPreferences(createDefaultNotificationPreferences()); + } + } catch { + setNotificationPreferences(createDefaultNotificationPreferences()); + } + await Promise.all([ fetchMcpServers(), fetchCursorMcpServers(), @@ -679,6 +716,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: console.error('Error loading settings:', error); setClaudePermissions(createEmptyClaudePermissions()); setCursorPermissions(createEmptyCursorPermissions()); + setNotificationPreferences(createDefaultNotificationPreferences()); setCodexPermissionMode('default'); setProjectSortOrder('name'); } @@ -699,7 +737,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: void checkAuthStatus(loginProvider); }, [checkAuthStatus, loginProvider]); - const saveSettings = useCallback(() => { + const saveSettings = useCallback(async () => { + setSaveStatus(null); + try { const now = new Date().toISOString(); localStorage.setItem('claude-settings', JSON.stringify({ @@ -727,6 +767,14 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: lastUpdated: now, })); + const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences', { + method: 'PUT', + body: JSON.stringify(notificationPreferences), + }); + if (!notificationResponse.ok) { + throw new Error('Failed to save notification preferences'); + } + setSaveStatus('success'); } catch (error) { console.error('Error saving settings:', error); @@ -740,6 +788,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: cursorPermissions.allowedCommands, cursorPermissions.disallowedCommands, cursorPermissions.skipPermissions, + notificationPreferences, geminiPermissionMode, projectSortOrder, ]); @@ -862,6 +911,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: setClaudePermissions, cursorPermissions, setCursorPermissions, + notificationPreferences, + setNotificationPreferences, codexPermissionMode, setCodexPermissionMode, mcpServers, diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts index eff5e13..096059c 100644 --- a/src/components/settings/types/types.ts +++ b/src/components/settings/types/types.ts @@ -1,6 +1,6 @@ import type { Dispatch, SetStateAction } from 'react'; -export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'plugins'; +export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins'; export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini'; export type AgentCategory = 'account' | 'permissions' | 'mcp'; export type ProjectSortOrder = 'name' | 'date'; @@ -106,6 +106,18 @@ export type ClaudePermissionsState = { skipPermissions: boolean; }; +export type NotificationPreferencesState = { + channels: { + inApp: boolean; + webPush: boolean; + }; + events: { + actionRequired: boolean; + stop: boolean; + error: boolean; + }; +}; + export type CursorPermissionsState = { allowedCommands: string[]; disallowedCommands: string[]; diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index a8a424d..444d0e0 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -9,9 +9,11 @@ import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab'; import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab'; import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab'; import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab'; +import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab'; import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab'; import PluginSettingsTab from '../../plugins/view/PluginSettingsTab'; import { useSettingsController } from '../hooks/useSettingsController'; +import { useWebPush } from '../../../hooks/useWebPush'; import type { SettingsProps } from '../types/types'; function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) { @@ -27,6 +29,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set updateCodeEditorSetting, claudePermissions, setClaudePermissions, + notificationPreferences, + setNotificationPreferences, cursorPermissions, setCursorPermissions, codexPermissionMode, @@ -70,6 +74,32 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set onClose, }); + const { + permission: pushPermission, + isSubscribed: isPushSubscribed, + isLoading: isPushLoading, + subscribe: pushSubscribe, + unsubscribe: pushUnsubscribe, + } = useWebPush(); + + const handleEnablePush = async () => { + await pushSubscribe(); + // Server sets webPush: true in preferences on subscribe; sync local state + setNotificationPreferences({ + ...notificationPreferences, + channels: { ...notificationPreferences.channels, webPush: true }, + }); + }; + + const handleDisablePush = async () => { + await pushUnsubscribe(); + // Server sets webPush: false in preferences on unsubscribe; sync local state + setNotificationPreferences({ + ...notificationPreferences, + channels: { ...notificationPreferences.channels, webPush: false }, + }); + }; + if (!isOpen) { return null; } @@ -161,6 +191,18 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set {activeTab === 'tasks' && } + {activeTab === 'notifications' && ( + + )} + {activeTab === 'api' && } {activeTab === 'plugins' && } diff --git a/src/components/settings/view/SettingsMainTabs.tsx b/src/components/settings/view/SettingsMainTabs.tsx index dd9bf60..d085d54 100644 --- a/src/components/settings/view/SettingsMainTabs.tsx +++ b/src/components/settings/view/SettingsMainTabs.tsx @@ -20,6 +20,7 @@ const TAB_CONFIG: MainTabConfig[] = [ { id: 'git', labelKey: 'mainTabs.git', icon: GitBranch }, { id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key }, { id: 'tasks', labelKey: 'mainTabs.tasks' }, + { id: 'notifications', labelKey: 'mainTabs.notifications' }, { id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle }, ]; @@ -28,7 +29,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa return (
-
+
{TAB_CONFIG.map((tab) => { const Icon = tab.icon; const isActive = activeTab === tab.id; @@ -39,7 +40,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa role="tab" aria-selected={isActive} onClick={() => onChange(tab.id)} - className={`border-b-2 px-4 py-3 text-sm font-medium transition-colors ${ + className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${ isActive ? 'border-blue-600 text-blue-600 dark:text-blue-400' : 'border-transparent text-muted-foreground hover:text-foreground' diff --git a/src/components/settings/view/SettingsSidebar.tsx b/src/components/settings/view/SettingsSidebar.tsx index c2e88f8..e6f56f4 100644 --- a/src/components/settings/view/SettingsSidebar.tsx +++ b/src/components/settings/view/SettingsSidebar.tsx @@ -1,4 +1,4 @@ -import { Bot, GitBranch, Key, ListChecks, Palette, Puzzle } from 'lucide-react'; +import { Bell, Bot, GitBranch, Key, ListChecks, Palette, Puzzle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { cn } from '../../../lib/utils'; import { PillBar, Pill } from '../../../shared/view/ui'; @@ -22,6 +22,7 @@ const NAV_ITEMS: NavItem[] = [ { id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key }, { id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks }, { id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle }, + { id: 'notifications', labelKey: 'mainTabs.notifications', icon: Bell }, ]; export default function SettingsSidebar({ activeTab, onChange }: SettingsSidebarProps) { diff --git a/src/components/settings/view/tabs/NotificationsSettingsTab.tsx b/src/components/settings/view/tabs/NotificationsSettingsTab.tsx new file mode 100644 index 0000000..a960740 --- /dev/null +++ b/src/components/settings/view/tabs/NotificationsSettingsTab.tsx @@ -0,0 +1,145 @@ +import { Bell, BellOff, BellRing, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { NotificationPreferencesState } from '../../types/types'; + +type NotificationsSettingsTabProps = { + notificationPreferences: NotificationPreferencesState; + onNotificationPreferencesChange: (value: NotificationPreferencesState) => void; + pushPermission: NotificationPermission | 'unsupported'; + isPushSubscribed: boolean; + isPushLoading: boolean; + onEnablePush: () => void; + onDisablePush: () => void; +}; + +export default function NotificationsSettingsTab({ + notificationPreferences, + onNotificationPreferencesChange, + pushPermission, + isPushSubscribed, + isPushLoading, + onEnablePush, + onDisablePush, +}: NotificationsSettingsTabProps) { + const { t } = useTranslation('settings'); + + const pushSupported = pushPermission !== 'unsupported'; + const pushDenied = pushPermission === 'denied'; + + return ( +
+
+
+ +

{t('notifications.title')}

+
+

{t('notifications.description')}

+
+ +
+

{t('notifications.webPush.title')}

+ {!pushSupported ? ( +

{t('notifications.webPush.unsupported')}

+ ) : pushDenied ? ( +

{t('notifications.webPush.denied')}

+ ) : ( +
+ + {isPushSubscribed && ( + + {t('notifications.webPush.enabled')} + + )} +
+ )} +
+ +
+

{t('notifications.events.title')}

+
+ + + + + +
+
+
+ ); +} diff --git a/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx b/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx index 0d575f2..fa545aa 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx @@ -255,6 +255,7 @@ function ClaudePermissions({
  • "Bash(rm:*)" {t('permissions.toolExamples.bashRm')}
  • +
    ); } diff --git a/src/hooks/useWebPush.ts b/src/hooks/useWebPush.ts new file mode 100644 index 0000000..b5e365a --- /dev/null +++ b/src/hooks/useWebPush.ts @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useState } from 'react'; +import { authenticatedFetch } from '../utils/api'; + +type WebPushState = { + permission: NotificationPermission | 'unsupported'; + isSubscribed: boolean; + isLoading: boolean; + subscribe: () => Promise; + unsubscribe: () => Promise; +}; + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +export function useWebPush(): WebPushState { + const [permission, setPermission] = useState(() => { + if (typeof window === 'undefined' || !('Notification' in window) || !('serviceWorker' in navigator)) { + return 'unsupported'; + } + return Notification.permission; + }); + const [isSubscribed, setIsSubscribed] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + // Check existing subscription on mount + useEffect(() => { + if (permission === 'unsupported') return; + + navigator.serviceWorker.ready.then((registration) => { + registration.pushManager.getSubscription().then((sub) => { + setIsSubscribed(sub !== null); + }); + }).catch(() => { + // SW not ready yet + }); + }, [permission]); + + const subscribe = useCallback(async () => { + if (permission === 'unsupported') return; + setIsLoading(true); + + try { + const perm = await Notification.requestPermission(); + setPermission(perm); + if (perm !== 'granted') return; + + const keyRes = await authenticatedFetch('/api/settings/push/vapid-public-key'); + const { publicKey } = await keyRes.json(); + + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey).buffer as ArrayBuffer, + }); + + const subJson = subscription.toJSON(); + await authenticatedFetch('/api/settings/push/subscribe', { + method: 'POST', + body: JSON.stringify({ + endpoint: subJson.endpoint, + keys: subJson.keys, + }), + }); + + setIsSubscribed(true); + } catch (err) { + console.error('Push subscribe failed:', err); + } finally { + setIsLoading(false); + } + }, [permission]); + + const unsubscribe = useCallback(async () => { + setIsLoading(true); + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + const endpoint = subscription.endpoint; + await subscription.unsubscribe(); + await authenticatedFetch('/api/settings/push/unsubscribe', { + method: 'POST', + body: JSON.stringify({ endpoint }), + }); + } + setIsSubscribed(false); + } catch (err) { + console.error('Push unsubscribe failed:', err); + } finally { + setIsLoading(false); + } + }, []); + + return { permission, isSubscribed, isLoading, subscribe, unsubscribe }; +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index f5df816..7bd6e1d 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -206,6 +206,36 @@ "failedToCreateFolder": "Failed to create folder" } }, + "notifications": { + "genericTool": "a tool", + "codes": { + "generic": { + "info": { + "title": "Notification" + } + }, + "permission": { + "required": { + "title": "Action Required", + "body": "{{toolName}} is waiting for your decision." + } + }, + "run": { + "stopped": { + "title": "Run Stopped", + "body": "Reason: {{reason}}" + }, + "failed": { + "title": "Run Failed" + } + }, + "agent": { + "notification": { + "title": "Agent Notification" + } + } + } + }, "versionUpdate": { "title": "Update Available", "newVersionReady": "A new version is ready", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 00e7eaa..fcd1c72 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -105,7 +105,28 @@ "git": "Git", "apiTokens": "API & Tokens", "tasks": "Tasks", + "notifications": "Notifications", "plugins": "Plugins" + + }, + "notifications": { + "title": "Notifications", + "description": "Control which notification events you receive.", + "webPush": { + "title": "Web Push Notifications", + "enable": "Enable Push Notifications", + "disable": "Disable Push Notifications", + "enabled": "Push notifications are enabled", + "loading": "Updating...", + "unsupported": "Push notifications are not supported in this browser.", + "denied": "Push notifications are blocked. Please allow them in your browser settings." + }, + "events": { + "title": "Event Types", + "actionRequired": "Action required", + "stop": "Run stopped", + "error": "Run failed" + } }, "appearanceSettings": { "darkMode": { diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index e8dd3c3..3625448 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -206,6 +206,36 @@ "failedToCreateFolder": "フォルダの作成に失敗しました" } }, + "notifications": { + "genericTool": "ツール", + "codes": { + "generic": { + "info": { + "title": "通知" + } + }, + "permission": { + "required": { + "title": "対応が必要です", + "body": "{{toolName}} があなたの判断を待っています。" + } + }, + "run": { + "stopped": { + "title": "実行が停止しました", + "body": "理由: {{reason}}" + }, + "failed": { + "title": "実行に失敗しました" + } + }, + "agent": { + "notification": { + "title": "エージェント通知" + } + } + } + }, "versionUpdate": { "title": "アップデートのお知らせ", "newVersionReady": "新しいバージョンが利用可能です", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 5b01a5c..60b454c 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -105,7 +105,28 @@ "git": "Git", "apiTokens": "API & トークン", "tasks": "タスク", + "notifications": "通知", "plugins": "プラグイン" + + }, + "notifications": { + "title": "通知", + "description": "受信する通知イベントを設定します。", + "webPush": { + "title": "Webプッシュ通知", + "enable": "プッシュ通知を有効にする", + "disable": "プッシュ通知を無効にする", + "enabled": "プッシュ通知は有効です", + "loading": "更新中...", + "unsupported": "このブラウザではプッシュ通知がサポートされていません。", + "denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。" + }, + "events": { + "title": "イベント種別", + "actionRequired": "対応が必要", + "stop": "実行停止", + "error": "実行失敗" + } }, "appearanceSettings": { "darkMode": { diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 980e5bc..dd0d220 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -206,6 +206,36 @@ "failedToCreateFolder": "폴더 생성 실패" } }, + "notifications": { + "genericTool": "도구", + "codes": { + "generic": { + "info": { + "title": "알림" + } + }, + "permission": { + "required": { + "title": "작업 필요", + "body": "{{toolName}} 에 대한 결정을 기다리고 있습니다." + } + }, + "run": { + "stopped": { + "title": "실행이 중지되었습니다", + "body": "사유: {{reason}}" + }, + "failed": { + "title": "실행 실패" + } + }, + "agent": { + "notification": { + "title": "에이전트 알림" + } + } + } + }, "versionUpdate": { "title": "업데이트 가능", "newVersionReady": "새 버전이 준비되었습니다", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 468f480..b8a1f45 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -105,7 +105,28 @@ "git": "Git", "apiTokens": "API & 토큰", "tasks": "작업", + "notifications": "알림", "plugins": "플러그인" + + }, + "notifications": { + "title": "알림", + "description": "수신할 알림 이벤트를 설정합니다.", + "webPush": { + "title": "웹 푸시 알림", + "enable": "푸시 알림 활성화", + "disable": "푸시 알림 비활성화", + "enabled": "푸시 알림이 활성화되었습니다", + "loading": "업데이트 중...", + "unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.", + "denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요." + }, + "events": { + "title": "이벤트 유형", + "actionRequired": "작업 필요", + "stop": "실행 중지", + "error": "실행 실패" + } }, "appearanceSettings": { "darkMode": { diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 039ba25..c54f684 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -206,6 +206,36 @@ "failedToCreateFolder": "创建文件夹失败" } }, + "notifications": { + "genericTool": "工具", + "codes": { + "generic": { + "info": { + "title": "通知" + } + }, + "permission": { + "required": { + "title": "需要处理", + "body": "{{toolName}} 正在等待你的决策。" + } + }, + "run": { + "stopped": { + "title": "运行已停止", + "body": "原因:{{reason}}" + }, + "failed": { + "title": "运行失败" + } + }, + "agent": { + "notification": { + "title": "Agent 通知" + } + } + } + }, "versionUpdate": { "title": "有可用更新", "newVersionReady": "新版本已准备就绪", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 0bd7731..d9f2b2c 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -105,7 +105,28 @@ "git": "Git", "apiTokens": "API 和令牌", "tasks": "任务", + "notifications": "通知", "plugins": "插件" + + }, + "notifications": { + "title": "通知", + "description": "控制你希望接收的通知事件。", + "webPush": { + "title": "Web 推送通知", + "enable": "启用推送通知", + "disable": "关闭推送通知", + "enabled": "推送通知已启用", + "loading": "更新中...", + "unsupported": "此浏览器不支持推送通知。", + "denied": "推送通知已被阻止,请在浏览器设置中允许。" + }, + "events": { + "title": "事件类型", + "actionRequired": "需要处理", + "stop": "运行已停止", + "error": "运行失败" + } }, "appearanceSettings": { "darkMode": { diff --git a/src/main.jsx b/src/main.jsx index cacb2db..0c88aea 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -7,14 +7,10 @@ import 'katex/dist/katex.min.css' // Initialize i18n import './i18n/config.js' -// Clean up stale service workers on app load to prevent caching issues after builds +// Register service worker for PWA + Web Push support if ('serviceWorker' in navigator) { - navigator.serviceWorker.getRegistrations().then(registrations => { - registrations.forEach(registration => { - registration.unregister(); - }); - }).catch(err => { - console.warn('Failed to unregister service workers:', err); + navigator.serviceWorker.register('/sw.js').catch(err => { + console.warn('Service worker registration failed:', err); }); }