mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-28 14:55:34 +08: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",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1903,9 +1904,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||||
"integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==",
|
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1920,9 +1921,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||||
"integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==",
|
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2049,9 +2050,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
"version": "0.34.3",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||||
"integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==",
|
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2067,13 +2068,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.0"
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
"version": "0.34.3",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||||
"integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==",
|
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2089,7 +2090,7 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.0"
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-wasm32": {
|
"node_modules/@img/sharp-wasm32": {
|
||||||
@@ -2113,9 +2114,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-arm64": {
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
"version": "0.34.3",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||||
"integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==",
|
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -4610,7 +4611,6 @@
|
|||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
@@ -4916,6 +4916,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/ast-types": {
|
||||||
"version": "0.13.4",
|
"version": "0.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||||
@@ -5178,6 +5190,12 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"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": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||||
@@ -8952,6 +8970,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/http-cache-semantics": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||||
@@ -8993,7 +9020,6 @@
|
|||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"agent-base": "^7.1.2",
|
"agent-base": "^7.1.2",
|
||||||
@@ -11751,6 +11777,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
@@ -14769,6 +14801,40 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"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": {
|
"node_modules/sharp/node_modules/@img/sharp-linux-arm": {
|
||||||
"version": "0.34.3",
|
"version": "0.34.3",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz",
|
"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"
|
"@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": {
|
"node_modules/sharp/node_modules/@img/sharp-win32-x64": {
|
||||||
"version": "0.34.3",
|
"version": "0.34.3",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz",
|
"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"
|
"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": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
60
public/sw.js
60
public/sw.js
@@ -19,14 +19,17 @@ self.addEventListener('install', event => {
|
|||||||
|
|
||||||
// Fetch event
|
// Fetch event
|
||||||
self.addEventListener('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(
|
event.respondWith(
|
||||||
caches.match(event.request)
|
caches.match(event.request)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
// Return cached response if found
|
|
||||||
if (response) {
|
if (response) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
// Otherwise fetch from network
|
|
||||||
return fetch(event.request);
|
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 path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||||
|
import {
|
||||||
|
createNotificationEvent,
|
||||||
|
notifyRunFailed,
|
||||||
|
notifyRunStopped,
|
||||||
|
notifyUserIfEnabled
|
||||||
|
} from './services/notification-orchestrator.js';
|
||||||
|
|
||||||
const activeSessions = new Map();
|
const activeSessions = new Map();
|
||||||
const pendingToolApprovals = new Map();
|
const pendingToolApprovals = new Map();
|
||||||
@@ -461,12 +467,20 @@ async function loadMcpConfig(cwd) {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function queryClaudeSDK(command, options = {}, ws) {
|
async function queryClaudeSDK(command, options = {}, ws) {
|
||||||
const { sessionId } = options;
|
const { sessionId, sessionSummary } = options;
|
||||||
let capturedSessionId = sessionId;
|
let capturedSessionId = sessionId;
|
||||||
let sessionCreatedSent = false;
|
let sessionCreatedSent = false;
|
||||||
let tempImagePaths = [];
|
let tempImagePaths = [];
|
||||||
let tempDir = null;
|
let tempDir = null;
|
||||||
|
|
||||||
|
const emitNotification = (event) => {
|
||||||
|
notifyUserIfEnabled({
|
||||||
|
userId: ws?.userId || null,
|
||||||
|
writer: ws,
|
||||||
|
event
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Map CLI options to SDK format
|
// Map CLI options to SDK format
|
||||||
const sdkOptions = mapCliOptionsToSDK(options);
|
const sdkOptions = mapCliOptionsToSDK(options);
|
||||||
@@ -483,6 +497,26 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
tempImagePaths = imageResult.tempImagePaths;
|
tempImagePaths = imageResult.tempImagePaths;
|
||||||
tempDir = imageResult.tempDir;
|
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) => {
|
sdkOptions.canUseTool = async (toolName, input, context) => {
|
||||||
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
||||||
|
|
||||||
@@ -514,6 +548,16 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
input,
|
input,
|
||||||
sessionId: capturedSessionId || sessionId || null
|
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, {
|
const decision = await waitForToolApproval(requestId, {
|
||||||
timeoutMs: requiresInteraction ? 0 : undefined,
|
timeoutMs: requiresInteraction ? 0 : undefined,
|
||||||
@@ -560,10 +604,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
||||||
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
|
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
|
||||||
|
|
||||||
const queryInstance = query({
|
let queryInstance;
|
||||||
prompt: finalCommand,
|
try {
|
||||||
options: sdkOptions
|
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
|
// Restore immediately — Query constructor already captured the value
|
||||||
if (prevStreamTimeout !== undefined) {
|
if (prevStreamTimeout !== undefined) {
|
||||||
@@ -647,6 +703,13 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
isNewSession: !sessionId && !!command
|
isNewSession: !sessionId && !!command
|
||||||
});
|
});
|
||||||
|
notifyRunStopped({
|
||||||
|
userId: ws?.userId || null,
|
||||||
|
provider: 'claude',
|
||||||
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
|
sessionName: sessionSummary,
|
||||||
|
stopReason: 'completed'
|
||||||
|
});
|
||||||
console.log('claude-complete event sent');
|
console.log('claude-complete event sent');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -666,6 +729,13 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
error: error.message,
|
error: error.message,
|
||||||
sessionId: capturedSessionId || sessionId || null
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
|
notifyRunFailed({
|
||||||
|
userId: ws?.userId || null,
|
||||||
|
provider: 'claude',
|
||||||
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
|
sessionName: sessionSummary,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import crossSpawn from 'cross-spawn';
|
import crossSpawn from 'cross-spawn';
|
||||||
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
|
|
||||||
// Use cross-spawn on Windows for better command execution
|
// Use cross-spawn on Windows for better command execution
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
@@ -23,7 +24,7 @@ function isWorkspaceTrustPrompt(text = '') {
|
|||||||
|
|
||||||
async function spawnCursor(command, options = {}, ws) {
|
async function spawnCursor(command, options = {}, ws) {
|
||||||
return new Promise(async (resolve, reject) => {
|
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 capturedSessionId = sessionId; // Track session ID throughout the process
|
||||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||||
let hasRetriedWithTrust = false;
|
let hasRetriedWithTrust = false;
|
||||||
@@ -81,6 +82,35 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
const isTrustRetry = runReason === 'trust-retry';
|
const isTrustRetry = runReason === 'trust-retry';
|
||||||
let runSawWorkspaceTrustPrompt = false;
|
let runSawWorkspaceTrustPrompt = false;
|
||||||
let stdoutLineBuffer = '';
|
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) {
|
if (isTrustRetry) {
|
||||||
console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
|
console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
|
||||||
@@ -255,7 +285,8 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
ws.send({
|
ws.send({
|
||||||
type: 'cursor-error',
|
type: 'cursor-error',
|
||||||
error: stderrText,
|
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',
|
type: 'claude-complete',
|
||||||
sessionId: finalSessionId,
|
sessionId: finalSessionId,
|
||||||
exitCode: code,
|
exitCode: code,
|
||||||
|
provider: 'cursor',
|
||||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
||||||
});
|
});
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
|
notifyTerminalState({ code });
|
||||||
settleOnce(() => resolve());
|
settleOnce(() => resolve());
|
||||||
} else {
|
} else {
|
||||||
|
notifyTerminalState({ code });
|
||||||
settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`)));
|
settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`)));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -308,8 +342,10 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
ws.send({
|
ws.send({
|
||||||
type: 'cursor-error',
|
type: 'cursor-error',
|
||||||
error: error.message,
|
error: error.message,
|
||||||
sessionId: capturedSessionId || sessionId || null
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
|
provider: 'cursor'
|
||||||
});
|
});
|
||||||
|
notifyTerminalState({ error });
|
||||||
|
|
||||||
settleOnce(() => reject(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('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)
|
// Create app_config table if it doesn't exist (for existing installations)
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
|
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
|
||||||
key TEXT PRIMARY KEY,
|
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
|
// Session custom names database operations
|
||||||
const sessionNamesDb = {
|
const sessionNamesDb = {
|
||||||
// Set (insert or update) a custom session name
|
// Set (insert or update) a custom session name
|
||||||
@@ -482,6 +621,8 @@ export {
|
|||||||
userDb,
|
userDb,
|
||||||
apiKeysDb,
|
apiKeysDb,
|
||||||
credentialsDb,
|
credentialsDb,
|
||||||
|
notificationPreferencesDb,
|
||||||
|
pushSubscriptionsDb,
|
||||||
sessionNamesDb,
|
sessionNamesDb,
|
||||||
applyCustomSessionNames,
|
applyCustomSessionNames,
|
||||||
appConfigDb,
|
appConfigDb,
|
||||||
|
|||||||
@@ -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_type ON user_credentials(credential_type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
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)
|
-- Session custom names (provider-agnostic display name overrides)
|
||||||
CREATE TABLE IF NOT EXISTS session_names (
|
CREATE TABLE IF NOT EXISTS session_names (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import os from 'os';
|
|||||||
import { getSessions, getSessionMessages } from './projects.js';
|
import { getSessions, getSessionMessages } from './projects.js';
|
||||||
import sessionManager from './sessionManager.js';
|
import sessionManager from './sessionManager.js';
|
||||||
import GeminiResponseHandler from './gemini-response-handler.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
|
let activeGeminiProcesses = new Map(); // Track active processes by session ID
|
||||||
|
|
||||||
async function spawnGemini(command, options = {}, ws) {
|
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 capturedSessionId = sessionId; // Track session ID throughout the process
|
||||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||||
let assistantBlocks = []; // Accumulate the full response blocks including tools
|
let assistantBlocks = []; // Accumulate the full response blocks including tools
|
||||||
@@ -172,6 +173,36 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
env: { ...process.env } // Inherit all environment variables
|
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
|
// Attach temp file info to process for cleanup later
|
||||||
geminiProcess.tempImagePaths = tempImagePaths;
|
geminiProcess.tempImagePaths = tempImagePaths;
|
||||||
@@ -196,10 +227,12 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey);
|
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey);
|
||||||
|
terminalFailureReason = `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`;
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'gemini-error',
|
type: 'gemini-error',
|
||||||
sessionId: socketSessionId,
|
sessionId: socketSessionId,
|
||||||
error: `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`
|
error: terminalFailureReason,
|
||||||
|
provider: 'gemini'
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
geminiProcess.kill('SIGTERM');
|
geminiProcess.kill('SIGTERM');
|
||||||
@@ -340,7 +373,8 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
ws.send({
|
ws.send({
|
||||||
type: 'gemini-error',
|
type: 'gemini-error',
|
||||||
sessionId: socketSessionId,
|
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
|
type: 'claude-complete', // Use claude-complete for compatibility with UI
|
||||||
sessionId: finalSessionId,
|
sessionId: finalSessionId,
|
||||||
exitCode: code,
|
exitCode: code,
|
||||||
|
provider: 'gemini',
|
||||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -381,8 +416,13 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
|
notifyTerminalState({ code });
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} 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}`));
|
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({
|
ws.send({
|
||||||
type: 'gemini-error',
|
type: 'gemini-error',
|
||||||
sessionId: errorSessionId,
|
sessionId: errorSessionId,
|
||||||
error: error.message
|
error: error.message,
|
||||||
|
provider: 'gemini'
|
||||||
});
|
});
|
||||||
|
notifyTerminalState({ error });
|
||||||
|
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import geminiRoutes from './routes/gemini.js';
|
|||||||
import pluginsRoutes from './routes/plugins.js';
|
import pluginsRoutes from './routes/plugins.js';
|
||||||
import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js';
|
import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js';
|
||||||
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.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 { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||||
import { IS_PLATFORM } from './constants/config.js';
|
import { IS_PLATFORM } from './constants/config.js';
|
||||||
|
|
||||||
@@ -1406,7 +1407,7 @@ wss.on('connection', (ws, request) => {
|
|||||||
if (pathname === '/shell') {
|
if (pathname === '/shell') {
|
||||||
handleShellConnection(ws);
|
handleShellConnection(ws);
|
||||||
} else if (pathname === '/ws') {
|
} else if (pathname === '/ws') {
|
||||||
handleChatConnection(ws);
|
handleChatConnection(ws, request);
|
||||||
} else {
|
} else {
|
||||||
console.log('[WARN] Unknown WebSocket path:', pathname);
|
console.log('[WARN] Unknown WebSocket path:', pathname);
|
||||||
ws.close();
|
ws.close();
|
||||||
@@ -1417,9 +1418,10 @@ wss.on('connection', (ws, request) => {
|
|||||||
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
||||||
*/
|
*/
|
||||||
class WebSocketWriter {
|
class WebSocketWriter {
|
||||||
constructor(ws) {
|
constructor(ws, userId = null) {
|
||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
this.sessionId = null;
|
this.sessionId = null;
|
||||||
|
this.userId = userId;
|
||||||
this.isWebSocketWriter = true; // Marker for transport detection
|
this.isWebSocketWriter = true; // Marker for transport detection
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1444,14 +1446,14 @@ class WebSocketWriter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle chat WebSocket connections
|
// Handle chat WebSocket connections
|
||||||
function handleChatConnection(ws) {
|
function handleChatConnection(ws, request) {
|
||||||
console.log('[INFO] Chat WebSocket connected');
|
console.log('[INFO] Chat WebSocket connected');
|
||||||
|
|
||||||
// Add to connected clients for project updates
|
// Add to connected clients for project updates
|
||||||
connectedClients.add(ws);
|
connectedClients.add(ws);
|
||||||
|
|
||||||
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
|
// 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) => {
|
ws.on('message', async (message) => {
|
||||||
try {
|
try {
|
||||||
@@ -2500,6 +2502,9 @@ async function startServer() {
|
|||||||
// Initialize authentication database
|
// Initialize authentication database
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
|
|
||||||
|
// Configure Web Push (VAPID keys)
|
||||||
|
configureWebPush();
|
||||||
|
|
||||||
// Check if running in production mode (dist folder exists)
|
// Check if running in production mode (dist folder exists)
|
||||||
const distIndexPath = path.join(__dirname, '../dist/index.html');
|
const distIndexPath = path.join(__dirname, '../dist/index.html');
|
||||||
const isProduction = fs.existsSync(distIndexPath);
|
const isProduction = fs.existsSync(distIndexPath);
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ const authenticateWebSocket = (token) => {
|
|||||||
try {
|
try {
|
||||||
const user = userDb.getFirstUser();
|
const user = userDb.getFirstUser();
|
||||||
if (user) {
|
if (user) {
|
||||||
return { userId: user.id, username: user.username };
|
return { id: user.id, userId: user.id, username: user.username };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Codex } from '@openai/codex-sdk';
|
import { Codex } from '@openai/codex-sdk';
|
||||||
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
|
|
||||||
// Track active sessions
|
// Track active sessions
|
||||||
const activeCodexSessions = new Map();
|
const activeCodexSessions = new Map();
|
||||||
@@ -191,6 +192,7 @@ function mapPermissionModeToCodexOptions(permissionMode) {
|
|||||||
export async function queryCodex(command, options = {}, ws) {
|
export async function queryCodex(command, options = {}, ws) {
|
||||||
const {
|
const {
|
||||||
sessionId,
|
sessionId,
|
||||||
|
sessionSummary,
|
||||||
cwd,
|
cwd,
|
||||||
projectPath,
|
projectPath,
|
||||||
model,
|
model,
|
||||||
@@ -203,6 +205,7 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
let codex;
|
let codex;
|
||||||
let thread;
|
let thread;
|
||||||
let currentSessionId = sessionId;
|
let currentSessionId = sessionId;
|
||||||
|
let terminalFailure = null;
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -268,6 +271,17 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
sessionId: currentSessionId
|
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)
|
// Extract and send token usage if available (normalized to match Claude format)
|
||||||
if (event.type === 'turn.completed' && event.usage) {
|
if (event.type === 'turn.completed' && event.usage) {
|
||||||
const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
|
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
|
// Send completion event
|
||||||
sendMessage(ws, {
|
if (!terminalFailure) {
|
||||||
type: 'codex-complete',
|
sendMessage(ws, {
|
||||||
sessionId: currentSessionId,
|
type: 'codex-complete',
|
||||||
actualSessionId: thread.id
|
sessionId: currentSessionId,
|
||||||
});
|
actualSessionId: thread.id,
|
||||||
|
provider: 'codex'
|
||||||
|
});
|
||||||
|
notifyRunStopped({
|
||||||
|
userId: ws?.userId || null,
|
||||||
|
provider: 'codex',
|
||||||
|
sessionId: currentSessionId,
|
||||||
|
sessionName: sessionSummary,
|
||||||
|
stopReason: 'completed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null;
|
const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null;
|
||||||
@@ -301,8 +325,18 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
sendMessage(ws, {
|
sendMessage(ws, {
|
||||||
type: 'codex-error',
|
type: 'codex-error',
|
||||||
error: error.message,
|
error: error.message,
|
||||||
sessionId: currentSessionId
|
sessionId: currentSessionId,
|
||||||
|
provider: 'codex'
|
||||||
});
|
});
|
||||||
|
if (!terminalFailure) {
|
||||||
|
notifyRunFailed({
|
||||||
|
userId: ws?.userId || null,
|
||||||
|
provider: 'codex',
|
||||||
|
sessionId: currentSessionId,
|
||||||
|
sessionName: sessionSummary,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -450,9 +450,10 @@ async function cleanupProject(projectPath, sessionId = null) {
|
|||||||
* SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events
|
* SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events
|
||||||
*/
|
*/
|
||||||
class SSEStreamWriter {
|
class SSEStreamWriter {
|
||||||
constructor(res) {
|
constructor(res, userId = null) {
|
||||||
this.res = res;
|
this.res = res;
|
||||||
this.sessionId = null;
|
this.sessionId = null;
|
||||||
|
this.userId = userId;
|
||||||
this.isSSEStreamWriter = true; // Marker for transport detection
|
this.isSSEStreamWriter = true; // Marker for transport detection
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,9 +486,10 @@ class SSEStreamWriter {
|
|||||||
* Non-streaming response collector
|
* Non-streaming response collector
|
||||||
*/
|
*/
|
||||||
class ResponseCollector {
|
class ResponseCollector {
|
||||||
constructor() {
|
constructor(userId = null) {
|
||||||
this.messages = [];
|
this.messages = [];
|
||||||
this.sessionId = null;
|
this.sessionId = null;
|
||||||
|
this.userId = userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
send(data) {
|
send(data) {
|
||||||
@@ -920,7 +922,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
||||||
|
|
||||||
writer = new SSEStreamWriter(res);
|
writer = new SSEStreamWriter(res, req.user.id);
|
||||||
|
|
||||||
// Send initial status
|
// Send initial status
|
||||||
writer.send({
|
writer.send({
|
||||||
@@ -930,7 +932,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Non-streaming mode: collect messages
|
// Non-streaming mode: collect messages
|
||||||
writer = new ResponseCollector();
|
writer = new ResponseCollector(req.user.id);
|
||||||
|
|
||||||
// Collect initial status message
|
// Collect initial status message
|
||||||
writer.send({
|
writer.send({
|
||||||
@@ -1219,7 +1221,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|||||||
res.setHeader('Cache-Control', 'no-cache');
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('X-Accel-Buffering', 'no');
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
writer = new SSEStreamWriter(res);
|
writer = new SSEStreamWriter(res, req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.writableEnded) {
|
if (!res.writableEnded) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import express from 'express';
|
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();
|
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;
|
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]);
|
}, [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
|
// Permission recovery: query pending permissions on WebSocket reconnect or session change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isReconnect = isConnected && !wasConnectedRef.current;
|
const isReconnect = isConnected && !wasConnectedRef.current;
|
||||||
|
|||||||
@@ -82,6 +82,24 @@ const createFakeSubmitEvent = () => {
|
|||||||
const isTemporarySessionId = (sessionId: string | null | undefined) =>
|
const isTemporarySessionId = (sessionId: string | null | undefined) =>
|
||||||
Boolean(sessionId && sessionId.startsWith('new-session-'));
|
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({
|
export function useChatComposerState({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -603,6 +621,7 @@ export function useChatComposerState({
|
|||||||
|
|
||||||
const toolsSettings = getToolsSettings();
|
const toolsSettings = getToolsSettings();
|
||||||
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
||||||
|
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
|
||||||
|
|
||||||
if (provider === 'cursor') {
|
if (provider === 'cursor') {
|
||||||
sendMessage({
|
sendMessage({
|
||||||
@@ -616,6 +635,7 @@ export function useChatComposerState({
|
|||||||
resume: Boolean(effectiveSessionId),
|
resume: Boolean(effectiveSessionId),
|
||||||
model: cursorModel,
|
model: cursorModel,
|
||||||
skipPermissions: toolsSettings?.skipPermissions || false,
|
skipPermissions: toolsSettings?.skipPermissions || false,
|
||||||
|
sessionSummary,
|
||||||
toolsSettings,
|
toolsSettings,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -630,6 +650,7 @@ export function useChatComposerState({
|
|||||||
sessionId: effectiveSessionId,
|
sessionId: effectiveSessionId,
|
||||||
resume: Boolean(effectiveSessionId),
|
resume: Boolean(effectiveSessionId),
|
||||||
model: codexModel,
|
model: codexModel,
|
||||||
|
sessionSummary,
|
||||||
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
|
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -644,6 +665,7 @@ export function useChatComposerState({
|
|||||||
sessionId: effectiveSessionId,
|
sessionId: effectiveSessionId,
|
||||||
resume: Boolean(effectiveSessionId),
|
resume: Boolean(effectiveSessionId),
|
||||||
model: geminiModel,
|
model: geminiModel,
|
||||||
|
sessionSummary,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
toolsSettings,
|
toolsSettings,
|
||||||
},
|
},
|
||||||
@@ -660,6 +682,7 @@ export function useChatComposerState({
|
|||||||
toolsSettings,
|
toolsSettings,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
model: claudeModel,
|
model: claudeModel,
|
||||||
|
sessionSummary,
|
||||||
images: uploadedImages,
|
images: uploadedImages,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -681,6 +704,7 @@ export function useChatComposerState({
|
|||||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
selectedSession,
|
||||||
attachedImages,
|
attachedImages,
|
||||||
claudeModel,
|
claudeModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
@@ -697,7 +721,6 @@ export function useChatComposerState({
|
|||||||
resetCommandMenuState,
|
resetCommandMenuState,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession?.id,
|
|
||||||
sendMessage,
|
sendMessage,
|
||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
setChatMessages,
|
setChatMessages,
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
}
|
}
|
||||||
}, [displayConfig, parsedData, onFileOpen]);
|
}, [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 (isSubagentContainer && subagentState) {
|
||||||
if (mode === 'result') {
|
if (mode === 'result') {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
|
|||||||
'git',
|
'git',
|
||||||
'api',
|
'api',
|
||||||
'tasks',
|
'tasks',
|
||||||
|
'notifications',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
|
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
McpServer,
|
McpServer,
|
||||||
McpToolsResult,
|
McpToolsResult,
|
||||||
McpTestResult,
|
McpTestResult,
|
||||||
|
NotificationPreferencesState,
|
||||||
ProjectSortOrder,
|
ProjectSortOrder,
|
||||||
SettingsMainTab,
|
SettingsMainTab,
|
||||||
SettingsProject,
|
SettingsProject,
|
||||||
@@ -96,9 +97,14 @@ type CodexSettingsStorage = {
|
|||||||
permissionMode?: CodexPermissionMode;
|
permissionMode?: CodexPermissionMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NotificationPreferencesResponse = {
|
||||||
|
success?: boolean;
|
||||||
|
preferences?: NotificationPreferencesState;
|
||||||
|
};
|
||||||
|
|
||||||
type ActiveLoginProvider = AgentProvider | '';
|
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 => {
|
const normalizeMainTab = (tab: string): SettingsMainTab => {
|
||||||
// Keep backwards compatibility with older callers that still pass "tools".
|
// Keep backwards compatibility with older callers that still pass "tools".
|
||||||
@@ -186,6 +192,18 @@ const createEmptyCursorPermissions = (): CursorPermissionsState => ({
|
|||||||
...DEFAULT_CURSOR_PERMISSIONS,
|
...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) {
|
export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) {
|
||||||
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
||||||
const closeTimerRef = useRef<number | null>(null);
|
const closeTimerRef = useRef<number | null>(null);
|
||||||
@@ -204,6 +222,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => (
|
const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => (
|
||||||
createEmptyCursorPermissions()
|
createEmptyCursorPermissions()
|
||||||
));
|
));
|
||||||
|
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferencesState>(() => (
|
||||||
|
createDefaultNotificationPreferences()
|
||||||
|
));
|
||||||
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
||||||
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
|
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
|
||||||
|
|
||||||
@@ -670,6 +691,22 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
);
|
);
|
||||||
setGeminiPermissionMode(savedGeminiSettings.permissionMode || 'default');
|
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([
|
await Promise.all([
|
||||||
fetchMcpServers(),
|
fetchMcpServers(),
|
||||||
fetchCursorMcpServers(),
|
fetchCursorMcpServers(),
|
||||||
@@ -679,6 +716,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
console.error('Error loading settings:', error);
|
console.error('Error loading settings:', error);
|
||||||
setClaudePermissions(createEmptyClaudePermissions());
|
setClaudePermissions(createEmptyClaudePermissions());
|
||||||
setCursorPermissions(createEmptyCursorPermissions());
|
setCursorPermissions(createEmptyCursorPermissions());
|
||||||
|
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||||
setCodexPermissionMode('default');
|
setCodexPermissionMode('default');
|
||||||
setProjectSortOrder('name');
|
setProjectSortOrder('name');
|
||||||
}
|
}
|
||||||
@@ -699,7 +737,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
void checkAuthStatus(loginProvider);
|
void checkAuthStatus(loginProvider);
|
||||||
}, [checkAuthStatus, loginProvider]);
|
}, [checkAuthStatus, loginProvider]);
|
||||||
|
|
||||||
const saveSettings = useCallback(() => {
|
const saveSettings = useCallback(async () => {
|
||||||
|
setSaveStatus(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
localStorage.setItem('claude-settings', JSON.stringify({
|
localStorage.setItem('claude-settings', JSON.stringify({
|
||||||
@@ -727,6 +767,14 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
lastUpdated: now,
|
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');
|
setSaveStatus('success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving settings:', error);
|
console.error('Error saving settings:', error);
|
||||||
@@ -740,6 +788,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
cursorPermissions.allowedCommands,
|
cursorPermissions.allowedCommands,
|
||||||
cursorPermissions.disallowedCommands,
|
cursorPermissions.disallowedCommands,
|
||||||
cursorPermissions.skipPermissions,
|
cursorPermissions.skipPermissions,
|
||||||
|
notificationPreferences,
|
||||||
geminiPermissionMode,
|
geminiPermissionMode,
|
||||||
projectSortOrder,
|
projectSortOrder,
|
||||||
]);
|
]);
|
||||||
@@ -862,6 +911,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
setClaudePermissions,
|
setClaudePermissions,
|
||||||
cursorPermissions,
|
cursorPermissions,
|
||||||
setCursorPermissions,
|
setCursorPermissions,
|
||||||
|
notificationPreferences,
|
||||||
|
setNotificationPreferences,
|
||||||
codexPermissionMode,
|
codexPermissionMode,
|
||||||
setCodexPermissionMode,
|
setCodexPermissionMode,
|
||||||
mcpServers,
|
mcpServers,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
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 AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
|
||||||
export type AgentCategory = 'account' | 'permissions' | 'mcp';
|
export type AgentCategory = 'account' | 'permissions' | 'mcp';
|
||||||
export type ProjectSortOrder = 'name' | 'date';
|
export type ProjectSortOrder = 'name' | 'date';
|
||||||
@@ -106,6 +106,18 @@ export type ClaudePermissionsState = {
|
|||||||
skipPermissions: boolean;
|
skipPermissions: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NotificationPreferencesState = {
|
||||||
|
channels: {
|
||||||
|
inApp: boolean;
|
||||||
|
webPush: boolean;
|
||||||
|
};
|
||||||
|
events: {
|
||||||
|
actionRequired: boolean;
|
||||||
|
stop: boolean;
|
||||||
|
error: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type CursorPermissionsState = {
|
export type CursorPermissionsState = {
|
||||||
allowedCommands: string[];
|
allowedCommands: string[];
|
||||||
disallowedCommands: string[];
|
disallowedCommands: string[];
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
|
|||||||
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
||||||
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
|
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
|
||||||
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
||||||
|
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
|
||||||
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
||||||
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
|
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
|
||||||
import { useSettingsController } from '../hooks/useSettingsController';
|
import { useSettingsController } from '../hooks/useSettingsController';
|
||||||
|
import { useWebPush } from '../../../hooks/useWebPush';
|
||||||
import type { SettingsProps } from '../types/types';
|
import type { SettingsProps } from '../types/types';
|
||||||
|
|
||||||
function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) {
|
function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) {
|
||||||
@@ -27,6 +29,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
updateCodeEditorSetting,
|
updateCodeEditorSetting,
|
||||||
claudePermissions,
|
claudePermissions,
|
||||||
setClaudePermissions,
|
setClaudePermissions,
|
||||||
|
notificationPreferences,
|
||||||
|
setNotificationPreferences,
|
||||||
cursorPermissions,
|
cursorPermissions,
|
||||||
setCursorPermissions,
|
setCursorPermissions,
|
||||||
codexPermissionMode,
|
codexPermissionMode,
|
||||||
@@ -70,6 +74,32 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
onClose,
|
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) {
|
if (!isOpen) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -161,6 +191,18 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
|
|
||||||
{activeTab === 'tasks' && <TasksSettingsTab />}
|
{activeTab === 'tasks' && <TasksSettingsTab />}
|
||||||
|
|
||||||
|
{activeTab === 'notifications' && (
|
||||||
|
<NotificationsSettingsTab
|
||||||
|
notificationPreferences={notificationPreferences}
|
||||||
|
onNotificationPreferencesChange={setNotificationPreferences}
|
||||||
|
pushPermission={pushPermission}
|
||||||
|
isPushSubscribed={isPushSubscribed}
|
||||||
|
isPushLoading={isPushLoading}
|
||||||
|
onEnablePush={handleEnablePush}
|
||||||
|
onDisablePush={handleDisablePush}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'api' && <CredentialsSettingsTab />}
|
{activeTab === 'api' && <CredentialsSettingsTab />}
|
||||||
|
|
||||||
{activeTab === 'plugins' && <PluginSettingsTab />}
|
{activeTab === 'plugins' && <PluginSettingsTab />}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const TAB_CONFIG: MainTabConfig[] = [
|
|||||||
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
|
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
|
||||||
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
||||||
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
|
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
|
||||||
|
{ id: 'notifications', labelKey: 'mainTabs.notifications' },
|
||||||
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
|
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border">
|
<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) => {
|
{TAB_CONFIG.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
const isActive = activeTab === tab.id;
|
const isActive = activeTab === tab.id;
|
||||||
@@ -39,7 +40,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
onClick={() => onChange(tab.id)}
|
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
|
isActive
|
||||||
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
: '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 { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from '../../../lib/utils';
|
||||||
import { PillBar, Pill } from '../../../shared/view/ui';
|
import { PillBar, Pill } from '../../../shared/view/ui';
|
||||||
@@ -22,6 +22,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
||||||
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
|
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
|
||||||
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
|
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
|
||||||
|
{ id: 'notifications', labelKey: 'mainTabs.notifications', icon: Bell },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsSidebar({ activeTab, onChange }: SettingsSidebarProps) {
|
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>
|
<li><code className="rounded bg-blue-100 px-1 dark:bg-blue-800">"Bash(rm:*)"</code> {t('permissions.toolExamples.bashRm')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</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"
|
"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": {
|
"versionUpdate": {
|
||||||
"title": "Update Available",
|
"title": "Update Available",
|
||||||
"newVersionReady": "A new version is ready",
|
"newVersionReady": "A new version is ready",
|
||||||
|
|||||||
@@ -105,7 +105,28 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & Tokens",
|
"apiTokens": "API & Tokens",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks",
|
||||||
|
"notifications": "Notifications",
|
||||||
"plugins": "Plugins"
|
"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": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
|
|||||||
@@ -206,6 +206,36 @@
|
|||||||
"failedToCreateFolder": "フォルダの作成に失敗しました"
|
"failedToCreateFolder": "フォルダの作成に失敗しました"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"genericTool": "ツール",
|
||||||
|
"codes": {
|
||||||
|
"generic": {
|
||||||
|
"info": {
|
||||||
|
"title": "通知"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"required": {
|
||||||
|
"title": "対応が必要です",
|
||||||
|
"body": "{{toolName}} があなたの判断を待っています。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"stopped": {
|
||||||
|
"title": "実行が停止しました",
|
||||||
|
"body": "理由: {{reason}}"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"title": "実行に失敗しました"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"notification": {
|
||||||
|
"title": "エージェント通知"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"versionUpdate": {
|
"versionUpdate": {
|
||||||
"title": "アップデートのお知らせ",
|
"title": "アップデートのお知らせ",
|
||||||
"newVersionReady": "新しいバージョンが利用可能です",
|
"newVersionReady": "新しいバージョンが利用可能です",
|
||||||
|
|||||||
@@ -105,7 +105,28 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & トークン",
|
"apiTokens": "API & トークン",
|
||||||
"tasks": "タスク",
|
"tasks": "タスク",
|
||||||
|
"notifications": "通知",
|
||||||
"plugins": "プラグイン"
|
"plugins": "プラグイン"
|
||||||
|
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "通知",
|
||||||
|
"description": "受信する通知イベントを設定します。",
|
||||||
|
"webPush": {
|
||||||
|
"title": "Webプッシュ通知",
|
||||||
|
"enable": "プッシュ通知を有効にする",
|
||||||
|
"disable": "プッシュ通知を無効にする",
|
||||||
|
"enabled": "プッシュ通知は有効です",
|
||||||
|
"loading": "更新中...",
|
||||||
|
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
||||||
|
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "イベント種別",
|
||||||
|
"actionRequired": "対応が必要",
|
||||||
|
"stop": "実行停止",
|
||||||
|
"error": "実行失敗"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
|
|||||||
@@ -206,6 +206,36 @@
|
|||||||
"failedToCreateFolder": "폴더 생성 실패"
|
"failedToCreateFolder": "폴더 생성 실패"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"genericTool": "도구",
|
||||||
|
"codes": {
|
||||||
|
"generic": {
|
||||||
|
"info": {
|
||||||
|
"title": "알림"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"required": {
|
||||||
|
"title": "작업 필요",
|
||||||
|
"body": "{{toolName}} 에 대한 결정을 기다리고 있습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"stopped": {
|
||||||
|
"title": "실행이 중지되었습니다",
|
||||||
|
"body": "사유: {{reason}}"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"title": "실행 실패"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"notification": {
|
||||||
|
"title": "에이전트 알림"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"versionUpdate": {
|
"versionUpdate": {
|
||||||
"title": "업데이트 가능",
|
"title": "업데이트 가능",
|
||||||
"newVersionReady": "새 버전이 준비되었습니다",
|
"newVersionReady": "새 버전이 준비되었습니다",
|
||||||
|
|||||||
@@ -105,7 +105,28 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & 토큰",
|
"apiTokens": "API & 토큰",
|
||||||
"tasks": "작업",
|
"tasks": "작업",
|
||||||
|
"notifications": "알림",
|
||||||
"plugins": "플러그인"
|
"plugins": "플러그인"
|
||||||
|
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "알림",
|
||||||
|
"description": "수신할 알림 이벤트를 설정합니다.",
|
||||||
|
"webPush": {
|
||||||
|
"title": "웹 푸시 알림",
|
||||||
|
"enable": "푸시 알림 활성화",
|
||||||
|
"disable": "푸시 알림 비활성화",
|
||||||
|
"enabled": "푸시 알림이 활성화되었습니다",
|
||||||
|
"loading": "업데이트 중...",
|
||||||
|
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
||||||
|
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "이벤트 유형",
|
||||||
|
"actionRequired": "작업 필요",
|
||||||
|
"stop": "실행 중지",
|
||||||
|
"error": "실행 실패"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
|
|||||||
@@ -206,6 +206,36 @@
|
|||||||
"failedToCreateFolder": "创建文件夹失败"
|
"failedToCreateFolder": "创建文件夹失败"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"genericTool": "工具",
|
||||||
|
"codes": {
|
||||||
|
"generic": {
|
||||||
|
"info": {
|
||||||
|
"title": "通知"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"required": {
|
||||||
|
"title": "需要处理",
|
||||||
|
"body": "{{toolName}} 正在等待你的决策。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"stopped": {
|
||||||
|
"title": "运行已停止",
|
||||||
|
"body": "原因:{{reason}}"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"title": "运行失败"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"notification": {
|
||||||
|
"title": "Agent 通知"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"versionUpdate": {
|
"versionUpdate": {
|
||||||
"title": "有可用更新",
|
"title": "有可用更新",
|
||||||
"newVersionReady": "新版本已准备就绪",
|
"newVersionReady": "新版本已准备就绪",
|
||||||
|
|||||||
@@ -105,7 +105,28 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API 和令牌",
|
"apiTokens": "API 和令牌",
|
||||||
"tasks": "任务",
|
"tasks": "任务",
|
||||||
|
"notifications": "通知",
|
||||||
"plugins": "插件"
|
"plugins": "插件"
|
||||||
|
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "通知",
|
||||||
|
"description": "控制你希望接收的通知事件。",
|
||||||
|
"webPush": {
|
||||||
|
"title": "Web 推送通知",
|
||||||
|
"enable": "启用推送通知",
|
||||||
|
"disable": "关闭推送通知",
|
||||||
|
"enabled": "推送通知已启用",
|
||||||
|
"loading": "更新中...",
|
||||||
|
"unsupported": "此浏览器不支持推送通知。",
|
||||||
|
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "事件类型",
|
||||||
|
"actionRequired": "需要处理",
|
||||||
|
"stop": "运行已停止",
|
||||||
|
"error": "运行失败"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
|
|||||||
10
src/main.jsx
10
src/main.jsx
@@ -7,14 +7,10 @@ import 'katex/dist/katex.min.css'
|
|||||||
// Initialize i18n
|
// Initialize i18n
|
||||||
import './i18n/config.js'
|
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) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.getRegistrations().then(registrations => {
|
navigator.serviceWorker.register('/sw.js').catch(err => {
|
||||||
registrations.forEach(registration => {
|
console.warn('Service worker registration failed:', err);
|
||||||
registration.unregister();
|
|
||||||
});
|
|
||||||
}).catch(err => {
|
|
||||||
console.warn('Failed to unregister service workers:', err);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user