mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-11 08:57:38 +00:00
Compare commits
16 Commits
feat/notif
...
ca247cddae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca247cddae | ||
|
|
4061a2761e | ||
|
|
c368451891 | ||
|
|
efdee162c9 | ||
|
|
a7e8b12ef4 | ||
|
|
24430fa343 | ||
|
|
38accf6505 | ||
|
|
e80fd4b09b | ||
|
|
1d62df68d6 | ||
|
|
0a3e22905f | ||
|
|
a09aa5f68e | ||
|
|
95ba61ea3e | ||
|
|
6e4ea7f333 | ||
|
|
6d4cea0435 | ||
|
|
ba197cc286 | ||
|
|
b4169887ab |
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "plugins/starter"]
|
||||
path = plugins/starter
|
||||
url = https://github.com/cloudcli-ai/cloudcli-plugin-starter.git
|
||||
14
README.md
14
README.md
@@ -1,22 +1,24 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
|
||||
<h1>Cloud CLI (aka Claude Code UI)</h1>
|
||||
<p>A desktop and mobile UI for <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a>, and <a href="https://geminicli.com/">Gemini-CLI</a>.<br>Use it locally or remotely to view your active projects and sessions from everywhere.</p>
|
||||
</div>
|
||||
|
||||
|
||||
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex), and [Gemini-CLI](https://geminicli.com/). You can use it locally or remotely to view your active projects and sessions and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="CONTRIBUTING.md">Contributing</a>
|
||||
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Documentation</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="CONTRIBUTING.md">Contributing</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Join our Discord"></a>
|
||||
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁️_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
|
||||
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord"></a>
|
||||
<br><br>
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
@@ -59,7 +61,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, GPT-5.2, and Gemini.
|
||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models)
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
339
package-lock.json
generated
339
package-lock.json
generated
@@ -65,7 +65,6 @@
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1828,9 +1827,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1847,9 +1843,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1867,9 +1860,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1887,9 +1877,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1906,9 +1893,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1919,15 +1903,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"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"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1938,15 +1919,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"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"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1963,9 +1941,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1988,9 +1963,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2014,9 +1986,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2040,9 +2009,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2065,9 +2031,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2084,15 +2047,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"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"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2105,19 +2065,16 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"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"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2130,7 +2087,7 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
@@ -2154,9 +2111,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"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"
|
||||
],
|
||||
@@ -3468,9 +3425,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3485,9 +3439,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3502,9 +3453,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3519,9 +3467,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3536,9 +3481,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3553,9 +3495,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3570,9 +3509,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3587,9 +3523,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3604,9 +3537,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3621,9 +3551,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3638,9 +3565,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4384,9 +4308,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4401,9 +4322,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4418,9 +4336,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4435,9 +4350,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4452,9 +4364,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4469,9 +4378,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4486,9 +4392,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4503,9 +4406,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4708,6 +4608,7 @@
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
@@ -5013,18 +4914,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||
@@ -5287,12 +5176,6 @@
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
@@ -9067,15 +8950,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
@@ -9117,6 +8991,7 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
@@ -11874,12 +11749,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
@@ -14855,9 +14724,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -14875,9 +14741,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -14895,49 +14758,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"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-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,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"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,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -14955,9 +14775,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -14981,9 +14798,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -15007,9 +14821,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -15025,78 +14836,6 @@
|
||||
"@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,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"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,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz",
|
||||
"integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz",
|
||||
@@ -17328,46 +17067,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push/node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push/node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
|
||||
@@ -103,7 +103,6 @@
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
1
plugins/starter
Submodule
1
plugins/starter
Submodule
Submodule plugins/starter added at bfa6332810
58
public/sw.js
58
public/sw.js
@@ -19,17 +19,14 @@ self.addEventListener('install', event => {
|
||||
|
||||
// Fetch event
|
||||
self.addEventListener('fetch', event => {
|
||||
// Never cache API requests or WebSocket upgrades
|
||||
if (event.request.url.includes('/api/') || event.request.url.includes('/ws')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
// Return cached response if found
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
// Otherwise fetch from network
|
||||
return fetch(event.request);
|
||||
}
|
||||
)
|
||||
@@ -49,53 +46,4 @@ 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?.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 urlPath = sessionId ? `/session/${sessionId}` : '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.location.origin)) {
|
||||
client.focus();
|
||||
if (sessionId) {
|
||||
client.navigate(self.location.origin + urlPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(urlPath);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,6 @@ import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||
import { createNotificationEvent, notifyUserIfEnabled } from './services/notification-orchestrator.js';
|
||||
|
||||
const activeSessions = new Map();
|
||||
const pendingToolApprovals = new Map();
|
||||
@@ -468,14 +467,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
let tempImagePaths = [];
|
||||
let tempDir = null;
|
||||
|
||||
const emitNotification = (event) => {
|
||||
notifyUserIfEnabled({
|
||||
userId: ws?.userId || null,
|
||||
writer: ws,
|
||||
event
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
// Map CLI options to SDK format
|
||||
const sdkOptions = mapCliOptionsToSDK(options);
|
||||
@@ -492,42 +483,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
tempImagePaths = imageResult.tempImagePaths;
|
||||
tempDir = imageResult.tempDir;
|
||||
|
||||
sdkOptions.hooks = {
|
||||
Notification: [{
|
||||
matcher: '',
|
||||
hooks: [async (input) => {
|
||||
const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
|
||||
emitNotification(createNotificationEvent({
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
kind: 'action_required',
|
||||
code: 'agent.notification',
|
||||
meta: { message },
|
||||
severity: 'warning',
|
||||
requiresUserAction: true,
|
||||
dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}`
|
||||
}));
|
||||
return {};
|
||||
}]
|
||||
}],
|
||||
Stop: [{
|
||||
matcher: '',
|
||||
hooks: [async (input) => {
|
||||
const stopReason = typeof input?.stop_reason === 'string' ? input.stop_reason : 'completed';
|
||||
emitNotification(createNotificationEvent({
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
kind: 'stop',
|
||||
code: 'run.stopped',
|
||||
meta: { stopReason },
|
||||
severity: 'info',
|
||||
dedupeKey: `claude:hook:stop:${capturedSessionId || sessionId || 'none'}:${stopReason}`
|
||||
}));
|
||||
return {};
|
||||
}]
|
||||
}]
|
||||
};
|
||||
|
||||
sdkOptions.canUseTool = async (toolName, input, context) => {
|
||||
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
||||
|
||||
@@ -559,16 +514,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
input,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
emitNotification(createNotificationEvent({
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
kind: 'action_required',
|
||||
code: 'permission.required',
|
||||
meta: { toolName },
|
||||
severity: 'warning',
|
||||
requiresUserAction: true,
|
||||
dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}`
|
||||
}));
|
||||
|
||||
const decision = await waitForToolApproval(requestId, {
|
||||
timeoutMs: requiresInteraction ? 0 : undefined,
|
||||
@@ -615,22 +560,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
||||
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
|
||||
|
||||
let queryInstance;
|
||||
try {
|
||||
queryInstance = query({
|
||||
prompt: finalCommand,
|
||||
options: sdkOptions
|
||||
});
|
||||
} catch (hookError) {
|
||||
// Older/newer SDK versions may not accept hook shapes yet.
|
||||
// Keep notification behavior operational via runtime events even if hook registration fails.
|
||||
console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
|
||||
delete sdkOptions.hooks;
|
||||
queryInstance = query({
|
||||
prompt: finalCommand,
|
||||
options: sdkOptions
|
||||
});
|
||||
}
|
||||
const queryInstance = query({
|
||||
prompt: finalCommand,
|
||||
options: sdkOptions
|
||||
});
|
||||
|
||||
// Restore immediately — Query constructor already captured the value
|
||||
if (prevStreamTimeout !== undefined) {
|
||||
@@ -733,15 +666,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
error: error.message,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
emitNotification(createNotificationEvent({
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
kind: 'error',
|
||||
code: 'run.failed',
|
||||
meta: { error: error.message },
|
||||
severity: 'error',
|
||||
dedupeKey: `claude:error:${capturedSessionId || sessionId || 'none'}:${error.message}`
|
||||
}));
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -91,36 +91,6 @@ const runMigrations = () => {
|
||||
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
preferences_json TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS vapid_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
keys_p256dh TEXT NOT NULL,
|
||||
keys_auth TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Create session_names table if it doesn't exist (for existing installations)
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS session_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -390,116 +360,6 @@ const credentialsDb = {
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_NOTIFICATION_PREFERENCES = {
|
||||
channels: {
|
||||
inApp: false,
|
||||
webPush: false
|
||||
},
|
||||
events: {
|
||||
actionRequired: true,
|
||||
stop: true,
|
||||
error: true
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeNotificationPreferences = (value) => {
|
||||
const source = value && typeof value === 'object' ? value : {};
|
||||
|
||||
return {
|
||||
channels: {
|
||||
inApp: source.channels?.inApp === true,
|
||||
webPush: source.channels?.webPush === true
|
||||
},
|
||||
events: {
|
||||
actionRequired: source.events?.actionRequired !== false,
|
||||
stop: source.events?.stop !== false,
|
||||
error: source.events?.error !== false
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const notificationPreferencesDb = {
|
||||
getPreferences: (userId) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId);
|
||||
if (!row) {
|
||||
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||
db.prepare(
|
||||
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
|
||||
).run(userId, JSON.stringify(defaults));
|
||||
return defaults;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(row.preferences_json);
|
||||
} catch {
|
||||
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
|
||||
}
|
||||
return normalizeNotificationPreferences(parsed);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
updatePreferences: (userId, preferences) => {
|
||||
try {
|
||||
const normalized = normalizeNotificationPreferences(preferences);
|
||||
db.prepare(
|
||||
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
preferences_json = excluded.preferences_json,
|
||||
updated_at = CURRENT_TIMESTAMP`
|
||||
).run(userId, JSON.stringify(normalized));
|
||||
return normalized;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pushSubscriptionsDb = {
|
||||
saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => {
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
keys_p256dh = excluded.keys_p256dh,
|
||||
keys_auth = excluded.keys_auth`
|
||||
).run(userId, endpoint, keysP256dh, keysAuth);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
getSubscriptions: (userId) => {
|
||||
try {
|
||||
return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
removeSubscription: (endpoint) => {
|
||||
try {
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
removeAllForUser: (userId) => {
|
||||
try {
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Session custom names database operations
|
||||
const sessionNamesDb = {
|
||||
// Set (insert or update) a custom session name
|
||||
@@ -579,9 +439,7 @@ export {
|
||||
userDb,
|
||||
apiKeysDb,
|
||||
credentialsDb,
|
||||
notificationPreferencesDb,
|
||||
pushSubscriptionsDb,
|
||||
sessionNamesDb,
|
||||
applyCustomSessionNames,
|
||||
githubTokensDb // Backward compatibility
|
||||
};
|
||||
};
|
||||
@@ -51,33 +51,6 @@ CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
||||
|
||||
-- User notification preferences (backend-owned, provider-agnostic)
|
||||
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
preferences_json TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- VAPID key pair for Web Push notifications
|
||||
CREATE TABLE IF NOT EXISTS vapid_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Browser push subscriptions
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
keys_p256dh TEXT NOT NULL,
|
||||
keys_auth TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Session custom names (provider-agnostic display name overrides)
|
||||
CREATE TABLE IF NOT EXISTS session_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -89,4 +62,4 @@ CREATE TABLE IF NOT EXISTS session_names (
|
||||
UNIQUE(session_id, provider)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
|
||||
@@ -64,8 +64,9 @@ import cliAuthRoutes from './routes/cli-auth.js';
|
||||
import userRoutes from './routes/user.js';
|
||||
import codexRoutes from './routes/codex.js';
|
||||
import geminiRoutes from './routes/gemini.js';
|
||||
import pluginsRoutes from './routes/plugins.js';
|
||||
import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js';
|
||||
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
|
||||
import { configureWebPush } from './services/vapid-keys.js';
|
||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||
import { IS_PLATFORM } from './constants/config.js';
|
||||
|
||||
@@ -390,6 +391,9 @@ app.use('/api/codex', authenticateToken, codexRoutes);
|
||||
// Gemini API Routes (protected)
|
||||
app.use('/api/gemini', authenticateToken, geminiRoutes);
|
||||
|
||||
// Plugins API Routes (protected)
|
||||
app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
||||
|
||||
// Agent API Routes (uses API key authentication)
|
||||
app.use('/api/agent', agentRoutes);
|
||||
|
||||
@@ -1402,7 +1406,7 @@ wss.on('connection', (ws, request) => {
|
||||
if (pathname === '/shell') {
|
||||
handleShellConnection(ws);
|
||||
} else if (pathname === '/ws') {
|
||||
handleChatConnection(ws, request);
|
||||
handleChatConnection(ws);
|
||||
} else {
|
||||
console.log('[WARN] Unknown WebSocket path:', pathname);
|
||||
ws.close();
|
||||
@@ -1413,10 +1417,9 @@ wss.on('connection', (ws, request) => {
|
||||
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
||||
*/
|
||||
class WebSocketWriter {
|
||||
constructor(ws, userId = null) {
|
||||
constructor(ws) {
|
||||
this.ws = ws;
|
||||
this.sessionId = null;
|
||||
this.userId = userId;
|
||||
this.isWebSocketWriter = true; // Marker for transport detection
|
||||
}
|
||||
|
||||
@@ -1441,14 +1444,14 @@ class WebSocketWriter {
|
||||
}
|
||||
|
||||
// Handle chat WebSocket connections
|
||||
function handleChatConnection(ws, request) {
|
||||
function handleChatConnection(ws) {
|
||||
console.log('[INFO] Chat WebSocket connected');
|
||||
|
||||
// Add to connected clients for project updates
|
||||
connectedClients.add(ws);
|
||||
|
||||
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
|
||||
const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null);
|
||||
const writer = new WebSocketWriter(ws);
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
try {
|
||||
@@ -2507,9 +2510,6 @@ async function startServer() {
|
||||
// Initialize authentication database
|
||||
await initializeDatabase();
|
||||
|
||||
// Configure Web Push (VAPID keys)
|
||||
configureWebPush();
|
||||
|
||||
// Check if running in production mode (dist folder exists)
|
||||
const distIndexPath = path.join(__dirname, '../dist/index.html');
|
||||
const isProduction = fs.existsSync(distIndexPath);
|
||||
@@ -2537,7 +2537,20 @@ async function startServer() {
|
||||
|
||||
// Start watching the projects folder for changes
|
||||
await setupProjectsWatcher();
|
||||
|
||||
// Start server-side plugin processes for enabled plugins
|
||||
startEnabledPluginServers().catch(err => {
|
||||
console.error('[Plugins] Error during startup:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up plugin processes on shutdown
|
||||
const shutdownPlugins = async () => {
|
||||
await stopAllPlugins();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', () => void shutdownPlugins());
|
||||
process.on('SIGINT', () => void shutdownPlugins());
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Failed to start server:', error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -85,7 +85,7 @@ const authenticateWebSocket = (token) => {
|
||||
try {
|
||||
const user = userDb.getFirstUser();
|
||||
if (user) {
|
||||
return { id: user.id, userId: user.id, username: user.username };
|
||||
return { userId: user.id, username: user.username };
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
@@ -101,10 +101,7 @@ const authenticateWebSocket = (token) => {
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
return {
|
||||
...decoded,
|
||||
id: decoded.userId
|
||||
};
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
console.error('WebSocket token verification error:', error);
|
||||
return null;
|
||||
@@ -117,4 +114,4 @@ export {
|
||||
generateToken,
|
||||
authenticateWebSocket,
|
||||
JWT_SECRET
|
||||
};
|
||||
};
|
||||
293
server/routes/plugins.js
Normal file
293
server/routes/plugins.js
Normal file
@@ -0,0 +1,293 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import mime from 'mime-types';
|
||||
import fs from 'fs';
|
||||
import {
|
||||
scanPlugins,
|
||||
getPluginsConfig,
|
||||
getPluginsDir,
|
||||
savePluginsConfig,
|
||||
getPluginDir,
|
||||
resolvePluginAssetPath,
|
||||
installPluginFromGit,
|
||||
updatePluginFromGit,
|
||||
uninstallPlugin,
|
||||
} from '../utils/plugin-loader.js';
|
||||
import {
|
||||
startPluginServer,
|
||||
stopPluginServer,
|
||||
getPluginPort,
|
||||
isPluginRunning,
|
||||
} from '../utils/plugin-process-manager.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET / — List all installed plugins (includes server running status)
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const plugins = scanPlugins().map(p => ({
|
||||
...p,
|
||||
serverRunning: p.server ? isPluginRunning(p.name) : false,
|
||||
}));
|
||||
res.json({ plugins });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to scan plugins', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /:name/manifest — Get single plugin manifest
|
||||
router.get('/:name/manifest', (req, res) => {
|
||||
try {
|
||||
const plugins = scanPlugins();
|
||||
const plugin = plugins.find(p => p.name === req.params.name);
|
||||
if (!plugin) {
|
||||
return res.status(404).json({ error: 'Plugin not found' });
|
||||
}
|
||||
res.json(plugin);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to read plugin manifest', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /:name/assets/* — Serve plugin static files
|
||||
router.get('/:name/assets/*', (req, res) => {
|
||||
const pluginName = req.params.name;
|
||||
const assetPath = req.params[0];
|
||||
|
||||
if (!assetPath) {
|
||||
return res.status(400).json({ error: 'No asset path specified' });
|
||||
}
|
||||
|
||||
const resolvedPath = resolvePluginAssetPath(pluginName, assetPath);
|
||||
if (!resolvedPath) {
|
||||
return res.status(404).json({ error: 'Asset not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(resolvedPath);
|
||||
if (!stat.isFile()) {
|
||||
return res.status(404).json({ error: 'Asset not found' });
|
||||
}
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'Asset not found' });
|
||||
}
|
||||
|
||||
const contentType = mime.lookup(resolvedPath) || 'application/octet-stream';
|
||||
res.setHeader('Content-Type', contentType);
|
||||
const stream = fs.createReadStream(resolvedPath);
|
||||
stream.on('error', () => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Failed to read asset' });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
stream.pipe(res);
|
||||
});
|
||||
|
||||
// PUT /:name/enable — Toggle plugin enabled/disabled (starts/stops server if applicable)
|
||||
router.put('/:name/enable', async (req, res) => {
|
||||
try {
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: '"enabled" must be a boolean' });
|
||||
}
|
||||
|
||||
const plugins = scanPlugins();
|
||||
const plugin = plugins.find(p => p.name === req.params.name);
|
||||
if (!plugin) {
|
||||
return res.status(404).json({ error: 'Plugin not found' });
|
||||
}
|
||||
|
||||
const config = getPluginsConfig();
|
||||
config[req.params.name] = { ...config[req.params.name], enabled };
|
||||
savePluginsConfig(config);
|
||||
|
||||
// Start or stop the plugin server as needed
|
||||
if (plugin.server) {
|
||||
if (enabled && !isPluginRunning(plugin.name)) {
|
||||
const pluginDir = getPluginDir(plugin.name);
|
||||
if (pluginDir) {
|
||||
try {
|
||||
await startPluginServer(plugin.name, pluginDir, plugin.server);
|
||||
} catch (err) {
|
||||
console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
|
||||
}
|
||||
}
|
||||
} else if (!enabled && isPluginRunning(plugin.name)) {
|
||||
await stopPluginServer(plugin.name);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, name: req.params.name, enabled });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to update plugin', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /install — Install plugin from git URL
|
||||
router.post('/install', async (req, res) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
if (!url || typeof url !== 'string') {
|
||||
return res.status(400).json({ error: '"url" is required and must be a string' });
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
if (!url.startsWith('https://') && !url.startsWith('git@')) {
|
||||
return res.status(400).json({ error: 'URL must start with https:// or git@' });
|
||||
}
|
||||
|
||||
const manifest = await installPluginFromGit(url);
|
||||
|
||||
// Auto-start the server if the plugin has one (enabled by default)
|
||||
if (manifest.server) {
|
||||
const pluginDir = getPluginDir(manifest.name);
|
||||
if (pluginDir) {
|
||||
try {
|
||||
await startPluginServer(manifest.name, pluginDir, manifest.server);
|
||||
} catch (err) {
|
||||
console.error(`[Plugins] Failed to start server for "${manifest.name}":`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, plugin: manifest });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: 'Failed to install plugin', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /:name/update — Pull latest from git (restarts server if running)
|
||||
router.post('/:name/update', async (req, res) => {
|
||||
try {
|
||||
const pluginName = req.params.name;
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
|
||||
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||
}
|
||||
|
||||
const wasRunning = isPluginRunning(pluginName);
|
||||
if (wasRunning) {
|
||||
await stopPluginServer(pluginName);
|
||||
}
|
||||
|
||||
const manifest = await updatePluginFromGit(pluginName);
|
||||
|
||||
// Restart server if it was running before the update
|
||||
if (wasRunning && manifest.server) {
|
||||
const pluginDir = getPluginDir(pluginName);
|
||||
if (pluginDir) {
|
||||
try {
|
||||
await startPluginServer(pluginName, pluginDir, manifest.server);
|
||||
} catch (err) {
|
||||
console.error(`[Plugins] Failed to restart server for "${pluginName}":`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, plugin: manifest });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: 'Failed to update plugin', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ALL /:name/rpc/* — Proxy requests to plugin's server subprocess
|
||||
router.all('/:name/rpc/*', async (req, res) => {
|
||||
const pluginName = req.params.name;
|
||||
const rpcPath = req.params[0] || '';
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
|
||||
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||
}
|
||||
|
||||
let port = getPluginPort(pluginName);
|
||||
if (!port) {
|
||||
// Lazily start the plugin server if it exists and is enabled
|
||||
const plugins = scanPlugins();
|
||||
const plugin = plugins.find(p => p.name === pluginName);
|
||||
if (!plugin || !plugin.server) {
|
||||
return res.status(503).json({ error: 'Plugin server is not running' });
|
||||
}
|
||||
if (!plugin.enabled) {
|
||||
return res.status(503).json({ error: 'Plugin is disabled' });
|
||||
}
|
||||
const pluginDir = path.join(getPluginsDir(), plugin.dirName);
|
||||
try {
|
||||
port = await startPluginServer(pluginName, pluginDir, plugin.server);
|
||||
} catch (err) {
|
||||
return res.status(503).json({ error: 'Plugin server failed to start', details: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Inject configured secrets as headers
|
||||
const config = getPluginsConfig();
|
||||
const pluginConfig = config[pluginName] || {};
|
||||
const secrets = pluginConfig.secrets || {};
|
||||
|
||||
const headers = {
|
||||
'content-type': req.headers['content-type'] || 'application/json',
|
||||
};
|
||||
|
||||
// Add per-plugin secrets as X-Plugin-Secret-* headers
|
||||
for (const [key, value] of Object.entries(secrets)) {
|
||||
headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value);
|
||||
}
|
||||
|
||||
// Reconstruct query string
|
||||
const qs = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
|
||||
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: `/${rpcPath}${qs}`,
|
||||
method: req.method,
|
||||
headers,
|
||||
};
|
||||
|
||||
const proxyReq = http.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
res.status(502).json({ error: 'Plugin server error', details: err.message });
|
||||
});
|
||||
|
||||
// Forward body (already parsed by express JSON middleware, so re-stringify).
|
||||
// Check content-length to detect whether a body was actually sent, since
|
||||
// req.body can be falsy for valid payloads like 0, false, null, or {}.
|
||||
const hasBody = req.headers['content-length'] && parseInt(req.headers['content-length'], 10) > 0;
|
||||
if (hasBody && req.body !== undefined) {
|
||||
const bodyStr = JSON.stringify(req.body);
|
||||
proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr));
|
||||
proxyReq.write(bodyStr);
|
||||
}
|
||||
|
||||
proxyReq.end();
|
||||
});
|
||||
|
||||
// DELETE /:name — Uninstall plugin (stops server first)
|
||||
router.delete('/:name', async (req, res) => {
|
||||
try {
|
||||
const pluginName = req.params.name;
|
||||
|
||||
// Validate name format to prevent path traversal
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
|
||||
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||
}
|
||||
|
||||
// Stop server and wait for the process to fully exit before deleting files
|
||||
if (isPluginRunning(pluginName)) {
|
||||
await stopPluginServer(pluginName);
|
||||
}
|
||||
|
||||
await uninstallPlugin(pluginName);
|
||||
res.json({ success: true, name: pluginName });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: 'Failed to uninstall plugin', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,7 +1,5 @@
|
||||
import express from 'express';
|
||||
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
|
||||
import { getPublicKey } from '../services/vapid-keys.js';
|
||||
import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';
|
||||
import { apiKeysDb, credentialsDb } from '../database/db.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -177,100 +175,4 @@ 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;
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import webPush from 'web-push';
|
||||
import { notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
|
||||
|
||||
const KIND_TO_PREF_KEY = {
|
||||
action_required: 'actionRequired',
|
||||
stop: 'stop',
|
||||
error: 'error'
|
||||
};
|
||||
|
||||
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 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!'
|
||||
};
|
||||
|
||||
return {
|
||||
title: 'Claude Code UI',
|
||||
body: CODE_MAP[event.code] || 'You have a new notification',
|
||||
data: {
|
||||
sessionId: event.sessionId || null,
|
||||
code: 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);
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
createNotificationEvent,
|
||||
notifyUserIfEnabled
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
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 };
|
||||
382
server/utils/plugin-loader.js
Normal file
382
server/utils/plugin-loader.js
Normal file
@@ -0,0 +1,382 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
|
||||
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
||||
|
||||
const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
|
||||
const ALLOWED_TYPES = ['react', 'module'];
|
||||
const ALLOWED_SLOTS = ['tab'];
|
||||
|
||||
export function getPluginsDir() {
|
||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
}
|
||||
return PLUGINS_DIR;
|
||||
}
|
||||
|
||||
export function getPluginsConfig() {
|
||||
try {
|
||||
if (fs.existsSync(PLUGINS_CONFIG_PATH)) {
|
||||
return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8'));
|
||||
}
|
||||
} catch {
|
||||
// Corrupted config, start fresh
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function savePluginsConfig(config) {
|
||||
const dir = path.dirname(PLUGINS_CONFIG_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
export function validateManifest(manifest) {
|
||||
if (!manifest || typeof manifest !== 'object') {
|
||||
return { valid: false, error: 'Manifest must be a JSON object' };
|
||||
}
|
||||
|
||||
for (const field of REQUIRED_MANIFEST_FIELDS) {
|
||||
if (!manifest[field] || typeof manifest[field] !== 'string') {
|
||||
return { valid: false, error: `Missing or invalid required field: ${field}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize name — only allow alphanumeric, hyphens, underscores
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) {
|
||||
return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' };
|
||||
}
|
||||
|
||||
if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) {
|
||||
return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` };
|
||||
}
|
||||
|
||||
if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) {
|
||||
return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };
|
||||
}
|
||||
|
||||
// Validate entry is a relative path without traversal
|
||||
if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) {
|
||||
return { valid: false, error: 'Entry must be a relative path without ".."' };
|
||||
}
|
||||
|
||||
if (manifest.server !== undefined && manifest.server !== null) {
|
||||
if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) {
|
||||
return { valid: false, error: 'Server entry must be a relative path string without ".."' };
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.permissions !== undefined) {
|
||||
if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) {
|
||||
return { valid: false, error: 'Permissions must be an array of strings' };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export function scanPlugins() {
|
||||
const pluginsDir = getPluginsDir();
|
||||
const config = getPluginsConfig();
|
||||
const plugins = [];
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return plugins;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
const validation = validateManifest(manifest);
|
||||
if (!validation.valid) {
|
||||
console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to read git remote URL
|
||||
let repoUrl = null;
|
||||
try {
|
||||
const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config');
|
||||
if (fs.existsSync(gitConfigPath)) {
|
||||
const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8');
|
||||
const match = gitConfig.match(/url\s*=\s*(.+)/);
|
||||
if (match) {
|
||||
repoUrl = match[1].trim().replace(/\.git$/, '');
|
||||
// Convert SSH URLs to HTTPS
|
||||
if (repoUrl.startsWith('git@')) {
|
||||
repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
plugins.push({
|
||||
name: manifest.name,
|
||||
displayName: manifest.displayName,
|
||||
version: manifest.version || '0.0.0',
|
||||
description: manifest.description || '',
|
||||
author: manifest.author || '',
|
||||
icon: manifest.icon || 'Puzzle',
|
||||
type: manifest.type || 'module',
|
||||
slot: manifest.slot || 'tab',
|
||||
entry: manifest.entry,
|
||||
server: manifest.server || null,
|
||||
permissions: manifest.permissions || [],
|
||||
enabled: config[manifest.name]?.enabled !== false, // enabled by default
|
||||
dirName: entry.name,
|
||||
repoUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
export function getPluginDir(name) {
|
||||
const plugins = scanPlugins();
|
||||
const plugin = plugins.find(p => p.name === name);
|
||||
if (!plugin) return null;
|
||||
return path.join(getPluginsDir(), plugin.dirName);
|
||||
}
|
||||
|
||||
export function resolvePluginAssetPath(name, assetPath) {
|
||||
const pluginDir = getPluginDir(name);
|
||||
if (!pluginDir) return null;
|
||||
|
||||
const resolved = path.resolve(pluginDir, assetPath);
|
||||
|
||||
// Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
|
||||
if (!fs.existsSync(resolved)) return null;
|
||||
|
||||
const realResolved = fs.realpathSync(resolved);
|
||||
const realPluginDir = fs.realpathSync(pluginDir);
|
||||
if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return realResolved;
|
||||
}
|
||||
|
||||
export function installPluginFromGit(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof url !== 'string' || !url.trim()) {
|
||||
return reject(new Error('Invalid URL: must be a non-empty string'));
|
||||
}
|
||||
if (url.startsWith('-')) {
|
||||
return reject(new Error('Invalid URL: must not start with "-"'));
|
||||
}
|
||||
|
||||
// Extract repo name from URL for directory name
|
||||
const urlClean = url.replace(/\.git$/, '').replace(/\/$/, '');
|
||||
const repoName = urlClean.split('/').pop();
|
||||
|
||||
if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) {
|
||||
return reject(new Error('Could not determine a valid directory name from the URL'));
|
||||
}
|
||||
|
||||
const pluginsDir = getPluginsDir();
|
||||
const targetDir = path.resolve(pluginsDir, repoName);
|
||||
|
||||
// Ensure the resolved target directory stays within the plugins directory
|
||||
if (!targetDir.startsWith(pluginsDir + path.sep)) {
|
||||
return reject(new Error('Invalid plugin directory path'));
|
||||
}
|
||||
|
||||
if (fs.existsSync(targetDir)) {
|
||||
return reject(new Error(`Plugin directory "${repoName}" already exists`));
|
||||
}
|
||||
|
||||
// Clone into a temp directory so scanPlugins() never sees a partially-installed plugin
|
||||
const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`));
|
||||
|
||||
const cleanupTemp = () => {
|
||||
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
|
||||
};
|
||||
|
||||
const finalize = (manifest) => {
|
||||
try {
|
||||
fs.renameSync(tempDir, targetDir);
|
||||
} catch (err) {
|
||||
cleanupTemp();
|
||||
return reject(new Error(`Failed to move plugin into place: ${err.message}`));
|
||||
}
|
||||
resolve(manifest);
|
||||
};
|
||||
|
||||
const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
gitProcess.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
cleanupTemp();
|
||||
return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
|
||||
}
|
||||
|
||||
// Validate manifest exists
|
||||
const manifestPath = path.join(tempDir, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
cleanupTemp();
|
||||
return reject(new Error('Cloned repository does not contain a manifest.json'));
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
} catch {
|
||||
cleanupTemp();
|
||||
return reject(new Error('manifest.json is not valid JSON'));
|
||||
}
|
||||
|
||||
const validation = validateManifest(manifest);
|
||||
if (!validation.valid) {
|
||||
cleanupTemp();
|
||||
return reject(new Error(`Invalid manifest: ${validation.error}`));
|
||||
}
|
||||
|
||||
// Reject if another installed plugin already uses this name
|
||||
const existing = scanPlugins().find(p => p.name === manifest.name);
|
||||
if (existing) {
|
||||
cleanupTemp();
|
||||
return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`));
|
||||
}
|
||||
|
||||
// Run npm install if package.json exists.
|
||||
// --ignore-scripts prevents postinstall hooks from executing arbitrary code.
|
||||
const packageJsonPath = path.join(tempDir, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
|
||||
cwd: tempDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
npmProcess.on('close', (npmCode) => {
|
||||
if (npmCode !== 0) {
|
||||
cleanupTemp();
|
||||
return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`));
|
||||
}
|
||||
finalize(manifest);
|
||||
});
|
||||
|
||||
npmProcess.on('error', (err) => {
|
||||
cleanupTemp();
|
||||
reject(err);
|
||||
});
|
||||
} else {
|
||||
finalize(manifest);
|
||||
}
|
||||
});
|
||||
|
||||
gitProcess.on('error', (err) => {
|
||||
cleanupTemp();
|
||||
reject(new Error(`Failed to spawn git: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePluginFromGit(name) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const pluginDir = getPluginDir(name);
|
||||
if (!pluginDir) {
|
||||
return reject(new Error(`Plugin "${name}" not found`));
|
||||
}
|
||||
|
||||
// Only fast-forward to avoid silent divergence
|
||||
const gitProcess = spawn('git', ['pull', '--ff-only', '--'], {
|
||||
cwd: pluginDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
gitProcess.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`));
|
||||
}
|
||||
|
||||
// Re-validate manifest after update
|
||||
const manifestPath = path.join(pluginDir, 'manifest.json');
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
} catch {
|
||||
return reject(new Error('manifest.json is not valid JSON after update'));
|
||||
}
|
||||
|
||||
const validation = validateManifest(manifest);
|
||||
if (!validation.valid) {
|
||||
return reject(new Error(`Invalid manifest after update: ${validation.error}`));
|
||||
}
|
||||
|
||||
// Re-run npm install if package.json exists
|
||||
const packageJsonPath = path.join(pluginDir, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
|
||||
cwd: pluginDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
npmProcess.on('close', (npmCode) => {
|
||||
if (npmCode !== 0) {
|
||||
return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`));
|
||||
}
|
||||
resolve(manifest);
|
||||
});
|
||||
npmProcess.on('error', (err) => reject(err));
|
||||
} else {
|
||||
resolve(manifest);
|
||||
}
|
||||
});
|
||||
|
||||
gitProcess.on('error', (err) => {
|
||||
reject(new Error(`Failed to spawn git: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function uninstallPlugin(name) {
|
||||
const pluginDir = getPluginDir(name);
|
||||
if (!pluginDir) {
|
||||
throw new Error(`Plugin "${name}" not found`);
|
||||
}
|
||||
|
||||
// On Windows, file handles may be released slightly after process exit.
|
||||
// Retry a few times with a short delay before giving up.
|
||||
const MAX_RETRIES = 5;
|
||||
const RETRY_DELAY_MS = 500;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
fs.rmSync(pluginDir, { recursive: true, force: true });
|
||||
break;
|
||||
} catch (err) {
|
||||
if (err.code === 'EBUSY' && attempt < MAX_RETRIES) {
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from config
|
||||
const config = getPluginsConfig();
|
||||
delete config[name];
|
||||
savePluginsConfig(config);
|
||||
}
|
||||
184
server/utils/plugin-process-manager.js
Normal file
184
server/utils/plugin-process-manager.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';
|
||||
|
||||
// Map<pluginName, { process, port }>
|
||||
const runningPlugins = new Map();
|
||||
// Map<pluginName, Promise<port>> — in-flight start operations
|
||||
const startingPlugins = new Map();
|
||||
|
||||
/**
|
||||
* Start a plugin's server subprocess.
|
||||
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
|
||||
* to stdout within 10 seconds.
|
||||
*/
|
||||
export function startPluginServer(name, pluginDir, serverEntry) {
|
||||
if (runningPlugins.has(name)) {
|
||||
return Promise.resolve(runningPlugins.get(name).port);
|
||||
}
|
||||
|
||||
// Coalesce concurrent starts for the same plugin
|
||||
if (startingPlugins.has(name)) {
|
||||
return startingPlugins.get(name);
|
||||
}
|
||||
|
||||
const startPromise = new Promise((resolve, reject) => {
|
||||
|
||||
const serverPath = path.join(pluginDir, serverEntry);
|
||||
|
||||
// Restricted env — only essentials, no host secrets
|
||||
const pluginProcess = spawn('node', [serverPath], {
|
||||
cwd: pluginDir,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
NODE_ENV: process.env.NODE_ENV || 'production',
|
||||
PLUGIN_NAME: name,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
let stdout = '';
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
pluginProcess.kill();
|
||||
reject(new Error('Plugin server did not report ready within 10 seconds'));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
pluginProcess.stdout.on('data', (data) => {
|
||||
if (resolved) return;
|
||||
stdout += data.toString();
|
||||
|
||||
// Look for the JSON ready line
|
||||
const lines = stdout.split('\n');
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const msg = JSON.parse(line.trim());
|
||||
if (msg.ready && typeof msg.port === 'number') {
|
||||
clearTimeout(timeout);
|
||||
resolved = true;
|
||||
runningPlugins.set(name, { process: pluginProcess, port: msg.port });
|
||||
|
||||
pluginProcess.on('exit', () => {
|
||||
runningPlugins.delete(name);
|
||||
});
|
||||
|
||||
console.log(`[Plugins] Server started for "${name}" on port ${msg.port}`);
|
||||
resolve(msg.port);
|
||||
}
|
||||
} catch {
|
||||
// Not JSON yet, keep buffering
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pluginProcess.stderr.on('data', (data) => {
|
||||
console.warn(`[Plugin:${name}] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
pluginProcess.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`Failed to start plugin server: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
pluginProcess.on('exit', (code) => {
|
||||
clearTimeout(timeout);
|
||||
runningPlugins.delete(name);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`Plugin server exited with code ${code} before reporting ready`));
|
||||
}
|
||||
});
|
||||
}).finally(() => {
|
||||
startingPlugins.delete(name);
|
||||
});
|
||||
|
||||
startingPlugins.set(name, startPromise);
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a plugin's server subprocess.
|
||||
* Returns a Promise that resolves when the process has fully exited.
|
||||
*/
|
||||
export function stopPluginServer(name) {
|
||||
const entry = runningPlugins.get(name);
|
||||
if (!entry) return Promise.resolve();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(forceKillTimer);
|
||||
runningPlugins.delete(name);
|
||||
resolve();
|
||||
};
|
||||
|
||||
entry.process.once('exit', cleanup);
|
||||
|
||||
entry.process.kill('SIGTERM');
|
||||
|
||||
// Force kill after 5 seconds if still running
|
||||
const forceKillTimer = setTimeout(() => {
|
||||
if (runningPlugins.has(name)) {
|
||||
entry.process.kill('SIGKILL');
|
||||
cleanup();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
console.log(`[Plugins] Server stopped for "${name}"`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the port a running plugin server is listening on.
|
||||
*/
|
||||
export function getPluginPort(name) {
|
||||
return runningPlugins.get(name)?.port ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plugin's server is running.
|
||||
*/
|
||||
export function isPluginRunning(name) {
|
||||
return runningPlugins.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all running plugin servers (called on host shutdown).
|
||||
*/
|
||||
export function stopAllPlugins() {
|
||||
const stops = [];
|
||||
for (const [name] of runningPlugins) {
|
||||
stops.push(stopPluginServer(name));
|
||||
}
|
||||
return Promise.all(stops);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start servers for all enabled plugins that have a server entry.
|
||||
* Called once on host server boot.
|
||||
*/
|
||||
export async function startEnabledPluginServers() {
|
||||
const plugins = scanPlugins();
|
||||
const config = getPluginsConfig();
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin.server) continue;
|
||||
if (config[plugin.name]?.enabled === false) continue;
|
||||
|
||||
const pluginDir = getPluginDir(plugin.name);
|
||||
if (!pluginDir) continue;
|
||||
|
||||
try {
|
||||
await startPluginServer(plugin.name, pluginDir, plugin.server);
|
||||
} catch (err) {
|
||||
console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/App.tsx
11
src/App.tsx
@@ -5,6 +5,7 @@ import { AuthProvider, ProtectedRoute } from './components/auth';
|
||||
import { TaskMasterProvider } from './contexts/TaskMasterContext';
|
||||
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
|
||||
import { WebSocketProvider } from './contexts/WebSocketContext';
|
||||
import { PluginsProvider } from './contexts/PluginsContext';
|
||||
import AppContent from './components/app/AppContent';
|
||||
import i18n from './i18n/config.js';
|
||||
|
||||
@@ -14,8 +15,9 @@ export default function App() {
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<WebSocketProvider>
|
||||
<TasksSettingsProvider>
|
||||
<TaskMasterProvider>
|
||||
<PluginsProvider>
|
||||
<TasksSettingsProvider>
|
||||
<TaskMasterProvider>
|
||||
<ProtectedRoute>
|
||||
<Router basename={window.__ROUTER_BASENAME__ || ''}>
|
||||
<Routes>
|
||||
@@ -24,8 +26,9 @@ export default function App() {
|
||||
</Routes>
|
||||
</Router>
|
||||
</ProtectedRoute>
|
||||
</TaskMasterProvider>
|
||||
</TasksSettingsProvider>
|
||||
</TaskMasterProvider>
|
||||
</TasksSettingsProvider>
|
||||
</PluginsProvider>
|
||||
</WebSocketProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { useState, useRef, useEffect, Dispatch, SetStateAction } from 'react';
|
||||
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck, Ellipsis, Puzzle, Box, Database, Globe, Wrench, Zap, BarChart3 } from 'lucide-react';
|
||||
import { useTasksSettings } from '../../contexts/TasksSettingsContext';
|
||||
import { usePlugins } from '../../contexts/PluginsContext';
|
||||
import { AppTab } from '../../types/app';
|
||||
|
||||
const PLUGIN_ICON_MAP = {
|
||||
Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
|
||||
};
|
||||
|
||||
type MobileNavProps = {
|
||||
activeTab: AppTab;
|
||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||
@@ -12,38 +17,38 @@ type MobileNavProps = {
|
||||
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
|
||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
||||
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
|
||||
const { plugins } = usePlugins();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const moreRef = useRef(null);
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
id: 'chat',
|
||||
icon: MessageSquare,
|
||||
label: 'Chat',
|
||||
onClick: () => setActiveTab('chat')
|
||||
},
|
||||
{
|
||||
id: 'shell',
|
||||
icon: Terminal,
|
||||
label: 'Shell',
|
||||
onClick: () => setActiveTab('shell')
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
icon: Folder,
|
||||
label: 'Files',
|
||||
onClick: () => setActiveTab('files')
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
icon: GitBranch,
|
||||
label: 'Git',
|
||||
onClick: () => setActiveTab('git')
|
||||
},
|
||||
...(shouldShowTasksTab ? [{
|
||||
id: 'tasks',
|
||||
icon: ClipboardCheck,
|
||||
label: 'Tasks',
|
||||
onClick: () => setActiveTab('tasks')
|
||||
}] : [])
|
||||
const enabledPlugins = plugins.filter((p) => p.enabled);
|
||||
const hasPlugins = enabledPlugins.length > 0;
|
||||
const isPluginActive = activeTab.startsWith('plugin:');
|
||||
|
||||
// Close the menu on outside tap
|
||||
useEffect(() => {
|
||||
if (!moreOpen) return;
|
||||
const handleTap = (e) => {
|
||||
if (moreRef.current && !moreRef.current.contains(e.target)) {
|
||||
setMoreOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', handleTap);
|
||||
return () => document.removeEventListener('pointerdown', handleTap);
|
||||
}, [moreOpen]);
|
||||
|
||||
// Close menu when a plugin tab is selected
|
||||
const selectPlugin = (name) => {
|
||||
setActiveTab(`plugin:${name}`);
|
||||
setMoreOpen(false);
|
||||
};
|
||||
|
||||
const coreItems = [
|
||||
{ id: 'chat', icon: MessageSquare, label: 'Chat' },
|
||||
{ id: 'shell', icon: Terminal, label: 'Shell' },
|
||||
{ id: 'files', icon: Folder, label: 'Files' },
|
||||
{ id: 'git', icon: GitBranch, label: 'Git' },
|
||||
...(shouldShowTasksTab ? [{ id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -53,17 +58,17 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
|
||||
>
|
||||
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
|
||||
<div className="flex items-center justify-around gap-0.5 px-1 py-1.5">
|
||||
{navItems.map((item) => {
|
||||
{coreItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={item.onClick}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
onTouchStart={(e) => {
|
||||
e.preventDefault();
|
||||
item.onClick();
|
||||
setActiveTab(item.id);
|
||||
}}
|
||||
className={`relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isActive
|
||||
? 'text-primary'
|
||||
@@ -85,6 +90,62 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* "More" button — only shown when there are enabled plugins */}
|
||||
{hasPlugins && (
|
||||
<div ref={moreRef} className="relative flex-1">
|
||||
<button
|
||||
onClick={() => setMoreOpen((v) => !v)}
|
||||
onTouchStart={(e) => {
|
||||
e.preventDefault();
|
||||
setMoreOpen((v) => !v);
|
||||
}}
|
||||
className={`relative flex w-full touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${
|
||||
isPluginActive || moreOpen
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
aria-label="More plugins"
|
||||
aria-expanded={moreOpen}
|
||||
>
|
||||
{(isPluginActive && !moreOpen) && (
|
||||
<div className="bg-primary/8 dark:bg-primary/12 absolute inset-0 rounded-xl" />
|
||||
)}
|
||||
<Ellipsis
|
||||
className={`relative z-10 transition-all duration-200 ${isPluginActive ? 'h-5 w-5' : 'h-[18px] w-[18px]'}`}
|
||||
strokeWidth={isPluginActive ? 2.4 : 1.8}
|
||||
/>
|
||||
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isPluginActive || moreOpen ? 'opacity-100' : 'opacity-60'}`}>
|
||||
More
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Popover menu */}
|
||||
{moreOpen && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full right-0 z-[60] mb-2 min-w-[180px] rounded-xl border border-border/40 bg-popover py-1.5 shadow-lg duration-150">
|
||||
{enabledPlugins.map((p) => {
|
||||
const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle;
|
||||
const isActive = activeTab === `plugin:${p.name}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={p.name}
|
||||
onClick={() => selectPlugin(p.name)}
|
||||
className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary/8 text-primary'
|
||||
: 'text-foreground hover:bg-muted/60'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" strokeWidth={isActive ? 2.2 : 1.8} />
|
||||
<span className="truncate">{p.displayName}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
||||
}
|
||||
}, [displayConfig, parsedData, onFileOpen]);
|
||||
|
||||
// Route subagent containers to dedicated component (after hooks to satisfy Rules of Hooks)
|
||||
// Route subagent containers to dedicated component (after hooks to keep call order stable)
|
||||
if (isSubagentContainer && subagentState) {
|
||||
if (mode === 'result') {
|
||||
return null;
|
||||
|
||||
@@ -108,6 +108,7 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
{activeView === 'changes' && (
|
||||
<ChangesView
|
||||
isMobile={isMobile}
|
||||
projectPath={selectedProject.fullPath}
|
||||
gitStatus={gitStatus}
|
||||
gitDiff={gitDiff}
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -9,6 +9,7 @@ import FileStatusLegend from './FileStatusLegend';
|
||||
|
||||
type ChangesViewProps = {
|
||||
isMobile: boolean;
|
||||
projectPath: string;
|
||||
gitStatus: GitStatusResponse | null;
|
||||
gitDiff: GitDiffMap;
|
||||
isLoading: boolean;
|
||||
@@ -27,6 +28,7 @@ type ChangesViewProps = {
|
||||
|
||||
export default function ChangesView({
|
||||
isMobile,
|
||||
projectPath,
|
||||
gitStatus,
|
||||
gitDiff,
|
||||
isLoading,
|
||||
@@ -131,6 +133,7 @@ export default function ChangesView({
|
||||
<>
|
||||
<CommitComposer
|
||||
isMobile={isMobile}
|
||||
projectPath={projectPath}
|
||||
selectedFileCount={selectedFiles.size}
|
||||
isHidden={hasExpandedFiles}
|
||||
onCommit={commitSelectedFiles}
|
||||
|
||||
@@ -3,8 +3,12 @@ import { useState } from 'react';
|
||||
import MicButton from '../../../mic-button/view/MicButton';
|
||||
import type { ConfirmationRequest } from '../../types/types';
|
||||
|
||||
// Persists commit messages across unmount/remount, keyed by project path
|
||||
const commitMessageCache = new Map<string, string>();
|
||||
|
||||
type CommitComposerProps = {
|
||||
isMobile: boolean;
|
||||
projectPath: string;
|
||||
selectedFileCount: number;
|
||||
isHidden: boolean;
|
||||
onCommit: (message: string) => Promise<boolean>;
|
||||
@@ -14,13 +18,24 @@ type CommitComposerProps = {
|
||||
|
||||
export default function CommitComposer({
|
||||
isMobile,
|
||||
projectPath,
|
||||
selectedFileCount,
|
||||
isHidden,
|
||||
onCommit,
|
||||
onGenerateMessage,
|
||||
onRequestConfirmation,
|
||||
}: CommitComposerProps) {
|
||||
const [commitMessage, setCommitMessage] = useState('');
|
||||
const [commitMessage, setCommitMessageRaw] = useState(() => commitMessageCache.get(projectPath) ?? '');
|
||||
|
||||
const setCommitMessage = (msg: string) => {
|
||||
setCommitMessageRaw(msg);
|
||||
if (msg) {
|
||||
commitMessageCache.set(projectPath, msg);
|
||||
} else {
|
||||
commitMessageCache.delete(projectPath);
|
||||
}
|
||||
};
|
||||
|
||||
const [isCommitting, setIsCommitting] = useState(false);
|
||||
const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(isMobile);
|
||||
|
||||
@@ -3,6 +3,7 @@ import ChatInterface from '../../chat/view/ChatInterface';
|
||||
import FileTree from '../../file-tree/view/FileTree';
|
||||
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
|
||||
import GitPanel from '../../git-panel/view/GitPanel';
|
||||
import PluginTabContent from '../../plugins/PluginTabContent';
|
||||
import type { MainContentProps } from '../types/types';
|
||||
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||
@@ -158,6 +159,16 @@ function MainContent({
|
||||
{shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}
|
||||
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`} />
|
||||
|
||||
{activeTab.startsWith('plugin:') && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<PluginTabContent
|
||||
pluginName={activeTab.replace('plugin:', '')}
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EditorSidebar
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from '../../../../shared/view/ui';
|
||||
import type { AppTab } from '../../../../types/app';
|
||||
import { usePlugins } from '../../../../contexts/PluginsContext';
|
||||
import PluginIcon from '../../../plugins/PluginIcon';
|
||||
|
||||
type MainContentTabSwitcherProps = {
|
||||
activeTab: AppTab;
|
||||
@@ -10,20 +12,32 @@ type MainContentTabSwitcherProps = {
|
||||
shouldShowTasksTab: boolean;
|
||||
};
|
||||
|
||||
type TabDefinition = {
|
||||
type BuiltInTab = {
|
||||
kind: 'builtin';
|
||||
id: AppTab;
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
const BASE_TABS: TabDefinition[] = [
|
||||
{ id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
|
||||
{ id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
|
||||
{ id: 'files', labelKey: 'tabs.files', icon: Folder },
|
||||
{ id: 'git', labelKey: 'tabs.git', icon: GitBranch },
|
||||
type PluginTab = {
|
||||
kind: 'plugin';
|
||||
id: AppTab;
|
||||
label: string;
|
||||
pluginName: string;
|
||||
iconFile: string;
|
||||
};
|
||||
|
||||
type TabDefinition = BuiltInTab | PluginTab;
|
||||
|
||||
const BASE_TABS: BuiltInTab[] = [
|
||||
{ kind: 'builtin', id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
|
||||
{ kind: 'builtin', id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
|
||||
{ kind: 'builtin', id: 'files', labelKey: 'tabs.files', icon: Folder },
|
||||
{ kind: 'builtin', id: 'git', labelKey: 'tabs.git', icon: GitBranch },
|
||||
];
|
||||
|
||||
const TASKS_TAB: TabDefinition = {
|
||||
const TASKS_TAB: BuiltInTab = {
|
||||
kind: 'builtin',
|
||||
id: 'tasks',
|
||||
labelKey: 'tabs.tasks',
|
||||
icon: ClipboardCheck,
|
||||
@@ -35,17 +49,30 @@ export default function MainContentTabSwitcher({
|
||||
shouldShowTasksTab,
|
||||
}: MainContentTabSwitcherProps) {
|
||||
const { t } = useTranslation();
|
||||
const { plugins } = usePlugins();
|
||||
|
||||
const tabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
|
||||
const builtInTabs: BuiltInTab[] = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
|
||||
|
||||
const pluginTabs: PluginTab[] = plugins
|
||||
.filter((p) => p.enabled)
|
||||
.map((p) => ({
|
||||
kind: 'plugin',
|
||||
id: `plugin:${p.name}` as AppTab,
|
||||
label: p.displayName,
|
||||
pluginName: p.name,
|
||||
iconFile: p.icon,
|
||||
}));
|
||||
|
||||
const tabs: TabDefinition[] = [...builtInTabs, ...pluginTabs];
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-[2px] rounded-lg bg-muted/60 p-[3px]">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = tab.id === activeTab;
|
||||
const displayLabel = tab.kind === 'builtin' ? t(tab.labelKey) : tab.label;
|
||||
|
||||
return (
|
||||
<Tooltip key={tab.id} content={t(tab.labelKey)} position="bottom">
|
||||
<Tooltip key={tab.id} content={displayLabel} position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative flex items-center gap-1.5 rounded-md px-2.5 py-[5px] text-sm font-medium transition-all duration-150 ${
|
||||
@@ -54,8 +81,16 @@ export default function MainContentTabSwitcher({
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
|
||||
<span className="hidden lg:inline">{t(tab.labelKey)}</span>
|
||||
{tab.kind === 'builtin' ? (
|
||||
<tab.icon className="h-3.5 w-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
|
||||
) : (
|
||||
<PluginIcon
|
||||
pluginName={tab.pluginName}
|
||||
iconFile={tab.iconFile}
|
||||
className="flex h-3.5 w-3.5 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
|
||||
/>
|
||||
)}
|
||||
<span className="hidden lg:inline">{displayLabel}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
import type { AppTab, Project, ProjectSession } from '../../../../types/app';
|
||||
import { usePlugins } from '../../../../contexts/PluginsContext';
|
||||
|
||||
type MainContentTitleProps = {
|
||||
activeTab: AppTab;
|
||||
@@ -9,7 +10,11 @@ type MainContentTitleProps = {
|
||||
shouldShowTasksTab: boolean;
|
||||
};
|
||||
|
||||
function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string) {
|
||||
function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string, pluginDisplayName?: string) {
|
||||
if (activeTab.startsWith('plugin:') && pluginDisplayName) {
|
||||
return pluginDisplayName;
|
||||
}
|
||||
|
||||
if (activeTab === 'files') {
|
||||
return t('mainContent.projectFiles');
|
||||
}
|
||||
@@ -40,6 +45,11 @@ export default function MainContentTitle({
|
||||
shouldShowTasksTab,
|
||||
}: MainContentTitleProps) {
|
||||
const { t } = useTranslation();
|
||||
const { plugins } = usePlugins();
|
||||
|
||||
const pluginDisplayName = activeTab.startsWith('plugin:')
|
||||
? plugins.find((p) => p.name === activeTab.replace('plugin:', ''))?.displayName
|
||||
: undefined;
|
||||
|
||||
const showSessionIcon = activeTab === 'chat' && Boolean(selectedSession);
|
||||
const showChatNewSession = activeTab === 'chat' && !selectedSession;
|
||||
@@ -68,7 +78,7 @@ export default function MainContentTitle({
|
||||
) : (
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold leading-tight text-foreground">
|
||||
{getTabTitle(activeTab, shouldShowTasksTab, t)}
|
||||
{getTabTitle(activeTab, shouldShowTasksTab, t, pluginDisplayName)}
|
||||
</h2>
|
||||
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>
|
||||
</div>
|
||||
|
||||
44
src/components/plugins/PluginIcon.tsx
Normal file
44
src/components/plugins/PluginIcon.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { authenticatedFetch } from '../../utils/api';
|
||||
|
||||
type Props = {
|
||||
pluginName: string;
|
||||
iconFile: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// Module-level cache so repeated renders don't re-fetch
|
||||
const svgCache = new Map<string, string>();
|
||||
|
||||
export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
||||
const url = iconFile
|
||||
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
|
||||
: '';
|
||||
const [svg, setSvg] = useState<string | null>(url ? (svgCache.get(url) ?? null) : null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url || svgCache.has(url)) return;
|
||||
authenticatedFetch(url)
|
||||
.then((r) => {
|
||||
if (!r.ok) return;
|
||||
return r.text();
|
||||
})
|
||||
.then((text) => {
|
||||
if (text && text.trimStart().startsWith('<svg')) {
|
||||
svgCache.set(url, text);
|
||||
setSvg(text);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [url]);
|
||||
|
||||
if (!svg) return <span className={className} />;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
// SVG is fetched from the user's own installed plugin — same trust level as the plugin code itself
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
444
src/components/plugins/PluginSettingsTab.tsx
Normal file
444
src/components/plugins/PluginSettingsTab.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import { useState } from 'react';
|
||||
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
|
||||
import { usePlugins } from '../../contexts/PluginsContext';
|
||||
import type { Plugin } from '../../contexts/PluginsContext';
|
||||
import PluginIcon from './PluginIcon';
|
||||
|
||||
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
|
||||
|
||||
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */
|
||||
function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {
|
||||
return (
|
||||
<label className="relative inline-flex cursor-pointer select-none items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="peer sr-only"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
relative h-5 w-9 rounded-full bg-muted transition-colors
|
||||
duration-200 after:absolute
|
||||
after:left-[2px] after:top-[2px] after:h-4 after:w-4
|
||||
after:rounded-full after:bg-white after:shadow-sm after:transition-transform after:duration-200
|
||||
after:content-[''] peer-checked:bg-emerald-500
|
||||
peer-checked:after:translate-x-4
|
||||
`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Server Dot ────────────────────────────────────────────────────────── */
|
||||
function ServerDot({ running }: { running: boolean }) {
|
||||
if (!running) return null;
|
||||
return (
|
||||
<span className="relative flex items-center gap-1.5">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<span className="font-mono text-[10px] uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
running
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Plugin Card ───────────────────────────────────────────────────────── */
|
||||
type PluginCardProps = {
|
||||
plugin: Plugin;
|
||||
index: number;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onUpdate: () => void;
|
||||
onUninstall: () => void;
|
||||
updating: boolean;
|
||||
confirmingUninstall: boolean;
|
||||
onCancelUninstall: () => void;
|
||||
updateError: string | null;
|
||||
};
|
||||
|
||||
function PluginCard({
|
||||
plugin,
|
||||
index,
|
||||
onToggle,
|
||||
onUpdate,
|
||||
onUninstall,
|
||||
updating,
|
||||
confirmingUninstall,
|
||||
onCancelUninstall,
|
||||
updateError,
|
||||
}: PluginCardProps) {
|
||||
const accentColor = plugin.enabled
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-muted-foreground/20';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex overflow-hidden rounded-lg border border-border bg-card transition-opacity duration-200"
|
||||
style={{
|
||||
opacity: plugin.enabled ? 1 : 0.65,
|
||||
animationDelay: `${index * 40}ms`,
|
||||
}}
|
||||
>
|
||||
{/* Left accent bar */}
|
||||
<div className={`w-[3px] flex-shrink-0 ${accentColor} transition-colors duration-300`} />
|
||||
|
||||
<div className="min-w-0 flex-1 p-4">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="h-5 w-5 flex-shrink-0 text-foreground/80">
|
||||
<PluginIcon
|
||||
pluginName={plugin.name}
|
||||
iconFile={plugin.icon}
|
||||
className="h-5 w-5 [&>svg]:h-full [&>svg]:w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold leading-none text-foreground">
|
||||
{plugin.displayName}
|
||||
</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
v{plugin.version}
|
||||
</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{plugin.slot}
|
||||
</span>
|
||||
<ServerDot running={!!plugin.serverRunning} />
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="mt-1 text-sm leading-snug text-muted-foreground">
|
||||
{plugin.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
{plugin.author && (
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{plugin.author}
|
||||
</span>
|
||||
)}
|
||||
{plugin.repoUrl && (
|
||||
<a
|
||||
href={plugin.repoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
<GitBranch className="h-3 w-3" />
|
||||
<span className="max-w-[200px] truncate">
|
||||
{plugin.repoUrl.replace(/^https?:\/\/(www\.)?github\.com\//, '')}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<button
|
||||
onClick={onUpdate}
|
||||
disabled={updating || !plugin.repoUrl}
|
||||
title={plugin.repoUrl ? 'Pull latest from git' : 'No git remote — update not available'}
|
||||
aria-label={`Update ${plugin.displayName}`}
|
||||
className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
{updating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onUninstall}
|
||||
title={confirmingUninstall ? 'Click again to confirm' : 'Uninstall plugin'}
|
||||
aria-label={`Uninstall ${plugin.displayName}`}
|
||||
className={`rounded p-1.5 transition-colors ${
|
||||
confirmingUninstall
|
||||
? 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-red-500'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} ariaLabel={`${plugin.enabled ? 'Disable' : 'Enable'} ${plugin.displayName}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm uninstall banner */}
|
||||
{confirmingUninstall && (
|
||||
<div className="mt-3 flex items-center justify-between gap-3 rounded border border-red-200 bg-red-50 px-3 py-2 dark:border-red-800/50 dark:bg-red-950/30">
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
Remove <span className="font-semibold">{plugin.displayName}</span>? This cannot be undone.
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={onCancelUninstall}
|
||||
className="rounded border border-border px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onUninstall}
|
||||
className="rounded border border-red-300 px-2.5 py-1 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Update error */}
|
||||
{updateError && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-sm text-red-500">
|
||||
<ServerCrash className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>{updateError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Starter Plugin Card ───────────────────────────────────────────────── */
|
||||
function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
|
||||
return (
|
||||
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
|
||||
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
|
||||
<div className="min-w-0 flex-1 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold leading-none text-foreground">
|
||||
Project Stats
|
||||
</span>
|
||||
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
|
||||
starter
|
||||
</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
tab
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-snug text-muted-foreground">
|
||||
File counts, lines of code, file-type breakdown, and recent activity for your project.
|
||||
</p>
|
||||
<a
|
||||
href={STARTER_PLUGIN_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
<GitBranch className="h-3 w-3" />
|
||||
cloudcli-ai/cloudcli-plugin-starter
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onInstall}
|
||||
disabled={installing}
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{installing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{installing ? 'Installing…' : 'Install'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main Component ────────────────────────────────────────────────────── */
|
||||
export default function PluginSettingsTab() {
|
||||
const { plugins, loading, installPlugin, uninstallPlugin, updatePlugin, togglePlugin } =
|
||||
usePlugins();
|
||||
|
||||
const [gitUrl, setGitUrl] = useState('');
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [installingStarter, setInstallingStarter] = useState(false);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
|
||||
const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
|
||||
const [updateErrors, setUpdateErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const handleUpdate = async (name: string) => {
|
||||
setUpdatingPlugins((prev) => new Set(prev).add(name));
|
||||
setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });
|
||||
const result = await updatePlugin(name);
|
||||
if (!result.success) {
|
||||
setUpdateErrors((prev) => ({ ...prev, [name]: result.error || 'Update failed' }));
|
||||
}
|
||||
setUpdatingPlugins((prev) => { const next = new Set(prev); next.delete(name); return next; });
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!gitUrl.trim()) return;
|
||||
setInstalling(true);
|
||||
setInstallError(null);
|
||||
const result = await installPlugin(gitUrl.trim());
|
||||
if (result.success) {
|
||||
setGitUrl('');
|
||||
} else {
|
||||
setInstallError(result.error || 'Installation failed');
|
||||
}
|
||||
setInstalling(false);
|
||||
};
|
||||
|
||||
const handleInstallStarter = async () => {
|
||||
setInstallingStarter(true);
|
||||
setInstallError(null);
|
||||
const result = await installPlugin(STARTER_PLUGIN_URL);
|
||||
if (!result.success) {
|
||||
setInstallError(result.error || 'Installation failed');
|
||||
}
|
||||
setInstallingStarter(false);
|
||||
};
|
||||
|
||||
const handleUninstall = async (name: string) => {
|
||||
if (confirmUninstall !== name) {
|
||||
setConfirmUninstall(name);
|
||||
return;
|
||||
}
|
||||
await uninstallPlugin(name);
|
||||
setConfirmUninstall(null);
|
||||
};
|
||||
|
||||
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="mb-1 text-base font-semibold text-foreground">
|
||||
Plugins
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Extend the interface with custom plugins. Install from{' '}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
|
||||
git
|
||||
</code>{' '}
|
||||
or drop a folder in{' '}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
|
||||
~/.claude-code-ui/plugins/
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Install from Git — compact */}
|
||||
<div className="flex items-center gap-0 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<span className="flex-shrink-0 pl-3 pr-1 text-muted-foreground/40">
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={gitUrl}
|
||||
onChange={(e) => {
|
||||
setGitUrl(e.target.value);
|
||||
setInstallError(null);
|
||||
}}
|
||||
placeholder="https://github.com/user/my-plugin"
|
||||
className="flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void handleInstall();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={installing || !gitUrl.trim()}
|
||||
className="flex-shrink-0 border-l border-border bg-foreground px-4 py-2.5 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-30"
|
||||
>
|
||||
{installing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Install'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{installError && (
|
||||
<p className="-mt-4 text-sm text-red-500">{installError}</p>
|
||||
)}
|
||||
|
||||
<p className="-mt-4 flex items-start gap-1.5 text-xs leading-snug text-muted-foreground/50">
|
||||
<ShieldAlert className="mt-px h-3 w-3 flex-shrink-0" />
|
||||
<span>
|
||||
Only install plugins whose source code you have reviewed or from authors you trust.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* Starter plugin suggestion — above the list */}
|
||||
{!loading && !hasStarterInstalled && (
|
||||
<StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} />
|
||||
)}
|
||||
|
||||
{/* Plugin List */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Scanning plugins…
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">No plugins installed</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{plugins.map((plugin, index) => (
|
||||
<PluginCard
|
||||
key={plugin.name}
|
||||
plugin={plugin}
|
||||
index={index}
|
||||
onToggle={(enabled) => void togglePlugin(plugin.name, enabled)}
|
||||
onUpdate={() => void handleUpdate(plugin.name)}
|
||||
onUninstall={() => void handleUninstall(plugin.name)}
|
||||
updating={updatingPlugins.has(plugin.name)}
|
||||
confirmingUninstall={confirmUninstall === plugin.name}
|
||||
onCancelUninstall={() => setConfirmUninstall(null)}
|
||||
updateError={updateErrors[plugin.name] ?? null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Build your own */}
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border/50 pt-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<BookOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/40" />
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
Build your own plugin
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-3">
|
||||
<a
|
||||
href={STARTER_PLUGIN_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
Starter <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
<span className="text-muted-foreground/20">·</span>
|
||||
<a
|
||||
href="https://cloudcli.ai/docs/plugin-overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
Docs <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/components/plugins/PluginTabContent.tsx
Normal file
131
src/components/plugins/PluginTabContent.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { authenticatedFetch } from '../../utils/api';
|
||||
import { usePlugins } from '../../contexts/PluginsContext';
|
||||
import type { Project, ProjectSession } from '../../types/app';
|
||||
|
||||
type PluginTabContentProps = {
|
||||
pluginName: string;
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
};
|
||||
|
||||
type PluginContext = {
|
||||
theme: 'dark' | 'light';
|
||||
project: { name: string; path: string } | null;
|
||||
session: { id: string; title: string } | null;
|
||||
};
|
||||
|
||||
function buildContext(
|
||||
isDarkMode: boolean,
|
||||
selectedProject: Project | null,
|
||||
selectedSession: ProjectSession | null,
|
||||
): PluginContext {
|
||||
return {
|
||||
theme: isDarkMode ? 'dark' : 'light',
|
||||
project: selectedProject
|
||||
? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path }
|
||||
: null,
|
||||
session: selectedSession
|
||||
? { id: selectedSession.id, title: selectedSession.title }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function PluginTabContent({
|
||||
pluginName,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
}: PluginTabContentProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { isDarkMode } = useTheme();
|
||||
const { plugins } = usePlugins();
|
||||
|
||||
// Stable refs so effects don't need context values in their dep arrays
|
||||
const contextRef = useRef<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession));
|
||||
const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set());
|
||||
|
||||
const moduleRef = useRef<any>(null);
|
||||
|
||||
const plugin = plugins.find(p => p.name === pluginName);
|
||||
|
||||
// Keep contextRef current and notify the mounted plugin on every context change
|
||||
useEffect(() => {
|
||||
const ctx = buildContext(isDarkMode, selectedProject, selectedSession);
|
||||
contextRef.current = ctx;
|
||||
|
||||
for (const cb of contextCallbacksRef.current) {
|
||||
try { cb(ctx); } catch { /* plugin error — ignore */ }
|
||||
}
|
||||
}, [isDarkMode, selectedProject, selectedSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !plugin?.enabled) return;
|
||||
|
||||
let active = true;
|
||||
const container = containerRef.current;
|
||||
const entryFile = plugin?.entry ?? 'index.js';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// Fetch the plugin JS with auth headers (Cloudflare Worker requires auth on all routes).
|
||||
// Then import it via a Blob URL so the browser never makes an unauthenticated request.
|
||||
const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${entryFile}`;
|
||||
const res = await authenticatedFetch(assetUrl);
|
||||
if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`);
|
||||
const jsText = await res.text();
|
||||
const blob = new Blob([jsText], { type: 'application/javascript' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
// @vite-ignore
|
||||
const mod = await import(/* @vite-ignore */ blobUrl).finally(() => URL.revokeObjectURL(blobUrl));
|
||||
if (!active || !containerRef.current) return;
|
||||
|
||||
moduleRef.current = mod;
|
||||
|
||||
const api = {
|
||||
get context(): PluginContext { return contextRef.current; },
|
||||
|
||||
onContextChange(cb: (ctx: PluginContext) => void): () => void {
|
||||
contextCallbacksRef.current.add(cb);
|
||||
return () => contextCallbacksRef.current.delete(cb);
|
||||
},
|
||||
|
||||
async rpc(method: string, path: string, body?: unknown): Promise<unknown> {
|
||||
const cleanPath = String(path).replace(/^\//, '');
|
||||
const res = await authenticatedFetch(
|
||||
`/api/plugins/${encodeURIComponent(pluginName)}/rpc/${cleanPath}`,
|
||||
{
|
||||
method: method || 'GET',
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`RPC error ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
await mod.mount?.(container, api);
|
||||
if (!active) {
|
||||
try { mod.unmount?.(container); } catch { /* ignore */ }
|
||||
moduleRef.current = null;
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!active) return;
|
||||
console.error(`[Plugin:${pluginName}] Failed to load:`, err);
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = `<div style="padding:16px;font-size:13px;color:#dc2626">Plugin failed to load: ${String(err)}</div>`;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ }
|
||||
contextCallbacksRef.current.clear();
|
||||
moduleRef.current = null;
|
||||
};
|
||||
}, [pluginName, plugin?.entry, plugin?.enabled]); // re-mount when plugin or enabled state changes
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full overflow-auto" />;
|
||||
}
|
||||
@@ -18,7 +18,6 @@ export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
|
||||
'git',
|
||||
'api',
|
||||
'tasks',
|
||||
'notifications',
|
||||
];
|
||||
|
||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
|
||||
|
||||
@@ -20,7 +20,6 @@ import type {
|
||||
McpServer,
|
||||
McpToolsResult,
|
||||
McpTestResult,
|
||||
NotificationPreferencesState,
|
||||
ProjectSortOrder,
|
||||
SettingsMainTab,
|
||||
SettingsProject,
|
||||
@@ -97,14 +96,9 @@ type CodexSettingsStorage = {
|
||||
permissionMode?: CodexPermissionMode;
|
||||
};
|
||||
|
||||
type NotificationPreferencesResponse = {
|
||||
success?: boolean;
|
||||
preferences?: NotificationPreferencesState;
|
||||
};
|
||||
|
||||
type ActiveLoginProvider = AgentProvider | '';
|
||||
|
||||
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications'];
|
||||
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'plugins'];
|
||||
|
||||
const normalizeMainTab = (tab: string): SettingsMainTab => {
|
||||
// Keep backwards compatibility with older callers that still pass "tools".
|
||||
@@ -192,18 +186,6 @@ const createEmptyCursorPermissions = (): CursorPermissionsState => ({
|
||||
...DEFAULT_CURSOR_PERMISSIONS,
|
||||
});
|
||||
|
||||
const createDefaultNotificationPreferences = (): NotificationPreferencesState => ({
|
||||
channels: {
|
||||
inApp: true,
|
||||
webPush: false,
|
||||
},
|
||||
events: {
|
||||
actionRequired: true,
|
||||
stop: true,
|
||||
error: true,
|
||||
},
|
||||
});
|
||||
|
||||
export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) {
|
||||
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
@@ -223,9 +205,6 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => (
|
||||
createEmptyCursorPermissions()
|
||||
));
|
||||
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferencesState>(() => (
|
||||
createDefaultNotificationPreferences()
|
||||
));
|
||||
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
||||
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
|
||||
|
||||
@@ -692,22 +671,6 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
);
|
||||
setGeminiPermissionMode(savedGeminiSettings.permissionMode || 'default');
|
||||
|
||||
try {
|
||||
const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences');
|
||||
if (notificationResponse.ok) {
|
||||
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
|
||||
if (notificationData.success && notificationData.preferences) {
|
||||
setNotificationPreferences(notificationData.preferences);
|
||||
} else {
|
||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||
}
|
||||
} else {
|
||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||
}
|
||||
} catch {
|
||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fetchMcpServers(),
|
||||
fetchCursorMcpServers(),
|
||||
@@ -717,7 +680,6 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
console.error('Error loading settings:', error);
|
||||
setClaudePermissions(createEmptyClaudePermissions());
|
||||
setCursorPermissions(createEmptyCursorPermissions());
|
||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||
setCodexPermissionMode('default');
|
||||
setProjectSortOrder('name');
|
||||
}
|
||||
@@ -738,7 +700,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
void checkAuthStatus(loginProvider);
|
||||
}, [checkAuthStatus, loginProvider]);
|
||||
|
||||
const saveSettings = useCallback(async () => {
|
||||
const saveSettings = useCallback(() => {
|
||||
setIsSaving(true);
|
||||
setSaveStatus(null);
|
||||
|
||||
@@ -769,14 +731,6 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
lastUpdated: now,
|
||||
}));
|
||||
|
||||
const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(notificationPreferences),
|
||||
});
|
||||
if (!notificationResponse.ok) {
|
||||
throw new Error('Failed to save notification preferences');
|
||||
}
|
||||
|
||||
setSaveStatus('success');
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
@@ -797,7 +751,6 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
cursorPermissions.allowedCommands,
|
||||
cursorPermissions.disallowedCommands,
|
||||
cursorPermissions.skipPermissions,
|
||||
notificationPreferences,
|
||||
onClose,
|
||||
projectSortOrder,
|
||||
]);
|
||||
@@ -874,8 +827,6 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
setClaudePermissions,
|
||||
cursorPermissions,
|
||||
setCursorPermissions,
|
||||
notificationPreferences,
|
||||
setNotificationPreferences,
|
||||
codexPermissionMode,
|
||||
setCodexPermissionMode,
|
||||
mcpServers,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications';
|
||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'plugins';
|
||||
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
|
||||
export type AgentCategory = 'account' | 'permissions' | 'mcp';
|
||||
export type ProjectSortOrder = 'name' | 'date';
|
||||
@@ -106,18 +106,6 @@ export type ClaudePermissionsState = {
|
||||
skipPermissions: boolean;
|
||||
};
|
||||
|
||||
export type NotificationPreferencesState = {
|
||||
channels: {
|
||||
inApp: boolean;
|
||||
webPush: boolean;
|
||||
};
|
||||
events: {
|
||||
actionRequired: boolean;
|
||||
stop: boolean;
|
||||
error: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type CursorPermissionsState = {
|
||||
allowedCommands: string[];
|
||||
disallowedCommands: string[];
|
||||
|
||||
@@ -9,10 +9,9 @@ import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
|
||||
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
||||
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
|
||||
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
||||
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
|
||||
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
||||
import PluginSettingsTab from '../../plugins/PluginSettingsTab';
|
||||
import { useSettingsController } from '../hooks/useSettingsController';
|
||||
import { useWebPush } from '../../../hooks/useWebPush';
|
||||
import type { SettingsProps } from '../types/types';
|
||||
|
||||
function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) {
|
||||
@@ -29,8 +28,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
updateCodeEditorSetting,
|
||||
claudePermissions,
|
||||
setClaudePermissions,
|
||||
notificationPreferences,
|
||||
setNotificationPreferences,
|
||||
cursorPermissions,
|
||||
setCursorPermissions,
|
||||
codexPermissionMode,
|
||||
@@ -75,32 +72,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
onClose,
|
||||
});
|
||||
|
||||
const {
|
||||
permission: pushPermission,
|
||||
isSubscribed: isPushSubscribed,
|
||||
isLoading: isPushLoading,
|
||||
subscribe: pushSubscribe,
|
||||
unsubscribe: pushUnsubscribe,
|
||||
} = useWebPush();
|
||||
|
||||
const handleEnablePush = async () => {
|
||||
await pushSubscribe();
|
||||
// Server sets webPush: true in preferences on subscribe; sync local state
|
||||
setNotificationPreferences({
|
||||
...notificationPreferences,
|
||||
channels: { ...notificationPreferences.channels, webPush: true },
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisablePush = async () => {
|
||||
await pushUnsubscribe();
|
||||
// Server sets webPush: false in preferences on unsubscribe; sync local state
|
||||
setNotificationPreferences({
|
||||
...notificationPreferences,
|
||||
channels: { ...notificationPreferences.channels, webPush: false },
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
@@ -190,23 +161,17 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<NotificationsSettingsTab
|
||||
notificationPreferences={notificationPreferences}
|
||||
onNotificationPreferencesChange={setNotificationPreferences}
|
||||
pushPermission={pushPermission}
|
||||
isPushSubscribed={isPushSubscribed}
|
||||
isPushLoading={isPushLoading}
|
||||
onEnablePush={handleEnablePush}
|
||||
onDisablePush={handleDisablePush}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'api' && (
|
||||
<div className="space-y-6 md:space-y-8">
|
||||
<CredentialsSettingsTab />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'plugins' && (
|
||||
<div className="space-y-6 md:space-y-8">
|
||||
<PluginSettingsTab />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GitBranch, Key } from 'lucide-react';
|
||||
import { GitBranch, Key, Puzzle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SettingsMainTab } from '../types/types';
|
||||
|
||||
@@ -9,7 +9,8 @@ type SettingsMainTabsProps = {
|
||||
|
||||
type MainTabConfig = {
|
||||
id: SettingsMainTab;
|
||||
labelKey: string;
|
||||
labelKey?: string;
|
||||
label?: string;
|
||||
icon?: typeof GitBranch;
|
||||
};
|
||||
|
||||
@@ -19,7 +20,7 @@ const TAB_CONFIG: MainTabConfig[] = [
|
||||
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
|
||||
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
||||
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
|
||||
{ id: 'notifications', labelKey: 'mainTabs.notifications' },
|
||||
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
|
||||
];
|
||||
|
||||
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
|
||||
@@ -27,7 +28,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="flex px-4 md:px-6 overflow-x-auto scrollbar-hide" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
|
||||
<div className="flex px-4 md:px-6" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
|
||||
{TAB_CONFIG.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
@@ -38,14 +39,14 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
className={`border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{Icon && <Icon className="mr-2 inline h-4 w-4" />}
|
||||
{t(tab.labelKey)}
|
||||
{tab.labelKey ? t(tab.labelKey) : tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
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,7 +255,6 @@ function ClaudePermissions({
|
||||
<li><code className="rounded bg-blue-100 px-1 dark:bg-blue-800">"Bash(rm:*)"</code> {t('permissions.toolExamples.bashRm')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import type { TFunction } from 'i18next';
|
||||
import { ScrollArea } from '../../../../shared/view/ui';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
||||
import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';
|
||||
import SidebarFooter from './SidebarFooter';
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
|
||||
import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';
|
||||
|
||||
type SearchMode = 'projects' | 'conversations';
|
||||
|
||||
@@ -19,7 +19,7 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh
|
||||
parts.push(snippet.slice(cursor, h.start));
|
||||
}
|
||||
parts.push(
|
||||
<mark key={h.start} className="bg-yellow-200 dark:bg-yellow-800 text-foreground rounded-sm px-0.5">
|
||||
<mark key={h.start} className="rounded-sm bg-yellow-200 px-0.5 text-foreground dark:bg-yellow-800">
|
||||
{snippet.slice(h.start, h.end)}
|
||||
</mark>
|
||||
);
|
||||
@@ -29,7 +29,7 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh
|
||||
parts.push(snippet.slice(cursor));
|
||||
}
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground leading-relaxed">
|
||||
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||
{parts}
|
||||
</span>
|
||||
);
|
||||
@@ -116,23 +116,23 @@ export default function SidebarContent({
|
||||
<ScrollArea className="flex-1 overflow-y-auto overscroll-contain md:px-1.5 md:py-2">
|
||||
{showConversationSearch ? (
|
||||
isSearching && !hasPartialResults ? (
|
||||
<div className="text-center py-12 md:py-8 px-4">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
|
||||
<div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
<div className="px-4 py-12 text-center md:py-8">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('search.searching')}</p>
|
||||
{searchProgress && (
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
<p className="mt-1 text-xs text-muted-foreground/60">
|
||||
{t('search.projectsScanned', { count: searchProgress.scannedProjects })}/{searchProgress.totalProjects}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : !isSearching && conversationResults && conversationResults.results.length === 0 ? (
|
||||
<div className="text-center py-12 md:py-8 px-4">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
|
||||
<Search className="w-6 h-6 text-muted-foreground" />
|
||||
<div className="px-4 py-12 text-center md:py-8">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3">
|
||||
<Search className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">{t('search.noResults')}</h3>
|
||||
<h3 className="mb-2 text-base font-medium text-foreground md:mb-1">{t('search.noResults')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('search.tryDifferentQuery')}</p>
|
||||
</div>
|
||||
) : hasPartialResults ? (
|
||||
@@ -143,7 +143,7 @@ export default function SidebarContent({
|
||||
</p>
|
||||
{isSearching && searchProgress && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 animate-spin rounded-full border-[1.5px] border-muted-foreground/40 border-t-primary" />
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground/40 border-t-primary" />
|
||||
<p className="text-[10px] text-muted-foreground/60">
|
||||
{searchProgress.scannedProjects}/{searchProgress.totalProjects}
|
||||
</p>
|
||||
@@ -151,9 +151,9 @@ export default function SidebarContent({
|
||||
)}
|
||||
</div>
|
||||
{isSearching && searchProgress && (
|
||||
<div className="mx-1 h-0.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="mx-1 h-0.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary/60 rounded-full transition-all duration-300"
|
||||
className="h-full rounded-full bg-primary/60 transition-all duration-300"
|
||||
style={{ width: `${Math.round((searchProgress.scannedProjects / searchProgress.totalProjects) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -161,15 +161,15 @@ export default function SidebarContent({
|
||||
{conversationResults.results.map((projectResult) => (
|
||||
<div key={projectResult.projectName} className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
<Folder className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{projectResult.projectDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
{projectResult.sessions.map((session) => (
|
||||
<button
|
||||
key={`${projectResult.projectName}-${session.sessionId}`}
|
||||
className="w-full text-left rounded-md px-2 py-2 hover:bg-accent/50 transition-colors"
|
||||
className="w-full rounded-md px-2 py-2 text-left transition-colors hover:bg-accent/50"
|
||||
onClick={() => onConversationResultClick(
|
||||
projectResult.projectName,
|
||||
session.sessionId,
|
||||
@@ -178,13 +178,13 @@ export default function SidebarContent({
|
||||
session.matches[0]?.snippet
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<MessageSquare className="w-3 h-3 text-primary flex-shrink-0" />
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<MessageSquare className="h-3 w-3 flex-shrink-0 text-primary" />
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{session.sessionSummary}
|
||||
</span>
|
||||
{session.provider && session.provider !== 'claude' && (
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-muted text-muted-foreground uppercase flex-shrink-0">
|
||||
<span className="flex-shrink-0 rounded bg-muted px-1 py-0.5 text-[9px] uppercase text-muted-foreground">
|
||||
{session.provider}
|
||||
</span>
|
||||
)}
|
||||
@@ -192,7 +192,7 @@ export default function SidebarContent({
|
||||
<div className="space-y-1 pl-4">
|
||||
{session.matches.map((match, idx) => (
|
||||
<div key={idx} className="flex items-start gap-1">
|
||||
<span className="text-[10px] text-muted-foreground/60 font-medium uppercase flex-shrink-0 mt-0.5">
|
||||
<span className="mt-0.5 flex-shrink-0 text-[10px] font-medium uppercase text-muted-foreground/60">
|
||||
{match.role === 'user' ? 'U' : 'A'}
|
||||
</span>
|
||||
<HighlightedSnippet
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function SidebarHeader({
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Folder className="w-3 h-3" />
|
||||
<Folder className="h-3 w-3" />
|
||||
{t('search.modeProjects')}
|
||||
</button>
|
||||
<button
|
||||
@@ -134,26 +134,26 @@ export default function SidebarHeader({
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{t('search.modeConversations')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/50 pointer-events-none" />
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
|
||||
value={searchFilter}
|
||||
onChange={(event) => onSearchFilterChange(event.target.value)}
|
||||
className="nav-search-input pl-9 pr-8 h-9 text-sm rounded-xl border-0 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all duration-200"
|
||||
className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-8 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
{searchFilter && (
|
||||
<button
|
||||
onClick={onClearSearchFilter}
|
||||
aria-label={t('tooltips.clearSearch')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 hover:bg-accent rounded-md"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-0.5 hover:bg-accent"
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -213,7 +213,7 @@ export default function SidebarHeader({
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Folder className="w-3 h-3" />
|
||||
<Folder className="h-3 w-3" />
|
||||
{t('search.modeProjects')}
|
||||
</button>
|
||||
<button
|
||||
@@ -226,26 +226,26 @@ export default function SidebarHeader({
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{t('search.modeConversations')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground/50 pointer-events-none" />
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
|
||||
value={searchFilter}
|
||||
onChange={(event) => onSearchFilterChange(event.target.value)}
|
||||
className="nav-search-input pl-10 pr-9 h-10 text-sm rounded-xl border-0 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all duration-200"
|
||||
className="nav-search-input h-10 rounded-xl border-0 pl-10 pr-9 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
{searchFilter && (
|
||||
<button
|
||||
onClick={onClearSearchFilter}
|
||||
aria-label={t('tooltips.clearSearch')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 hover:bg-accent rounded-md"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 hover:bg-accent"
|
||||
>
|
||||
<X className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
157
src/contexts/PluginsContext.tsx
Normal file
157
src/contexts/PluginsContext.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
|
||||
export type Plugin = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
icon: string;
|
||||
type: 'react' | 'module';
|
||||
slot: 'tab';
|
||||
entry: string;
|
||||
server: string | null;
|
||||
permissions: string[];
|
||||
enabled: boolean;
|
||||
serverRunning: boolean;
|
||||
dirName: string;
|
||||
repoUrl: string | null;
|
||||
};
|
||||
|
||||
type PluginsContextValue = {
|
||||
plugins: Plugin[];
|
||||
loading: boolean;
|
||||
pluginsError: string | null;
|
||||
refreshPlugins: () => Promise<void>;
|
||||
installPlugin: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
uninstallPlugin: (name: string) => Promise<{ success: boolean; error?: string }>;
|
||||
updatePlugin: (name: string) => Promise<{ success: boolean; error?: string }>;
|
||||
togglePlugin: (name: string, enabled: boolean) => Promise<{ success: boolean; error: string | null }>;
|
||||
};
|
||||
|
||||
const PluginsContext = createContext<PluginsContextValue | null>(null);
|
||||
|
||||
export function usePlugins() {
|
||||
const context = useContext(PluginsContext);
|
||||
if (!context) {
|
||||
throw new Error('usePlugins must be used within a PluginsProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function PluginsProvider({ children }: { children: ReactNode }) {
|
||||
const [plugins, setPlugins] = useState<Plugin[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pluginsError, setPluginsError] = useState<string | null>(null);
|
||||
|
||||
const refreshPlugins = useCallback(async () => {
|
||||
try {
|
||||
const res = await authenticatedFetch('/api/plugins');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPlugins(data.plugins || []);
|
||||
setPluginsError(null);
|
||||
} else {
|
||||
let errorMessage = `Failed to fetch plugins (${res.status})`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
errorMessage = data.details || data.error || errorMessage;
|
||||
} catch {
|
||||
errorMessage = res.statusText || errorMessage;
|
||||
}
|
||||
setPluginsError(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch plugins';
|
||||
setPluginsError(message);
|
||||
console.error('[Plugins] Failed to fetch plugins:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPlugins();
|
||||
}, [refreshPlugins]);
|
||||
|
||||
const installPlugin = useCallback(async (url: string) => {
|
||||
try {
|
||||
const res = await authenticatedFetch('/api/plugins/install', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
await refreshPlugins();
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: data.details || data.error || 'Install failed' };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Install failed' };
|
||||
}
|
||||
}, [refreshPlugins]);
|
||||
|
||||
const uninstallPlugin = useCallback(async (name: string) => {
|
||||
try {
|
||||
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
await refreshPlugins();
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: data.details || data.error || 'Uninstall failed' };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Uninstall failed' };
|
||||
}
|
||||
}, [refreshPlugins]);
|
||||
|
||||
const updatePlugin = useCallback(async (name: string) => {
|
||||
try {
|
||||
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/update`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
await refreshPlugins();
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: data.details || data.error || 'Update failed' };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Update failed' };
|
||||
}
|
||||
}, [refreshPlugins]);
|
||||
|
||||
const togglePlugin = useCallback(async (name: string, enabled: boolean): Promise<{ success: boolean; error: string | null }> => {
|
||||
try {
|
||||
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/enable`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let errorMessage = `Toggle failed (${res.status})`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
errorMessage = data.details || data.error || errorMessage;
|
||||
} catch {
|
||||
// response body wasn't JSON, use status text
|
||||
errorMessage = res.statusText || errorMessage;
|
||||
}
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
await refreshPlugins();
|
||||
return { success: true, error: null };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Toggle failed' };
|
||||
}
|
||||
}, [refreshPlugins]);
|
||||
|
||||
return (
|
||||
<PluginsContext.Provider value={{ plugins, loading, pluginsError, refreshPlugins, installPlugin, uninstallPlugin, updatePlugin, togglePlugin }}>
|
||||
{children}
|
||||
</PluginsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -106,10 +106,14 @@ const isUpdateAdditive = (
|
||||
|
||||
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
|
||||
|
||||
const isValidTab = (tab: string): tab is AppTab => {
|
||||
return VALID_TABS.has(tab) || tab.startsWith('plugin:');
|
||||
};
|
||||
|
||||
const readPersistedTab = (): AppTab => {
|
||||
try {
|
||||
const stored = localStorage.getItem('activeTab');
|
||||
if (stored && VALID_TABS.has(stored)) {
|
||||
if (stored && isValidTab(stored)) {
|
||||
return stored as AppTab;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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,36 +206,6 @@
|
||||
"failedToCreateFolder": "Failed to create folder"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"genericTool": "a tool",
|
||||
"codes": {
|
||||
"generic": {
|
||||
"info": {
|
||||
"title": "Notification"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"required": {
|
||||
"title": "Action Required",
|
||||
"body": "{{toolName}} is waiting for your decision."
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"stopped": {
|
||||
"title": "Run Stopped",
|
||||
"body": "Reason: {{reason}}"
|
||||
},
|
||||
"failed": {
|
||||
"title": "Run Failed"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"notification": {
|
||||
"title": "Agent Notification"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"versionUpdate": {
|
||||
"title": "Update Available",
|
||||
"newVersionReady": "A new version is ready",
|
||||
|
||||
@@ -105,26 +105,7 @@
|
||||
"git": "Git",
|
||||
"apiTokens": "API & Tokens",
|
||||
"tasks": "Tasks",
|
||||
"notifications": "Notifications"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
"plugins": "Plugins"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
@@ -206,36 +206,6 @@
|
||||
"failedToCreateFolder": "フォルダの作成に失敗しました"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"genericTool": "ツール",
|
||||
"codes": {
|
||||
"generic": {
|
||||
"info": {
|
||||
"title": "通知"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"required": {
|
||||
"title": "対応が必要です",
|
||||
"body": "{{toolName}} があなたの判断を待っています。"
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"stopped": {
|
||||
"title": "実行が停止しました",
|
||||
"body": "理由: {{reason}}"
|
||||
},
|
||||
"failed": {
|
||||
"title": "実行に失敗しました"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"notification": {
|
||||
"title": "エージェント通知"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"versionUpdate": {
|
||||
"title": "アップデートのお知らせ",
|
||||
"newVersionReady": "新しいバージョンが利用可能です",
|
||||
|
||||
@@ -105,26 +105,7 @@
|
||||
"git": "Git",
|
||||
"apiTokens": "API & トークン",
|
||||
"tasks": "タスク",
|
||||
"notifications": "通知"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知",
|
||||
"description": "受信する通知イベントを設定します。",
|
||||
"webPush": {
|
||||
"title": "Webプッシュ通知",
|
||||
"enable": "プッシュ通知を有効にする",
|
||||
"disable": "プッシュ通知を無効にする",
|
||||
"enabled": "プッシュ通知は有効です",
|
||||
"loading": "更新中...",
|
||||
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
||||
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
||||
},
|
||||
"events": {
|
||||
"title": "イベント種別",
|
||||
"actionRequired": "対応が必要",
|
||||
"stop": "実行停止",
|
||||
"error": "実行失敗"
|
||||
}
|
||||
"plugins": "プラグイン"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
@@ -206,36 +206,6 @@
|
||||
"failedToCreateFolder": "폴더 생성 실패"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"genericTool": "도구",
|
||||
"codes": {
|
||||
"generic": {
|
||||
"info": {
|
||||
"title": "알림"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"required": {
|
||||
"title": "작업 필요",
|
||||
"body": "{{toolName}} 에 대한 결정을 기다리고 있습니다."
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"stopped": {
|
||||
"title": "실행이 중지되었습니다",
|
||||
"body": "사유: {{reason}}"
|
||||
},
|
||||
"failed": {
|
||||
"title": "실행 실패"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"notification": {
|
||||
"title": "에이전트 알림"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"versionUpdate": {
|
||||
"title": "업데이트 가능",
|
||||
"newVersionReady": "새 버전이 준비되었습니다",
|
||||
|
||||
@@ -105,26 +105,7 @@
|
||||
"git": "Git",
|
||||
"apiTokens": "API & 토큰",
|
||||
"tasks": "작업",
|
||||
"notifications": "알림"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "알림",
|
||||
"description": "수신할 알림 이벤트를 설정합니다.",
|
||||
"webPush": {
|
||||
"title": "웹 푸시 알림",
|
||||
"enable": "푸시 알림 활성화",
|
||||
"disable": "푸시 알림 비활성화",
|
||||
"enabled": "푸시 알림이 활성화되었습니다",
|
||||
"loading": "업데이트 중...",
|
||||
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
||||
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
||||
},
|
||||
"events": {
|
||||
"title": "이벤트 유형",
|
||||
"actionRequired": "작업 필요",
|
||||
"stop": "실행 중지",
|
||||
"error": "실행 실패"
|
||||
}
|
||||
"plugins": "플러그인"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
@@ -206,36 +206,6 @@
|
||||
"failedToCreateFolder": "创建文件夹失败"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"genericTool": "工具",
|
||||
"codes": {
|
||||
"generic": {
|
||||
"info": {
|
||||
"title": "通知"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"required": {
|
||||
"title": "需要处理",
|
||||
"body": "{{toolName}} 正在等待你的决策。"
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"stopped": {
|
||||
"title": "运行已停止",
|
||||
"body": "原因:{{reason}}"
|
||||
},
|
||||
"failed": {
|
||||
"title": "运行失败"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"notification": {
|
||||
"title": "Agent 通知"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"versionUpdate": {
|
||||
"title": "有可用更新",
|
||||
"newVersionReady": "新版本已准备就绪",
|
||||
|
||||
@@ -105,26 +105,7 @@
|
||||
"git": "Git",
|
||||
"apiTokens": "API 和令牌",
|
||||
"tasks": "任务",
|
||||
"notifications": "通知"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知",
|
||||
"description": "控制你希望接收的通知事件。",
|
||||
"webPush": {
|
||||
"title": "Web 推送通知",
|
||||
"enable": "启用推送通知",
|
||||
"disable": "关闭推送通知",
|
||||
"enabled": "推送通知已启用",
|
||||
"loading": "更新中...",
|
||||
"unsupported": "此浏览器不支持推送通知。",
|
||||
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
|
||||
},
|
||||
"events": {
|
||||
"title": "事件类型",
|
||||
"actionRequired": "需要处理",
|
||||
"stop": "运行已停止",
|
||||
"error": "运行失败"
|
||||
}
|
||||
"plugins": "插件"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
10
src/main.jsx
10
src/main.jsx
@@ -7,10 +7,14 @@ import 'katex/dist/katex.min.css'
|
||||
// Initialize i18n
|
||||
import './i18n/config.js'
|
||||
|
||||
// Register service worker for PWA + Web Push support
|
||||
// Clean up stale service workers on app load to prevent caching issues after builds
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(err => {
|
||||
console.warn('Service worker registration failed:', err);
|
||||
navigator.serviceWorker.getRegistrations().then(registrations => {
|
||||
registrations.forEach(registration => {
|
||||
registration.unregister();
|
||||
});
|
||||
}).catch(err => {
|
||||
console.warn('Failed to unregister service workers:', err);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type SessionProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
|
||||
|
||||
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview';
|
||||
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`;
|
||||
|
||||
export interface ProjectSession {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user