mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-14 10:27:24 +00:00
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 <noreply@anthropic.com> Co-authored-by: Haileyesus <something@gmail.com>
This commit is contained in:
210
package-lock.json
generated
210
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
62
public/sw.js
62
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 => {
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
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);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
227
server/services/notification-orchestrator.js
Normal file
227
server/services/notification-orchestrator.js
Normal file
@@ -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
|
||||
};
|
||||
35
server/services/vapid-keys.js
Normal file
35
server/services/vapid-keys.js
Normal file
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -80,7 +80,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = 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;
|
||||
|
||||
@@ -18,6 +18,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
|
||||
'git',
|
||||
'api',
|
||||
'tasks',
|
||||
'notifications',
|
||||
];
|
||||
|
||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
@@ -204,6 +222,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => (
|
||||
createEmptyCursorPermissions()
|
||||
));
|
||||
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferencesState>(() => (
|
||||
createDefaultNotificationPreferences()
|
||||
));
|
||||
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
||||
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('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<NotificationPreferencesResponse>(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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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' && <TasksSettingsTab />}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<NotificationsSettingsTab
|
||||
notificationPreferences={notificationPreferences}
|
||||
onNotificationPreferencesChange={setNotificationPreferences}
|
||||
pushPermission={pushPermission}
|
||||
isPushSubscribed={isPushSubscribed}
|
||||
isPushLoading={isPushLoading}
|
||||
onEnablePush={handleEnablePush}
|
||||
onDisablePush={handleDisablePush}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'api' && <CredentialsSettingsTab />}
|
||||
|
||||
{activeTab === 'plugins' && <PluginSettingsTab />}
|
||||
|
||||
@@ -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 (
|
||||
<div className="border-b border-border">
|
||||
<div className="flex px-4 md:px-6" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
|
||||
<div className="flex px-4 md:px-6 overflow-x-auto scrollbar-hide" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
|
||||
{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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
145
src/components/settings/view/tabs/NotificationsSettingsTab.tsx
Normal file
145
src/components/settings/view/tabs/NotificationsSettingsTab.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-6 md:space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="w-5 h-5 text-blue-600" />
|
||||
<h3 className="text-lg font-medium text-foreground">{t('notifications.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('notifications.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
||||
<h4 className="font-medium text-foreground">{t('notifications.webPush.title')}</h4>
|
||||
{!pushSupported ? (
|
||||
<p className="text-sm text-muted-foreground">{t('notifications.webPush.unsupported')}</p>
|
||||
) : pushDenied ? (
|
||||
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPushLoading}
|
||||
onClick={() => {
|
||||
if (isPushSubscribed) {
|
||||
onDisablePush();
|
||||
} else {
|
||||
onEnablePush();
|
||||
}
|
||||
}}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isPushSubscribed
|
||||
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
{isPushLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isPushSubscribed ? (
|
||||
<BellOff className="w-4 h-4" />
|
||||
) : (
|
||||
<BellRing className="w-4 h-4" />
|
||||
)}
|
||||
{isPushLoading
|
||||
? t('notifications.webPush.loading')
|
||||
: isPushSubscribed
|
||||
? t('notifications.webPush.disable')
|
||||
: t('notifications.webPush.enable')}
|
||||
</button>
|
||||
{isPushSubscribed && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('notifications.webPush.enabled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
||||
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPreferences.events.actionRequired}
|
||||
onChange={(event) =>
|
||||
onNotificationPreferencesChange({
|
||||
...notificationPreferences,
|
||||
events: {
|
||||
...notificationPreferences.events,
|
||||
actionRequired: event.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
{t('notifications.events.actionRequired')}
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPreferences.events.stop}
|
||||
onChange={(event) =>
|
||||
onNotificationPreferencesChange({
|
||||
...notificationPreferences,
|
||||
events: {
|
||||
...notificationPreferences.events,
|
||||
stop: event.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
{t('notifications.events.stop')}
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPreferences.events.error}
|
||||
onChange={(event) =>
|
||||
onNotificationPreferencesChange({
|
||||
...notificationPreferences,
|
||||
events: {
|
||||
...notificationPreferences.events,
|
||||
error: event.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
{t('notifications.events.error')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -255,6 +255,7 @@ function ClaudePermissions({
|
||||
<li><code className="rounded bg-blue-100 px-1 dark:bg-blue-800">"Bash(rm:*)"</code> {t('permissions.toolExamples.bashRm')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
103
src/hooks/useWebPush.ts
Normal file
103
src/hooks/useWebPush.ts
Normal file
@@ -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<void>;
|
||||
unsubscribe: () => Promise<void>;
|
||||
};
|
||||
|
||||
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<NotificationPermission | 'unsupported'>(() => {
|
||||
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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "新しいバージョンが利用可能です",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "새 버전이 준비되었습니다",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "新版本已准备就绪",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
10
src/main.jsx
10
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user