diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9eb7c6e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,41 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' +type: Bug + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Error message** +If applicable, add the error message you see to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..712801b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[Feature]" +labels: '' +assignees: '' +type: Feature + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/README.de.md b/README.de.md new file mode 100644 index 0000000..a3e190d --- /dev/null +++ b/README.de.md @@ -0,0 +1,239 @@ +
+ CloudCLI UI +

Cloud CLI (auch bekannt als Claude Code UI)

+

Eine Desktop- und Mobile-Oberfläche für Claude Code, Cursor CLI, Codex und Gemini-CLI.
Lokal oder remote nutzbar – verwalte deine aktiven Projekte und Sitzungen von überall.

+
+ +

+ CloudCLI Cloud · Dokumentation · Discord · Fehler melden · Mitwirken +

+ +

+ CloudCLI Cloud + Discord beitreten +

+ siteboon%2Fclaudecodeui | Trendshift +

+ +
English · Русский · 한국어 · 中文 · 日本語 · Deutsch
+ +--- + +## Screenshots + +
+ + + + + + + + + +
+

Desktop-Ansicht

+Desktop-Oberfläche +
+Hauptoberfläche mit Projektübersicht und Chat +
+

Mobile-Erfahrung

+Mobile-Oberfläche +
+Responsives mobiles Design mit Touch-Navigation +
+

CLI-Auswahl

+CLI-Auswahl +
+Wähle zwischen Claude Code, Gemini, Cursor CLI und Codex +
+ + + +
+ +## Funktionen + +- **Responsives Design** – Funktioniert nahtlos auf Desktop, Tablet und Mobilgerät, sodass du Agents auch vom Smartphone aus nutzen kannst +- **Interaktives Chat-Interface** – Eingebaute Chat-Oberfläche für die reibungslose Kommunikation mit den Agents +- **Integriertes Shell-Terminal** – Direkter Zugriff auf die Agents CLI über die eingebaute Shell-Funktionalität +- **Datei-Explorer** – Interaktiver Dateibaum mit Syntaxhervorhebung und Live-Bearbeitung +- **Git-Explorer** – Änderungen anzeigen, stagen und committen. Branches wechseln ebenfalls möglich +- **Sitzungsverwaltung** – Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen +- **Plugin-System** – CloudCLI mit eigenen Plugins erweitern – neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) +- **TaskMaster AI Integration** *(Optional)* – Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung +- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`shared/modelConstants.js`](shared/modelConstants.js)) + + +## Schnellstart + +### CloudCLI Cloud (Empfohlen) + +Der schnellste Einstieg – keine lokale Einrichtung erforderlich. Erhalte eine vollständig verwaltete, containerisierte Entwicklungsumgebung, die über Web, Mobile App, API oder deine bevorzugte IDE erreichbar ist. + +**[Mit CloudCLI Cloud starten](https://cloudcli.ai)** + + +### Self-Hosted (Open Source) + +CloudCLI UI sofort mit **npx** ausprobieren (erfordert **Node.js** v22+): + +```bash +npx @siteboon/claude-code-ui +``` + +Oder **global** installieren für regelmäßige Nutzung: + +```bash +npm install -g @siteboon/claude-code-ui +cloudcli +``` + +Öffne `http://localhost:3001` – alle vorhandenen Sitzungen werden automatisch erkannt. + +Die **[Dokumentation →](https://cloudcli.ai/docs)** enthält weitere Konfigurationsoptionen, PM2, Remote-Server-Einrichtung und mehr. + + +--- + +## Welche Option passt zu dir? + +CloudCLI UI ist die Open-Source-UI-Schicht, die CloudCLI Cloud antreibt. Du kannst es auf deinem eigenen Rechner selbst hosten oder CloudCLI Cloud nutzen, das darauf aufbaut und eine vollständig verwaltete Cloud-Umgebung, Team-Funktionen und tiefere Integrationen bietet. + +| | CloudCLI UI (Self-hosted) | CloudCLI Cloud | +|---|---|---| +| **Am besten für** | Entwickler:innen, die eine vollständige UI für lokale Agent-Sitzungen auf ihrem eigenen Rechner möchten | Teams und Entwickler:innen, die Agents in der Cloud betreiben möchten, überall erreichbar | +| **Zugriff** | Browser via `[deineIP]:port` | Browser, jede IDE, REST API, n8n | +| **Einrichtung** | `npx @siteboon/claude-code-ui` | Keine Einrichtung erforderlich | +| **Rechner muss laufen** | Ja | Nein | +| **Mobiler Zugriff** | Jeder Browser im Netzwerk | Jedes Gerät, native App in Entwicklung | +| **Verfügbare Sitzungen** | Alle Sitzungen automatisch aus `~/.claude` erkannt | Alle Sitzungen in deiner Cloud-Umgebung | +| **Unterstützte Agents** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | +| **Datei-Explorer und Git** | Ja, direkt in der UI | Ja, direkt in der UI | +| **MCP-Konfiguration** | Über UI verwaltet, synchronisiert mit lokalem `~/.claude` | Über UI verwaltet | +| **IDE-Zugriff** | Deine lokale IDE | Jede IDE, die mit deiner Cloud-Umgebung verbunden ist | +| **REST API** | Ja | Ja | +| **n8n-Node** | Nein | Ja | +| **Team-Sharing** | Nein | Ja | +| **Plattformkosten** | Kostenlos, Open Source | Ab $7/Monat | + +> Beide Optionen verwenden deine eigenen KI-Abonnements (Claude, Cursor usw.) – CloudCLI stellt die Umgebung bereit, nicht die KI. + +--- + +## Sicherheit & Tool-Konfiguration + +**🔒 Wichtiger Hinweis**: Alle Claude Code Tools sind **standardmäßig deaktiviert**. Dies verhindert, dass potenziell schädliche Operationen automatisch ausgeführt werden. + +### Tools aktivieren + +Um den vollen Funktionsumfang von Claude Code zu nutzen, müssen Tools manuell aktiviert werden: + +1. **Tool-Einstellungen öffnen** – Klicke auf das Zahnrad-Symbol in der Seitenleiste +2. **Selektiv aktivieren** – Nur die benötigten Tools einschalten +3. **Einstellungen übernehmen** – Deine Einstellungen werden lokal gespeichert + +
+ +![Tool-Einstellungen Modal](public/screenshots/tools-modal.png) +*Tool-Einstellungen – nur aktivieren, was benötigt wird* + +
+ +**Empfohlene Vorgehensweise**: Mit grundlegenden Tools starten und bei Bedarf weitere hinzufügen. Die Einstellungen können jederzeit angepasst werden. + +--- + +## Plugins + +CloudCLI verfügt über ein Plugin-System, mit dem benutzerdefinierte Tabs mit eigener Frontend-UI und optionalem Node.js-Backend hinzugefügt werden können. Plugins können direkt in **Einstellungen > Plugins** aus Git-Repos installiert oder selbst entwickelt werden. + +### Verfügbare Plugins + +| Plugin | Beschreibung | +|---|---| +| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts | + +### Eigenes Plugin erstellen + +**[Plugin-Starter-Vorlage →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** – Forke dieses Repository, um ein eigenes Plugin zu erstellen. Es enthält ein funktionierendes Beispiel mit Frontend-Rendering, Live-Kontext-Updates und RPC-Kommunikation zu einem Backend-Server. + +**[Plugin-Dokumentation →](https://cloudcli.ai/docs/plugin-overview)** – Vollständige Anleitung zur Plugin-API, zum Manifest-Format, zum Sicherheitsmodell und mehr. + +--- +## FAQ + +
+Wie unterscheidet sich das von Claude Code Remote Control? + +Claude Code Remote Control ermöglicht es, Nachrichten an eine bereits im lokalen Terminal laufende Sitzung zu senden. Der Rechner muss eingeschaltet bleiben, das Terminal muss offen bleiben, und Sitzungen laufen nach etwa 10 Minuten ohne Netzwerkverbindung ab. + +CloudCLI UI und CloudCLI Cloud erweitern Claude Code, anstatt neben ihm zu laufen – MCP-Server, Berechtigungen, Einstellungen und Sitzungen sind exakt dieselben, die Claude Code nativ verwendet. Nichts wird dupliziert oder separat verwaltet. + +Das bedeutet in der Praxis: + +- **Alle Sitzungen, nicht nur eine** – CloudCLI UI erkennt automatisch jede Sitzung aus dem `~/.claude`-Ordner. Remote Control stellt nur die einzelne aktive Sitzung bereit, um sie in der Claude Mobile App verfügbar zu machen. +- **Deine Einstellungen sind deine Einstellungen** – MCP-Server, Tool-Berechtigungen und Projektkonfiguration, die in CloudCLI UI geändert werden, werden direkt in die Claude Code-Konfiguration geschrieben und treten sofort in Kraft – und umgekehrt. +- **Funktioniert mit mehr Agents** – Claude Code, Cursor CLI, Codex und Gemini CLI, nicht nur Claude Code. +- **Vollständige UI, nicht nur ein Chat-Fenster** – Datei-Explorer, Git-Integration, MCP-Verwaltung und ein Shell-Terminal sind alle eingebaut. +- **CloudCLI Cloud läuft in der Cloud** – Laptop zuklappen, der Agent läuft weiter. Kein Terminal zu überwachen, kein Rechner, der laufen muss. + +
+ +
+Muss ich ein KI-Abonnement separat bezahlen? + +Ja. CloudCLI stellt die Umgebung bereit, nicht die KI. Du bringst dein eigenes Claude-, Cursor-, Codex- oder Gemini-Abonnement mit. CloudCLI Cloud beginnt bei $7/Monat für die gehostete Umgebung zusätzlich dazu. + +
+ +
+Kann ich CloudCLI UI auf meinem Smartphone nutzen? + +Ja. Bei Self-Hosted: Server auf dem eigenen Rechner starten und `[deineIP]:port` in einem beliebigen Browser im Netzwerk öffnen. Bei CloudCLI Cloud: Von jedem Gerät aus öffnen – kein VPN, keine Portweiterleitung, keine Einrichtung. Eine native App ist ebenfalls in Entwicklung. + +
+ +
+Wirken sich Änderungen in der UI auf mein lokales Claude Code-Setup aus? + +Ja, bei Self-Hosted. CloudCLI UI liest aus und schreibt in dieselbe `~/.claude`-Konfiguration, die Claude Code nativ verwendet. MCP-Server, die über die UI hinzugefügt werden, erscheinen sofort in Claude Code und umgekehrt. + +
+ +--- + +## Community & Support + +- **[Dokumentation](https://cloudcli.ai/docs)** — Installation, Konfiguration, Funktionen und Fehlerbehebung +- **[Discord](https://discord.gg/buxwujPNRE)** — Hilfe erhalten und mit anderen Nutzer:innen in Kontakt treten +- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — Fehlerberichte und Feature-Anfragen +- **[Beitragsrichtlinien](CONTRIBUTING.md)** — So kannst du zum Projekt beitragen + +## Lizenz + +GNU General Public License v3.0 – siehe [LICENSE](LICENSE)-Datei für Details. + +Dieses Projekt ist Open Source und kann unter der GPL v3-Lizenz kostenlos genutzt, modifiziert und verteilt werden. + +## Danksagungen + +### Erstellt mit +- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropics offizielle CLI +- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursors offizielle CLI +- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex +- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI +- **[React](https://react.dev/)** - UI-Bibliothek +- **[Vite](https://vitejs.dev/)** - Schnelles Build-Tool und Dev-Server +- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS-Framework +- **[CodeMirror](https://codemirror.net/)** - Erweiterter Code-Editor +- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(Optional)* - KI-gestütztes Projektmanagement und Aufgabenplanung + + +### Sponsoren +- [Siteboon - KI-gestützter Website-Builder](https://siteboon.ai) +--- + +
+ Mit Sorgfalt für die Claude Code-, Cursor- und Codex-Community erstellt. +
diff --git a/README.ja.md b/README.ja.md index a64e3df..6073e53 100644 --- a/README.ja.md +++ b/README.ja.md @@ -6,7 +6,7 @@ [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) 向けのデスクトップ・モバイル UI です。ローカルまたはリモートで使用して、Claude Code、Cursor、Codex のアクティブなプロジェクトやセッションを確認し、どこからでも(モバイルやデスクトップから)変更を加えることができます。どこでも使える適切なインターフェースを提供します。 -
English · Русский · 한국어 · 中文 · 日本語
+
English · Русский · 한국어 · 中文 · 日本語 · Deutsch
## スクリーンショット diff --git a/README.ko.md b/README.ko.md index 4ac511a..8cbbe8f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -6,7 +6,7 @@ [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)를 위한 데스크톱 및 모바일 UI입니다. 로컬 또는 원격으로 사용하여 Claude Code, Cursor 또는 Codex의 활성 프로젝트와 세션을 확인하고, 어디서든(모바일 또는 데스크톱) 변경할 수 있습니다. 어디서든 작동하는 적절한 인터페이스를 제공합니다. -
English · Русский · 한국어 · 中文 · 日本語
+
English · Русский · 한국어 · 中文 · 日本語 · Deutsch
## 스크린샷 diff --git a/README.md b/README.md index 3eb4b0f..7df11e2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ siteboon%2Fclaudecodeui | Trendshift

-
English · Русский · 한국어 · 中文 · 日本語
+
English · Русский · 한국어 · 中文 · 日本語 · Deutsch
--- diff --git a/README.ru.md b/README.ru.md index d864711..06e0f98 100644 --- a/README.ru.md +++ b/README.ru.md @@ -15,7 +15,7 @@ siteboon%2Fclaudecodeui | Trendshift

-
English · Русский · 한국어 · 中文 · 日本語
+
English · Русский · 한국어 · 中文 · 日本語 · Deutsch
## Скриншоты diff --git a/README.zh-CN.md b/README.zh-CN.md index 1ccad8a..54d94c5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@ [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) 的桌面端和移动端界面。您可以在本地或远程使用它来查看 Claude Code、Cursor 或 Codex 中的活跃项目和会话,并从任何地方(移动端或桌面端)对它们进行修改。这为您提供了一个在任何地方都能正常使用的合适界面。 -
English · Русский · 한국어 · 中文 · 日本語
+
English · Русский · 한국어 · 中文 · 日本語 · Deutsch
## 截图 diff --git a/package-lock.json b/package-lock.json index 82ad891..979d333 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,7 @@ "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "tailwind-merge": "^3.3.1", + "web-push": "^3.6.7", "ws": "^8.14.2" }, "bin": { @@ -1903,12 +1904,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", - "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1919,12 +1921,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", - "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2047,9 +2050,9 @@ } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", - "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -2065,13 +2068,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", - "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -2087,7 +2090,7 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { @@ -2111,9 +2114,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", - "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], @@ -4608,7 +4611,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -4914,6 +4916,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -5176,6 +5190,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -8950,6 +8970,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -8991,7 +9020,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -11749,6 +11777,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -14767,6 +14801,40 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", + "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", + "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/sharp/node_modules/@img/sharp-linux-arm": { "version": "0.34.3", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", @@ -14836,6 +14904,72 @@ "@img/sharp-libvips-linux-x64": "1.2.0" } }, + "node_modules/sharp/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", + "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", + "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + } + }, + "node_modules/sharp/node_modules/@img/sharp-win32-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", + "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/sharp/node_modules/@img/sharp-win32-x64": { "version": "0.34.3", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", @@ -17067,6 +17201,46 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/web-push/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 6bdb1c9..f98afb0 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "tailwind-merge": "^3.3.1", + "web-push": "^3.6.7", "ws": "^8.14.2" }, "devDependencies": { diff --git a/public/sw.js b/public/sw.js index 181c60d..f521dda 100755 --- a/public/sw.js +++ b/public/sw.js @@ -19,14 +19,17 @@ self.addEventListener('install', event => { // Fetch event self.addEventListener('fetch', event => { + // Never cache API requests or WebSocket upgrades + if (event.request.url.includes('/api/') || event.request.url.includes('/ws')) { + return; + } + event.respondWith( caches.match(event.request) .then(response => { - // Return cached response if found if (response) { return response; } - // Otherwise fetch from network return fetch(event.request); } ) @@ -46,4 +49,57 @@ self.addEventListener('activate', event => { ); }) ); -}); \ No newline at end of file + self.clients.claim(); +}); + +// Push notification event +self.addEventListener('push', event => { + if (!event.data) return; + + let payload; + try { + payload = event.data.json(); + } catch { + payload = { title: 'Claude Code UI', body: event.data.text() }; + } + + const options = { + body: payload.body || '', + icon: '/logo-256.png', + badge: '/logo-128.png', + data: payload.data || {}, + tag: payload.data?.tag || `${payload.data?.sessionId || 'global'}:${payload.data?.code || 'default'}`, + renotify: true + }; + + event.waitUntil( + self.registration.showNotification(payload.title || 'Claude Code UI', options) + ); +}); + +// Notification click event +self.addEventListener('notificationclick', event => { + event.notification.close(); + + const sessionId = event.notification.data?.sessionId; + const provider = event.notification.data?.provider || null; + const urlPath = sessionId ? `/session/${sessionId}` : '/'; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async clientList => { + for (const client of clientList) { + if (client.url.includes(self.location.origin)) { + await client.focus(); + client.postMessage({ + type: 'notification:navigate', + sessionId: sessionId || null, + provider, + urlPath + }); + return; + } + } + return self.clients.openWindow(urlPath); + }) + ); +}); diff --git a/server/claude-sdk.js b/server/claude-sdk.js index cdf41ff..2bc80b0 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -18,6 +18,12 @@ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import { CLAUDE_MODELS } from '../shared/modelConstants.js'; +import { + createNotificationEvent, + notifyRunFailed, + notifyRunStopped, + notifyUserIfEnabled +} from './services/notification-orchestrator.js'; const activeSessions = new Map(); const pendingToolApprovals = new Map(); @@ -461,12 +467,20 @@ async function loadMcpConfig(cwd) { * @returns {Promise} */ async function queryClaudeSDK(command, options = {}, ws) { - const { sessionId } = options; + const { sessionId, sessionSummary } = options; let capturedSessionId = sessionId; let sessionCreatedSent = false; let tempImagePaths = []; let tempDir = null; + const emitNotification = (event) => { + notifyUserIfEnabled({ + userId: ws?.userId || null, + writer: ws, + event + }); + }; + try { // Map CLI options to SDK format const sdkOptions = mapCliOptionsToSDK(options); @@ -483,6 +497,26 @@ async function queryClaudeSDK(command, options = {}, ws) { tempImagePaths = imageResult.tempImagePaths; tempDir = imageResult.tempDir; + sdkOptions.hooks = { + Notification: [{ + matcher: '', + hooks: [async (input) => { + const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.'; + emitNotification(createNotificationEvent({ + provider: 'claude', + sessionId: capturedSessionId || sessionId || null, + kind: 'action_required', + code: 'agent.notification', + meta: { message, sessionName: sessionSummary }, + severity: 'warning', + requiresUserAction: true, + dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}` + })); + return {}; + }] + }] + }; + sdkOptions.canUseTool = async (toolName, input, context) => { const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName); @@ -514,6 +548,16 @@ async function queryClaudeSDK(command, options = {}, ws) { input, sessionId: capturedSessionId || sessionId || null }); + emitNotification(createNotificationEvent({ + provider: 'claude', + sessionId: capturedSessionId || sessionId || null, + kind: 'action_required', + code: 'permission.required', + meta: { toolName, sessionName: sessionSummary }, + severity: 'warning', + requiresUserAction: true, + dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}` + })); const decision = await waitForToolApproval(requestId, { timeoutMs: requiresInteraction ? 0 : undefined, @@ -560,10 +604,22 @@ async function queryClaudeSDK(command, options = {}, ws) { const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000'; - const queryInstance = query({ - prompt: finalCommand, - options: sdkOptions - }); + let queryInstance; + try { + queryInstance = query({ + prompt: finalCommand, + options: sdkOptions + }); + } catch (hookError) { + // Older/newer SDK versions may not accept hook shapes yet. + // Keep notification behavior operational via runtime events even if hook registration fails. + console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError); + delete sdkOptions.hooks; + queryInstance = query({ + prompt: finalCommand, + options: sdkOptions + }); + } // Restore immediately — Query constructor already captured the value if (prevStreamTimeout !== undefined) { @@ -647,6 +703,13 @@ async function queryClaudeSDK(command, options = {}, ws) { exitCode: 0, isNewSession: !sessionId && !!command }); + notifyRunStopped({ + userId: ws?.userId || null, + provider: 'claude', + sessionId: capturedSessionId || sessionId || null, + sessionName: sessionSummary, + stopReason: 'completed' + }); console.log('claude-complete event sent'); } catch (error) { @@ -666,6 +729,13 @@ async function queryClaudeSDK(command, options = {}, ws) { error: error.message, sessionId: capturedSessionId || sessionId || null }); + notifyRunFailed({ + userId: ws?.userId || null, + provider: 'claude', + sessionId: capturedSessionId || sessionId || null, + sessionName: sessionSummary, + error + }); throw error; } diff --git a/server/cursor-cli.js b/server/cursor-cli.js index f5fe7db..d354723 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -1,5 +1,6 @@ import { spawn } from 'child_process'; import crossSpawn from 'cross-spawn'; +import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; // Use cross-spawn on Windows for better command execution const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; @@ -23,7 +24,7 @@ function isWorkspaceTrustPrompt(text = '') { async function spawnCursor(command, options = {}, ws) { return new Promise(async (resolve, reject) => { - const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options; + const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary } = options; let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event let hasRetriedWithTrust = false; @@ -81,6 +82,35 @@ async function spawnCursor(command, options = {}, ws) { const isTrustRetry = runReason === 'trust-retry'; let runSawWorkspaceTrustPrompt = false; let stdoutLineBuffer = ''; + let terminalNotificationSent = false; + + const notifyTerminalState = ({ code = null, error = null } = {}) => { + if (terminalNotificationSent) { + return; + } + + terminalNotificationSent = true; + + const finalSessionId = capturedSessionId || sessionId || processKey; + if (code === 0 && !error) { + notifyRunStopped({ + userId: ws?.userId || null, + provider: 'cursor', + sessionId: finalSessionId, + sessionName: sessionSummary, + stopReason: 'completed' + }); + return; + } + + notifyRunFailed({ + userId: ws?.userId || null, + provider: 'cursor', + sessionId: finalSessionId, + sessionName: sessionSummary, + error: error || `Cursor CLI exited with code ${code}` + }); + }; if (isTrustRetry) { console.log('Retrying Cursor CLI with --trust after workspace trust prompt'); @@ -255,7 +285,8 @@ async function spawnCursor(command, options = {}, ws) { ws.send({ type: 'cursor-error', error: stderrText, - sessionId: capturedSessionId || sessionId || null + sessionId: capturedSessionId || sessionId || null, + provider: 'cursor' }); }); @@ -287,12 +318,15 @@ async function spawnCursor(command, options = {}, ws) { type: 'claude-complete', sessionId: finalSessionId, exitCode: code, + provider: 'cursor', isNewSession: !sessionId && !!command // Flag to indicate this was a new session }); if (code === 0) { + notifyTerminalState({ code }); settleOnce(() => resolve()); } else { + notifyTerminalState({ code }); settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`))); } }); @@ -308,8 +342,10 @@ async function spawnCursor(command, options = {}, ws) { ws.send({ type: 'cursor-error', error: error.message, - sessionId: capturedSessionId || sessionId || null + sessionId: capturedSessionId || sessionId || null, + provider: 'cursor' }); + notifyTerminalState({ error }); settleOnce(() => reject(error)); }); diff --git a/server/database/db.js b/server/database/db.js index bb90c61..9ab0ad7 100644 --- a/server/database/db.js +++ b/server/database/db.js @@ -100,6 +100,35 @@ const runMigrations = () => { db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0'); } + db.exec(` + CREATE TABLE IF NOT EXISTS user_notification_preferences ( + user_id INTEGER PRIMARY KEY, + preferences_json TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS vapid_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + keys_p256dh TEXT NOT NULL, + keys_auth TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); // Create app_config table if it doesn't exist (for existing installations) db.exec(`CREATE TABLE IF NOT EXISTS app_config ( key TEXT PRIMARY KEY, @@ -376,6 +405,116 @@ const credentialsDb = { } }; +const DEFAULT_NOTIFICATION_PREFERENCES = { + channels: { + inApp: false, + webPush: false + }, + events: { + actionRequired: true, + stop: true, + error: true + } +}; + +const normalizeNotificationPreferences = (value) => { + const source = value && typeof value === 'object' ? value : {}; + + return { + channels: { + inApp: source.channels?.inApp === true, + webPush: source.channels?.webPush === true + }, + events: { + actionRequired: source.events?.actionRequired !== false, + stop: source.events?.stop !== false, + error: source.events?.error !== false + } + }; +}; + +const notificationPreferencesDb = { + getPreferences: (userId) => { + try { + const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId); + if (!row) { + const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES); + db.prepare( + 'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)' + ).run(userId, JSON.stringify(defaults)); + return defaults; + } + + let parsed; + try { + parsed = JSON.parse(row.preferences_json); + } catch { + parsed = DEFAULT_NOTIFICATION_PREFERENCES; + } + return normalizeNotificationPreferences(parsed); + } catch (err) { + throw err; + } + }, + + updatePreferences: (userId, preferences) => { + try { + const normalized = normalizeNotificationPreferences(preferences); + db.prepare( + `INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET + preferences_json = excluded.preferences_json, + updated_at = CURRENT_TIMESTAMP` + ).run(userId, JSON.stringify(normalized)); + return normalized; + } catch (err) { + throw err; + } + } +}; + +const pushSubscriptionsDb = { + saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => { + try { + db.prepare( + `INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth) + VALUES (?, ?, ?, ?) + ON CONFLICT(endpoint) DO UPDATE SET + user_id = excluded.user_id, + keys_p256dh = excluded.keys_p256dh, + keys_auth = excluded.keys_auth` + ).run(userId, endpoint, keysP256dh, keysAuth); + } catch (err) { + throw err; + } + }, + + getSubscriptions: (userId) => { + try { + return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId); + } catch (err) { + throw err; + } + }, + + removeSubscription: (endpoint) => { + try { + db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint); + } catch (err) { + throw err; + } + }, + + removeAllForUser: (userId) => { + try { + db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId); + } catch (err) { + throw err; + } + } +}; + // Session custom names database operations const sessionNamesDb = { // Set (insert or update) a custom session name @@ -482,8 +621,10 @@ export { userDb, apiKeysDb, credentialsDb, + notificationPreferencesDb, + pushSubscriptionsDb, sessionNamesDb, applyCustomSessionNames, appConfigDb, githubTokensDb // Backward compatibility -}; \ No newline at end of file +}; diff --git a/server/database/init.sql b/server/database/init.sql index 71ba1bb..9835151 100644 --- a/server/database/init.sql +++ b/server/database/init.sql @@ -51,6 +51,33 @@ CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type); CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active); +-- User notification preferences (backend-owned, provider-agnostic) +CREATE TABLE IF NOT EXISTS user_notification_preferences ( + user_id INTEGER PRIMARY KEY, + preferences_json TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- VAPID key pair for Web Push notifications +CREATE TABLE IF NOT EXISTS vapid_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Browser push subscriptions +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + keys_p256dh TEXT NOT NULL, + keys_auth TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + -- Session custom names (provider-agnostic display name overrides) CREATE TABLE IF NOT EXISTS session_names ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -69,4 +96,4 @@ CREATE TABLE IF NOT EXISTS app_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); \ No newline at end of file +); diff --git a/server/gemini-cli.js b/server/gemini-cli.js index 0c6506a..3a2c968 100644 --- a/server/gemini-cli.js +++ b/server/gemini-cli.js @@ -9,11 +9,12 @@ import os from 'os'; import { getSessions, getSessionMessages } from './projects.js'; import sessionManager from './sessionManager.js'; import GeminiResponseHandler from './gemini-response-handler.js'; +import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; let activeGeminiProcesses = new Map(); // Track active processes by session ID async function spawnGemini(command, options = {}, ws) { - const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options; + const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images, sessionSummary } = options; let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event let assistantBlocks = []; // Accumulate the full response blocks including tools @@ -172,6 +173,36 @@ async function spawnGemini(command, options = {}, ws) { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } // Inherit all environment variables }); + let terminalNotificationSent = false; + let terminalFailureReason = null; + + const notifyTerminalState = ({ code = null, error = null } = {}) => { + if (terminalNotificationSent) { + return; + } + + terminalNotificationSent = true; + + const finalSessionId = capturedSessionId || sessionId || processKey; + if (code === 0 && !error) { + notifyRunStopped({ + userId: ws?.userId || null, + provider: 'gemini', + sessionId: finalSessionId, + sessionName: sessionSummary, + stopReason: 'completed' + }); + return; + } + + notifyRunFailed({ + userId: ws?.userId || null, + provider: 'gemini', + sessionId: finalSessionId, + sessionName: sessionSummary, + error: error || terminalFailureReason || `Gemini CLI exited with code ${code}` + }); + }; // Attach temp file info to process for cleanup later geminiProcess.tempImagePaths = tempImagePaths; @@ -196,10 +227,12 @@ async function spawnGemini(command, options = {}, ws) { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => { const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey); + terminalFailureReason = `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`; ws.send({ type: 'gemini-error', sessionId: socketSessionId, - error: `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds` + error: terminalFailureReason, + provider: 'gemini' }); try { geminiProcess.kill('SIGTERM'); @@ -340,7 +373,8 @@ async function spawnGemini(command, options = {}, ws) { ws.send({ type: 'gemini-error', sessionId: socketSessionId, - error: errorMsg + error: errorMsg, + provider: 'gemini' }); }); @@ -367,6 +401,7 @@ async function spawnGemini(command, options = {}, ws) { type: 'claude-complete', // Use claude-complete for compatibility with UI sessionId: finalSessionId, exitCode: code, + provider: 'gemini', isNewSession: !sessionId && !!command // Flag to indicate this was a new session }); @@ -381,8 +416,13 @@ async function spawnGemini(command, options = {}, ws) { } if (code === 0) { + notifyTerminalState({ code }); resolve(); } else { + notifyTerminalState({ + code, + error: code === null ? 'Gemini CLI process was terminated or timed out' : null + }); reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`)); } }); @@ -397,8 +437,10 @@ async function spawnGemini(command, options = {}, ws) { ws.send({ type: 'gemini-error', sessionId: errorSessionId, - error: error.message + error: error.message, + provider: 'gemini' }); + notifyTerminalState({ error }); reject(error); }); diff --git a/server/index.js b/server/index.js index 770575a..d7e4d52 100755 --- a/server/index.js +++ b/server/index.js @@ -67,6 +67,7 @@ import geminiRoutes from './routes/gemini.js'; import pluginsRoutes from './routes/plugins.js'; import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js'; import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js'; +import { configureWebPush } from './services/vapid-keys.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { IS_PLATFORM } from './constants/config.js'; import { getConnectableHost } from '../shared/networkHosts.js'; @@ -1407,7 +1408,7 @@ wss.on('connection', (ws, request) => { if (pathname === '/shell') { handleShellConnection(ws); } else if (pathname === '/ws') { - handleChatConnection(ws); + handleChatConnection(ws, request); } else { console.log('[WARN] Unknown WebSocket path:', pathname); ws.close(); @@ -1418,9 +1419,10 @@ wss.on('connection', (ws, request) => { * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface */ class WebSocketWriter { - constructor(ws) { + constructor(ws, userId = null) { this.ws = ws; this.sessionId = null; + this.userId = userId; this.isWebSocketWriter = true; // Marker for transport detection } @@ -1445,14 +1447,14 @@ class WebSocketWriter { } // Handle chat WebSocket connections -function handleChatConnection(ws) { +function handleChatConnection(ws, request) { console.log('[INFO] Chat WebSocket connected'); // Add to connected clients for project updates connectedClients.add(ws); // Wrap WebSocket with writer for consistent interface with SSEStreamWriter - const writer = new WebSocketWriter(ws); + const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null); ws.on('message', async (message) => { try { @@ -2502,6 +2504,9 @@ async function startServer() { // Initialize authentication database await initializeDatabase(); + // Configure Web Push (VAPID keys) + configureWebPush(); + // Check if running in production mode (dist folder exists) const distIndexPath = path.join(__dirname, '../dist/index.html'); const isProduction = fs.existsSync(distIndexPath); diff --git a/server/middleware/auth.js b/server/middleware/auth.js index bdfd0f5..7374979 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -95,7 +95,7 @@ const authenticateWebSocket = (token) => { try { const user = userDb.getFirstUser(); if (user) { - return { userId: user.id, username: user.username }; + return { id: user.id, userId: user.id, username: user.username }; } return null; } catch (error) { @@ -129,4 +129,4 @@ export { generateToken, authenticateWebSocket, JWT_SECRET -}; \ No newline at end of file +}; diff --git a/server/openai-codex.js b/server/openai-codex.js index bd368ff..a12f7e0 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -14,6 +14,7 @@ */ import { Codex } from '@openai/codex-sdk'; +import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; // Track active sessions const activeCodexSessions = new Map(); @@ -191,6 +192,7 @@ function mapPermissionModeToCodexOptions(permissionMode) { export async function queryCodex(command, options = {}, ws) { const { sessionId, + sessionSummary, cwd, projectPath, model, @@ -203,6 +205,7 @@ export async function queryCodex(command, options = {}, ws) { let codex; let thread; let currentSessionId = sessionId; + let terminalFailure = null; const abortController = new AbortController(); try { @@ -268,6 +271,17 @@ export async function queryCodex(command, options = {}, ws) { sessionId: currentSessionId }); + if (event.type === 'turn.failed' && !terminalFailure) { + terminalFailure = event.error || new Error('Turn failed'); + notifyRunFailed({ + userId: ws?.userId || null, + provider: 'codex', + sessionId: currentSessionId, + sessionName: sessionSummary, + error: terminalFailure + }); + } + // Extract and send token usage if available (normalized to match Claude format) if (event.type === 'turn.completed' && event.usage) { const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0); @@ -283,11 +297,21 @@ export async function queryCodex(command, options = {}, ws) { } // Send completion event - sendMessage(ws, { - type: 'codex-complete', - sessionId: currentSessionId, - actualSessionId: thread.id - }); + if (!terminalFailure) { + sendMessage(ws, { + type: 'codex-complete', + sessionId: currentSessionId, + actualSessionId: thread.id, + provider: 'codex' + }); + notifyRunStopped({ + userId: ws?.userId || null, + provider: 'codex', + sessionId: currentSessionId, + sessionName: sessionSummary, + stopReason: 'completed' + }); + } } catch (error) { const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null; @@ -301,8 +325,18 @@ export async function queryCodex(command, options = {}, ws) { sendMessage(ws, { type: 'codex-error', error: error.message, - sessionId: currentSessionId + sessionId: currentSessionId, + provider: 'codex' }); + if (!terminalFailure) { + notifyRunFailed({ + userId: ws?.userId || null, + provider: 'codex', + sessionId: currentSessionId, + sessionName: sessionSummary, + error + }); + } } } finally { diff --git a/server/routes/agent.js b/server/routes/agent.js index 8bc88c9..bf2d36d 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -450,9 +450,10 @@ async function cleanupProject(projectPath, sessionId = null) { * SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events */ class SSEStreamWriter { - constructor(res) { + constructor(res, userId = null) { this.res = res; this.sessionId = null; + this.userId = userId; this.isSSEStreamWriter = true; // Marker for transport detection } @@ -485,9 +486,10 @@ class SSEStreamWriter { * Non-streaming response collector */ class ResponseCollector { - constructor() { + constructor(userId = null) { this.messages = []; this.sessionId = null; + this.userId = userId; } send(data) { @@ -920,7 +922,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering - writer = new SSEStreamWriter(res); + writer = new SSEStreamWriter(res, req.user.id); // Send initial status writer.send({ @@ -930,7 +932,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { }); } else { // Non-streaming mode: collect messages - writer = new ResponseCollector(); + writer = new ResponseCollector(req.user.id); // Collect initial status message writer.send({ @@ -1219,7 +1221,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); - writer = new SSEStreamWriter(res); + writer = new SSEStreamWriter(res, req.user.id); } if (!res.writableEnded) { diff --git a/server/routes/cli-auth.js b/server/routes/cli-auth.js index cb7275d..78ffa30 100644 --- a/server/routes/cli-auth.js +++ b/server/routes/cli-auth.js @@ -96,10 +96,27 @@ router.get('/gemini/status', async (req, res) => { } }); +async function loadClaudeSettingsEnv() { + try { + const settingsPath = path.join(os.homedir(), '.claude', 'settings.json'); + const content = await fs.readFile(settingsPath, 'utf8'); + const settings = JSON.parse(content); + + if (settings?.env && typeof settings.env === 'object') { + return settings.env; + } + } catch (error) { + // Ignore missing or malformed settings and fall back to other auth sources. + } + + return {}; +} + /** * Checks Claude authentication credentials using two methods with priority order: * * Priority 1: ANTHROPIC_API_KEY environment variable + * Priority 1b: ~/.claude/settings.json env values * Priority 2: ~/.claude/.credentials.json OAuth tokens * * The Claude Agent SDK prioritizes environment variables over authenticated subscriptions. @@ -128,6 +145,27 @@ async function checkClaudeCredentials() { }; } + // Priority 1b: Check ~/.claude/settings.json env values. + // Claude Code can read proxy/auth values from settings.json even when the + // CloudCLI server process itself was not started with those env vars exported. + const settingsEnv = await loadClaudeSettingsEnv(); + + if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) { + return { + authenticated: true, + email: 'API Key Auth', + method: 'api_key' + }; + } + + if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) { + return { + authenticated: true, + email: 'Configured via settings.json', + method: 'api_key' + }; + } + // Priority 2: Check ~/.claude/.credentials.json for OAuth tokens // This is the standard authentication method used by Claude CLI after running // 'claude /login' or 'claude setup-token' commands. diff --git a/server/routes/git.js b/server/routes/git.js index 701c3be..a439563 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -651,26 +651,28 @@ router.get('/branches', async (req, res) => { // Get all branches const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath }); - - // Parse branches - const branches = stdout + + const rawLines = stdout .split('\n') - .map(branch => branch.trim()) - .filter(branch => branch && !branch.includes('->')) // Remove empty lines and HEAD pointer - .map(branch => { - // Remove asterisk from current branch - if (branch.startsWith('* ')) { - return branch.substring(2); - } - // Remove remotes/ prefix - if (branch.startsWith('remotes/origin/')) { - return branch.substring(15); - } - return branch; - }) - .filter((branch, index, self) => self.indexOf(branch) === index); // Remove duplicates - - res.json({ branches }); + .map(b => b.trim()) + .filter(b => b && !b.includes('->')); + + // Local branches (may start with '* ' for current) + const localBranches = rawLines + .filter(b => !b.startsWith('remotes/')) + .map(b => (b.startsWith('* ') ? b.substring(2) : b)); + + // Remote branches — strip 'remotes//' prefix + const remoteBranches = rawLines + .filter(b => b.startsWith('remotes/')) + .map(b => b.replace(/^remotes\/[^/]+\//, '')) + .filter(name => !localBranches.includes(name)); // skip if already a local branch + + // Backward-compat flat list (local + unique remotes, deduplicated) + const branches = [...localBranches, ...remoteBranches] + .filter((b, i, arr) => arr.indexOf(b) === i); + + res.json({ branches, localBranches, remoteBranches }); } catch (error) { console.error('Git branches error:', error); res.json({ error: error.message }); @@ -721,6 +723,32 @@ router.post('/create-branch', async (req, res) => { } }); +// Delete a local branch +router.post('/delete-branch', async (req, res) => { + const { project, branch } = req.body; + + if (!project || !branch) { + return res.status(400).json({ error: 'Project name and branch name are required' }); + } + + try { + const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + + // Safety: cannot delete the currently checked-out branch + const { stdout: currentBranch } = await spawnAsync('git', ['branch', '--show-current'], { cwd: projectPath }); + if (currentBranch.trim() === branch) { + return res.status(400).json({ error: 'Cannot delete the currently checked-out branch' }); + } + + const { stdout } = await spawnAsync('git', ['branch', '-d', branch], { cwd: projectPath }); + res.json({ success: true, output: stdout }); + } catch (error) { + console.error('Git delete branch error:', error); + res.status(500).json({ error: error.message }); + } +}); + // Get recent commits router.get('/commits', async (req, res) => { const { project, limit = 10 } = req.query; @@ -740,7 +768,7 @@ router.get('/commits', async (req, res) => { // Get commit log with stats const { stdout } = await spawnAsync( 'git', - ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=relative', '-n', String(safeLimit)], + ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=iso-strict', '-n', String(safeLimit)], { cwd: projectPath }, ); diff --git a/server/routes/settings.js b/server/routes/settings.js index d1c141b..7eee245 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -1,5 +1,7 @@ import express from 'express'; -import { apiKeysDb, credentialsDb } from '../database/db.js'; +import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js'; +import { getPublicKey } from '../services/vapid-keys.js'; +import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js'; const router = express.Router(); @@ -175,4 +177,100 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => { } }); +// =============================== +// Notification Preferences +// =============================== + +router.get('/notification-preferences', async (req, res) => { + try { + const preferences = notificationPreferencesDb.getPreferences(req.user.id); + res.json({ success: true, preferences }); + } catch (error) { + console.error('Error fetching notification preferences:', error); + res.status(500).json({ error: 'Failed to fetch notification preferences' }); + } +}); + +router.put('/notification-preferences', async (req, res) => { + try { + const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {}); + res.json({ success: true, preferences }); + } catch (error) { + console.error('Error saving notification preferences:', error); + res.status(500).json({ error: 'Failed to save notification preferences' }); + } +}); + +// =============================== +// Push Subscription Management +// =============================== + +router.get('/push/vapid-public-key', async (req, res) => { + try { + const publicKey = getPublicKey(); + res.json({ publicKey }); + } catch (error) { + console.error('Error fetching VAPID public key:', error); + res.status(500).json({ error: 'Failed to fetch VAPID public key' }); + } +}); + +router.post('/push/subscribe', async (req, res) => { + try { + const { endpoint, keys } = req.body; + if (!endpoint || !keys?.p256dh || !keys?.auth) { + return res.status(400).json({ error: 'Missing subscription fields' }); + } + pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth); + + // Enable webPush in preferences so the confirmation goes through the full pipeline + const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id); + if (!currentPrefs?.channels?.webPush) { + notificationPreferencesDb.updatePreferences(req.user.id, { + ...currentPrefs, + channels: { ...currentPrefs?.channels, webPush: true }, + }); + } + + res.json({ success: true }); + + // Send a confirmation push through the full notification pipeline + const event = createNotificationEvent({ + provider: 'system', + kind: 'info', + code: 'push.enabled', + meta: { message: 'Push notifications are now enabled!' }, + severity: 'info' + }); + notifyUserIfEnabled({ userId: req.user.id, event }); + } catch (error) { + console.error('Error saving push subscription:', error); + res.status(500).json({ error: 'Failed to save push subscription' }); + } +}); + +router.post('/push/unsubscribe', async (req, res) => { + try { + const { endpoint } = req.body; + if (!endpoint) { + return res.status(400).json({ error: 'Missing endpoint' }); + } + pushSubscriptionsDb.removeSubscription(endpoint); + + // Disable webPush in preferences to match subscription state + const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id); + if (currentPrefs?.channels?.webPush) { + notificationPreferencesDb.updatePreferences(req.user.id, { + ...currentPrefs, + channels: { ...currentPrefs.channels, webPush: false }, + }); + } + + res.json({ success: true }); + } catch (error) { + console.error('Error removing push subscription:', error); + res.status(500).json({ error: 'Failed to remove push subscription' }); + } +}); + export default router; diff --git a/server/services/notification-orchestrator.js b/server/services/notification-orchestrator.js new file mode 100644 index 0000000..bb573e1 --- /dev/null +++ b/server/services/notification-orchestrator.js @@ -0,0 +1,227 @@ +import webPush from 'web-push'; +import { notificationPreferencesDb, pushSubscriptionsDb, sessionNamesDb } from '../database/db.js'; + +const KIND_TO_PREF_KEY = { + action_required: 'actionRequired', + stop: 'stop', + error: 'error' +}; + +const PROVIDER_LABELS = { + claude: 'Claude', + cursor: 'Cursor', + codex: 'Codex', + gemini: 'Gemini', + system: 'System' +}; + +const recentEventKeys = new Map(); +const DEDUPE_WINDOW_MS = 20000; + +const cleanupOldEventKeys = () => { + const now = Date.now(); + for (const [key, timestamp] of recentEventKeys.entries()) { + if (now - timestamp > DEDUPE_WINDOW_MS) { + recentEventKeys.delete(key); + } + } +}; + +function shouldSendPush(preferences, event) { + const webPushEnabled = Boolean(preferences?.channels?.webPush); + const prefEventKey = KIND_TO_PREF_KEY[event.kind]; + const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true; + + return webPushEnabled && eventEnabled; +} + +function isDuplicate(event) { + cleanupOldEventKeys(); + const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`; + if (recentEventKeys.has(key)) { + return true; + } + recentEventKeys.set(key, Date.now()); + return false; +} + +function createNotificationEvent({ + provider, + sessionId = null, + kind = 'info', + code = 'generic.info', + meta = {}, + severity = 'info', + dedupeKey = null, + requiresUserAction = false +}) { + return { + provider, + sessionId, + kind, + code, + meta, + severity, + requiresUserAction, + dedupeKey, + createdAt: new Date().toISOString() + }; +} + +function normalizeErrorMessage(error) { + if (typeof error === 'string') { + return error; + } + + if (error && typeof error.message === 'string') { + return error.message; + } + + if (error == null) { + return 'Unknown error'; + } + + return String(error); +} + +function normalizeSessionName(sessionName) { + if (typeof sessionName !== 'string') { + return null; + } + + const normalized = sessionName.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return null; + } + + return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; +} + +function resolveSessionName(event) { + const explicitSessionName = normalizeSessionName(event.meta?.sessionName); + if (explicitSessionName) { + return explicitSessionName; + } + + if (!event.sessionId || !event.provider) { + return null; + } + + return normalizeSessionName(sessionNamesDb.getName(event.sessionId, event.provider)); +} + +function buildPushBody(event) { + const CODE_MAP = { + 'permission.required': event.meta?.toolName + ? `Action Required: Tool "${event.meta.toolName}" needs approval` + : 'Action Required: A tool needs your approval', + 'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped', + 'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error', + 'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification', + 'push.enabled': 'Push notifications are now enabled!' + }; + const providerLabel = PROVIDER_LABELS[event.provider] || 'Assistant'; + const sessionName = resolveSessionName(event); + const message = CODE_MAP[event.code] || 'You have a new notification'; + + return { + title: sessionName || 'Claude Code UI', + body: `${providerLabel}: ${message}`, + data: { + sessionId: event.sessionId || null, + code: event.code, + provider: event.provider || null, + sessionName, + tag: `${event.provider || 'assistant'}:${event.sessionId || 'none'}:${event.code}` + } + }; +} + +async function sendWebPush(userId, event) { + const subscriptions = pushSubscriptionsDb.getSubscriptions(userId); + if (!subscriptions.length) return; + + const payload = JSON.stringify(buildPushBody(event)); + + const results = await Promise.allSettled( + subscriptions.map((sub) => + webPush.sendNotification( + { + endpoint: sub.endpoint, + keys: { + p256dh: sub.keys_p256dh, + auth: sub.keys_auth + } + }, + payload + ) + ) + ); + + // Clean up gone subscriptions (410 Gone or 404) + results.forEach((result, index) => { + if (result.status === 'rejected') { + const statusCode = result.reason?.statusCode; + if (statusCode === 410 || statusCode === 404) { + pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint); + } + } + }); +} + +function notifyUserIfEnabled({ userId, event }) { + if (!userId || !event) { + return; + } + + const preferences = notificationPreferencesDb.getPreferences(userId); + if (!shouldSendPush(preferences, event)) { + return; + } + if (isDuplicate(event)) { + return; + } + + sendWebPush(userId, event).catch((err) => { + console.error('Web push send error:', err); + }); +} + +function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) { + notifyUserIfEnabled({ + userId, + event: createNotificationEvent({ + provider, + sessionId, + kind: 'stop', + code: 'run.stopped', + meta: { stopReason, sessionName }, + severity: 'info', + dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}` + }) + }); +} + +function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) { + const errorMessage = normalizeErrorMessage(error); + + notifyUserIfEnabled({ + userId, + event: createNotificationEvent({ + provider, + sessionId, + kind: 'error', + code: 'run.failed', + meta: { error: errorMessage, sessionName }, + severity: 'error', + dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}` + }) + }); +} + +export { + createNotificationEvent, + notifyUserIfEnabled, + notifyRunStopped, + notifyRunFailed +}; diff --git a/server/services/vapid-keys.js b/server/services/vapid-keys.js new file mode 100644 index 0000000..1abaeba --- /dev/null +++ b/server/services/vapid-keys.js @@ -0,0 +1,35 @@ +import webPush from 'web-push'; +import { db } from '../database/db.js'; + +let cachedKeys = null; + +function ensureVapidKeys() { + if (cachedKeys) return cachedKeys; + + const row = db.prepare('SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1').get(); + if (row) { + cachedKeys = { publicKey: row.public_key, privateKey: row.private_key }; + return cachedKeys; + } + + const keys = webPush.generateVAPIDKeys(); + db.prepare('INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)').run(keys.publicKey, keys.privateKey); + cachedKeys = keys; + return cachedKeys; +} + +function getPublicKey() { + return ensureVapidKeys().publicKey; +} + +function configureWebPush() { + const keys = ensureVapidKeys(); + webPush.setVapidDetails( + 'mailto:noreply@claudecodeui.local', + keys.publicKey, + keys.privateKey + ); + console.log('Web Push notifications configured'); +} + +export { ensureVapidKeys, getPublicKey, configureWebPush }; diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 5649c0c..ed14fdb 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -72,6 +72,40 @@ export default function AppContent() { }; }, [openSettings]); + useEffect(() => { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return undefined; + } + + const handleServiceWorkerMessage = (event: MessageEvent) => { + const message = event.data; + if (!message || message.type !== 'notification:navigate') { + return; + } + + if (typeof message.provider === 'string' && message.provider.trim()) { + localStorage.setItem('selected-provider', message.provider); + } + + setActiveTab('chat'); + setSidebarOpen(false); + void refreshProjectsSilently(); + + if (typeof message.sessionId === 'string' && message.sessionId) { + navigate(`/session/${message.sessionId}`); + return; + } + + navigate('/'); + }; + + navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage); + + return () => { + navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage); + }; + }, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]); + // Permission recovery: query pending permissions on WebSocket reconnect or session change useEffect(() => { const isReconnect = isConnected && !wasConnectedRef.current; diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index bdba41d..6aab9ee 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -82,6 +82,24 @@ const createFakeSubmitEvent = () => { const isTemporarySessionId = (sessionId: string | null | undefined) => Boolean(sessionId && sessionId.startsWith('new-session-')); +const getNotificationSessionSummary = ( + selectedSession: ProjectSession | null, + fallbackInput: string, +): string | null => { + const sessionSummary = selectedSession?.summary || selectedSession?.name || selectedSession?.title; + if (typeof sessionSummary === 'string' && sessionSummary.trim()) { + const normalized = sessionSummary.replace(/\s+/g, ' ').trim(); + return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; + } + + const normalizedFallback = fallbackInput.replace(/\s+/g, ' ').trim(); + if (!normalizedFallback) { + return null; + } + + return normalizedFallback.length > 80 ? `${normalizedFallback.slice(0, 77)}...` : normalizedFallback; +}; + export function useChatComposerState({ selectedProject, selectedSession, @@ -603,6 +621,7 @@ export function useChatComposerState({ const toolsSettings = getToolsSettings(); const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || ''; + const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput); if (provider === 'cursor') { sendMessage({ @@ -616,6 +635,7 @@ export function useChatComposerState({ resume: Boolean(effectiveSessionId), model: cursorModel, skipPermissions: toolsSettings?.skipPermissions || false, + sessionSummary, toolsSettings, }, }); @@ -630,6 +650,7 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: codexModel, + sessionSummary, permissionMode: permissionMode === 'plan' ? 'default' : permissionMode, }, }); @@ -644,6 +665,7 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: geminiModel, + sessionSummary, permissionMode, toolsSettings, }, @@ -660,6 +682,7 @@ export function useChatComposerState({ toolsSettings, permissionMode, model: claudeModel, + sessionSummary, images: uploadedImages, }, }); @@ -681,6 +704,7 @@ export function useChatComposerState({ safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); }, [ + selectedSession, attachedImages, claudeModel, codexModel, @@ -697,7 +721,6 @@ export function useChatComposerState({ resetCommandMenuState, scrollToBottom, selectedProject, - selectedSession?.id, sendMessage, setCanAbortSession, setChatMessages, diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx index 978723d..26524c5 100644 --- a/src/components/chat/tools/ToolRenderer.tsx +++ b/src/components/chat/tools/ToolRenderer.tsx @@ -80,7 +80,7 @@ export const ToolRenderer: React.FC = memo(({ } }, [displayConfig, parsedData, onFileOpen]); - // Route subagent containers to dedicated component (after hooks to keep call order stable) + // Route subagent containers to dedicated component (after hooks to satisfy Rules of Hooks) if (isSubagentContainer && subagentState) { if (mode === 'result') { return null; diff --git a/src/components/git-panel/constants/constants.ts b/src/components/git-panel/constants/constants.ts index 420f955..5e2ff19 100644 --- a/src/components/git-panel/constants/constants.ts +++ b/src/components/git-panel/constants/constants.ts @@ -27,21 +27,23 @@ export const FILE_STATUS_BADGE_CLASSES: Record = { export const CONFIRMATION_TITLES: Record = { discard: 'Discard Changes', delete: 'Delete File', - commit: 'Confirm Commit', + commit: 'Confirm Action', pull: 'Confirm Pull', push: 'Confirm Push', publish: 'Publish Branch', revertLocalCommit: 'Revert Local Commit', + deleteBranch: 'Delete Branch', }; export const CONFIRMATION_ACTION_LABELS: Record = { discard: 'Discard', delete: 'Delete', - commit: 'Commit', + commit: 'Confirm', pull: 'Pull', push: 'Push', publish: 'Publish', revertLocalCommit: 'Revert Commit', + deleteBranch: 'Delete', }; export const CONFIRMATION_BUTTON_CLASSES: Record = { @@ -52,6 +54,7 @@ export const CONFIRMATION_BUTTON_CLASSES: Record = { push: 'bg-orange-600 hover:bg-orange-700', publish: 'bg-purple-600 hover:bg-purple-700', revertLocalCommit: 'bg-yellow-600 hover:bg-yellow-700', + deleteBranch: 'bg-red-600 hover:bg-red-700', }; export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record = { @@ -62,6 +65,7 @@ export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record = { @@ -72,4 +76,5 @@ export const CONFIRMATION_ICON_CLASSES: Record = { push: 'text-yellow-600 dark:text-yellow-400', publish: 'text-yellow-600 dark:text-yellow-400', revertLocalCommit: 'text-yellow-600 dark:text-yellow-400', + deleteBranch: 'text-red-600 dark:text-red-400', }; diff --git a/src/components/git-panel/hooks/useGitPanelController.ts b/src/components/git-panel/hooks/useGitPanelController.ts index 7fe8635..cb34cf1 100644 --- a/src/components/git-panel/hooks/useGitPanelController.ts +++ b/src/components/git-panel/hooks/useGitPanelController.ts @@ -53,12 +53,17 @@ export function useGitPanelController({ const [recentCommits, setRecentCommits] = useState([]); const [commitDiffs, setCommitDiffs] = useState({}); const [remoteStatus, setRemoteStatus] = useState(null); + const [localBranches, setLocalBranches] = useState([]); + const [remoteBranches, setRemoteBranches] = useState([]); const [isCreatingBranch, setIsCreatingBranch] = useState(false); const [isFetching, setIsFetching] = useState(false); const [isPulling, setIsPulling] = useState(false); const [isPushing, setIsPushing] = useState(false); const [isPublishing, setIsPublishing] = useState(false); const [isCreatingInitialCommit, setIsCreatingInitialCommit] = useState(false); + const [operationError, setOperationError] = useState(null); + + const clearOperationError = useCallback(() => setOperationError(null), []); const selectedProjectNameRef = useRef(selectedProject?.name ?? null); useEffect(() => { @@ -169,13 +174,19 @@ export function useGitPanelController({ if (!data.error && data.branches) { setBranches(data.branches); + setLocalBranches(data.localBranches ?? data.branches); + setRemoteBranches(data.remoteBranches ?? []); return; } setBranches([]); + setLocalBranches([]); + setRemoteBranches([]); } catch (error) { console.error('Error fetching branches:', error); setBranches([]); + setLocalBranches([]); + setRemoteBranches([]); } }, [selectedProject]); @@ -271,6 +282,33 @@ export function useGitPanelController({ [fetchBranches, fetchGitStatus, selectedProject], ); + const deleteBranch = useCallback( + async (branchName: string) => { + if (!selectedProject) return false; + + try { + const response = await fetchWithAuth('/api/git/delete-branch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project: selectedProject.name, branch: branchName }), + }); + + const data = await readJson(response); + if (!data.success) { + setOperationError(data.error ?? 'Delete branch failed'); + return false; + } + + void fetchBranches(); + return true; + } catch (error) { + setOperationError(error instanceof Error ? error.message : 'Delete branch failed'); + return false; + } + }, + [fetchBranches, selectedProject], + ); + const handleFetch = useCallback(async () => { if (!selectedProject) { return; @@ -290,16 +328,17 @@ export function useGitPanelController({ if (data.success) { void fetchGitStatus(); void fetchRemoteStatus(); + void fetchBranches(); return; } - console.error('Fetch failed:', data.error); + setOperationError(data.error ?? 'Fetch failed'); } catch (error) { - console.error('Error fetching from remote:', error); + setOperationError(error instanceof Error ? error.message : 'Fetch failed'); } finally { setIsFetching(false); } - }, [fetchGitStatus, fetchRemoteStatus, selectedProject]); + }, [fetchBranches, fetchGitStatus, fetchRemoteStatus, selectedProject]); const handlePull = useCallback(async () => { if (!selectedProject) { @@ -323,9 +362,9 @@ export function useGitPanelController({ return; } - console.error('Pull failed:', data.error); + setOperationError(data.error ?? 'Pull failed'); } catch (error) { - console.error('Error pulling from remote:', error); + setOperationError(error instanceof Error ? error.message : 'Pull failed'); } finally { setIsPulling(false); } @@ -353,9 +392,9 @@ export function useGitPanelController({ return; } - console.error('Push failed:', data.error); + setOperationError(data.error ?? 'Push failed'); } catch (error) { - console.error('Error pushing to remote:', error); + setOperationError(error instanceof Error ? error.message : 'Push failed'); } finally { setIsPushing(false); } @@ -640,12 +679,15 @@ export function useGitPanelController({ // Reset repository-scoped state when project changes to avoid stale UI. setCurrentBranch(''); setBranches([]); + setLocalBranches([]); + setRemoteBranches([]); setGitStatus(null); setRemoteStatus(null); setGitDiff({}); setRecentCommits([]); setCommitDiffs({}); setIsLoading(false); + setOperationError(null); if (!selectedProject) { return () => { @@ -666,7 +708,6 @@ export function useGitPanelController({ if (!selectedProject || activeView !== 'history') { return; } - void fetchRecentCommits(); }, [activeView, fetchRecentCommits, selectedProject]); @@ -676,6 +717,8 @@ export function useGitPanelController({ isLoading, currentBranch, branches, + localBranches, + remoteBranches, recentCommits, commitDiffs, remoteStatus, @@ -685,9 +728,12 @@ export function useGitPanelController({ isPushing, isPublishing, isCreatingInitialCommit, + operationError, + clearOperationError, refreshAll, switchBranch, createBranch, + deleteBranch, handleFetch, handlePull, handlePush, diff --git a/src/components/git-panel/types/types.ts b/src/components/git-panel/types/types.ts index c8188e9..7abf982 100644 --- a/src/components/git-panel/types/types.ts +++ b/src/components/git-panel/types/types.ts @@ -1,9 +1,9 @@ import type { Project } from '../../../types/app'; -export type GitPanelView = 'changes' | 'history'; +export type GitPanelView = 'changes' | 'history' | 'branches'; export type FileStatusCode = 'M' | 'A' | 'D' | 'U'; export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked'; -export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit'; +export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit' | 'deleteBranch'; export type FileDiffInfo = { old_string: string; @@ -76,6 +76,8 @@ export type GitPanelController = { isLoading: boolean; currentBranch: string; branches: string[]; + localBranches: string[]; + remoteBranches: string[]; recentCommits: GitCommitSummary[]; commitDiffs: GitDiffMap; remoteStatus: GitRemoteStatus | null; @@ -85,9 +87,12 @@ export type GitPanelController = { isPushing: boolean; isPublishing: boolean; isCreatingInitialCommit: boolean; + operationError: string | null; + clearOperationError: () => void; refreshAll: () => void; switchBranch: (branchName: string) => Promise; createBranch: (branchName: string) => Promise; + deleteBranch: (branchName: string) => Promise; handleFetch: () => Promise; handlePull: () => Promise; handlePush: () => Promise; @@ -112,6 +117,8 @@ export type GitDiffResponse = GitApiErrorResponse & { export type GitBranchesResponse = GitApiErrorResponse & { branches?: string[]; + localBranches?: string[]; + remoteBranches?: string[]; }; export type GitCommitsResponse = GitApiErrorResponse & { diff --git a/src/components/git-panel/utils/gitPanelUtils.ts b/src/components/git-panel/utils/gitPanelUtils.ts index 736deeb..7a66ba6 100644 --- a/src/components/git-panel/utils/gitPanelUtils.ts +++ b/src/components/git-panel/utils/gitPanelUtils.ts @@ -24,3 +24,70 @@ export function getStatusLabel(status: FileStatusCode): string { export function getStatusBadgeClass(status: FileStatusCode): string { return FILE_STATUS_BADGE_CLASSES[status] || FILE_STATUS_BADGE_CLASSES.U; } + +// --------------------------------------------------------------------------- +// Parse `git show` output to extract per-file change info +// --------------------------------------------------------------------------- + +export type CommitFileChange = { + path: string; + directory: string; + filename: string; + status: FileStatusCode; + insertions: number; + deletions: number; +}; + +export type CommitFileSummary = { + files: CommitFileChange[]; + totalFiles: number; + totalInsertions: number; + totalDeletions: number; +}; + +export function parseCommitFiles(showOutput: string): CommitFileSummary { + const files: CommitFileChange[] = []; + // Split on file diff boundaries + const fileDiffs = showOutput.split(/^diff --git /m).slice(1); + + for (const section of fileDiffs) { + const lines = section.split('\n'); + // Extract path from "a/path b/path" + const header = lines[0] ?? ''; + const match = header.match(/^a\/(.+?) b\/(.+)/); + if (!match) continue; + + const pathA = match[1]; + const pathB = match[2]; + + // Determine status + let status: FileStatusCode = 'M'; + const joined = lines.slice(0, 6).join('\n'); + if (joined.includes('new file mode')) status = 'A'; + else if (joined.includes('deleted file mode')) status = 'D'; + + const filePath = status === 'D' ? pathA : pathB; + + // Count insertions/deletions (lines starting with +/- but not +++/---) + let insertions = 0; + let deletions = 0; + for (const line of lines) { + if (line.startsWith('+++') || line.startsWith('---')) continue; + if (line.startsWith('+')) insertions++; + else if (line.startsWith('-')) deletions++; + } + + const lastSlash = filePath.lastIndexOf('/'); + const directory = lastSlash >= 0 ? filePath.substring(0, lastSlash + 1) : ''; + const filename = lastSlash >= 0 ? filePath.substring(lastSlash + 1) : filePath; + + files.push({ path: filePath, directory, filename, status, insertions, deletions }); + } + + return { + files, + totalFiles: files.length, + totalInsertions: files.reduce((sum, f) => sum + f.insertions, 0), + totalDeletions: files.reduce((sum, f) => sum + f.deletions, 0), + }; +} diff --git a/src/components/git-panel/view/GitPanel.tsx b/src/components/git-panel/view/GitPanel.tsx index d670f65..fc6438b 100644 --- a/src/components/git-panel/view/GitPanel.tsx +++ b/src/components/git-panel/view/GitPanel.tsx @@ -2,8 +2,10 @@ import { useCallback, useState } from 'react'; import { useGitPanelController } from '../hooks/useGitPanelController'; import { useRevertLocalCommit } from '../hooks/useRevertLocalCommit'; import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types'; +import { getChangedFileCount } from '../utils/gitPanelUtils'; import ChangesView from '../view/changes/ChangesView'; import HistoryView from '../view/history/HistoryView'; +import BranchesView from '../view/branches/BranchesView'; import GitPanelHeader from '../view/GitPanelHeader'; import GitRepositoryErrorState from '../view/GitRepositoryErrorState'; import GitViewTabs from '../view/GitViewTabs'; @@ -21,6 +23,8 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen isLoading, currentBranch, branches, + localBranches, + remoteBranches, recentCommits, commitDiffs, remoteStatus, @@ -30,9 +34,12 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen isPushing, isPublishing, isCreatingInitialCommit, + operationError, + clearOperationError, refreshAll, switchBranch, createBranch, + deleteBranch, handleFetch, handlePull, handlePush, @@ -56,13 +63,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen }); const executeConfirmedAction = useCallback(async () => { - if (!confirmAction) { - return; - } - + if (!confirmAction) return; const actionToExecute = confirmAction; setConfirmAction(null); - try { await actionToExecute.onConfirm(); } catch (error) { @@ -70,6 +73,8 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen } }, [confirmAction]); + const changeCount = getChangedFileCount(gitStatus); + if (!selectedProject) { return (
@@ -92,6 +97,7 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen isPushing={isPushing} isPublishing={isPublishing} isRevertingLocalCommit={isRevertingLocalCommit} + operationError={operationError} onRefresh={refreshAll} onRevertLocalCommit={revertLatestLocalCommit} onSwitchBranch={switchBranch} @@ -100,6 +106,7 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen onPull={handlePull} onPush={handlePush} onPublish={handlePublish} + onClearError={clearOperationError} onRequestConfirmation={setConfirmAction} /> @@ -110,6 +117,7 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen @@ -145,6 +153,22 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen onFetchCommitDiff={fetchCommitDiff} /> )} + + {activeView === 'branches' && ( + + )} )} diff --git a/src/components/git-panel/view/GitPanelHeader.tsx b/src/components/git-panel/view/GitPanelHeader.tsx index 2710d4b..9913cef 100644 --- a/src/components/git-panel/view/GitPanelHeader.tsx +++ b/src/components/git-panel/view/GitPanelHeader.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload } from 'lucide-react'; +import { AlertCircle, Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import type { ConfirmationRequest, GitRemoteStatus } from '../types/types'; import NewBranchModal from './modals/NewBranchModal'; @@ -15,6 +15,7 @@ type GitPanelHeaderProps = { isPushing: boolean; isPublishing: boolean; isRevertingLocalCommit: boolean; + operationError: string | null; onRefresh: () => void; onRevertLocalCommit: () => Promise; onSwitchBranch: (branchName: string) => Promise; @@ -23,6 +24,7 @@ type GitPanelHeaderProps = { onPull: () => Promise; onPush: () => Promise; onPublish: () => Promise; + onClearError: () => void; onRequestConfirmation: (request: ConfirmationRequest) => void; }; @@ -38,6 +40,7 @@ export default function GitPanelHeader({ isPushing, isPublishing, isRevertingLocalCommit, + operationError, onRefresh, onRevertLocalCommit, onSwitchBranch, @@ -46,6 +49,7 @@ export default function GitPanelHeader({ onPull, onPush, onPublish, + onClearError, onRequestConfirmation, }: GitPanelHeaderProps) { const [showBranchDropdown, setShowBranchDropdown] = useState(false); @@ -63,10 +67,10 @@ export default function GitPanelHeader({ return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - const aheadCount = remoteStatus?.ahead || 0; - const behindCount = remoteStatus?.behind || 0; - const remoteName = remoteStatus?.remoteName || 'remote'; - const shouldShowFetchButton = aheadCount > 0 && behindCount > 0; + const aheadCount = remoteStatus?.ahead ?? 0; + const behindCount = remoteStatus?.behind ?? 0; + const remoteName = remoteStatus?.remoteName ?? 'remote'; + const anyPending = isFetching || isPulling || isPushing || isPublishing; const requestPullConfirmation = () => { onRequestConfirmation({ @@ -103,57 +107,39 @@ export default function GitPanelHeader({ const handleSwitchBranch = async (branchName: string) => { try { const success = await onSwitchBranch(branchName); - if (success) { - setShowBranchDropdown(false); - } + if (success) setShowBranchDropdown(false); } catch (error) { console.error('[GitPanelHeader] Failed to switch branch:', error); } }; - const handleFetch = async () => { - try { - await onFetch(); - } catch (error) { - console.error('[GitPanelHeader] Failed to fetch remote changes:', error); - } - }; - return ( <> + {/* Branch row + action buttons */}
+ {/* Branch selector */}
+ {/* Action buttons */}
{remoteStatus?.hasRemote && ( <> - {!remoteStatus.hasUpstream && ( + {!remoteStatus.hasUpstream ? ( - )} - - {remoteStatus.hasUpstream && !remoteStatus.isUpToDate && ( + ) : ( <> + {/* Fetch — always visible when remote exists */} + + {behindCount > 0 && ( )} {aheadCount > 0 && ( - )} - - {shouldShowFetchButton && ( - )} @@ -274,6 +258,21 @@ export default function GitPanelHeader({
+ {/* Inline error banner */} + {operationError && ( +
+ + {operationError} + +
+ )} + void; }; -export default function GitViewTabs({ activeView, isHidden, onChange }: GitViewTabsProps) { +const TABS: { id: GitPanelView; label: string; Icon: typeof FileText }[] = [ + { id: 'changes', label: 'Changes', Icon: FileText }, + { id: 'history', label: 'Commits', Icon: History }, + { id: 'branches', label: 'Branches', Icon: GitBranch }, +]; + +export default function GitViewTabs({ activeView, isHidden, changeCount, onChange }: GitViewTabsProps) { return (
- - + {TABS.map(({ id, label, Icon }) => ( + + ))}
); } diff --git a/src/components/git-panel/view/branches/BranchesView.tsx b/src/components/git-panel/view/branches/BranchesView.tsx new file mode 100644 index 0000000..6fd06b0 --- /dev/null +++ b/src/components/git-panel/view/branches/BranchesView.tsx @@ -0,0 +1,242 @@ +import { Check, GitBranch, Globe, Plus, RefreshCw, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import type { ConfirmationRequest, GitRemoteStatus } from '../../types/types'; +import NewBranchModal from '../modals/NewBranchModal'; + +type BranchesViewProps = { + isMobile: boolean; + isLoading: boolean; + currentBranch: string; + localBranches: string[]; + remoteBranches: string[]; + remoteStatus: GitRemoteStatus | null; + isCreatingBranch: boolean; + onSwitchBranch: (branchName: string) => Promise; + onCreateBranch: (branchName: string) => Promise; + onDeleteBranch: (branchName: string) => Promise; + onRequestConfirmation: (request: ConfirmationRequest) => void; +}; + +// --------------------------------------------------------------------------- +// Branch row +// --------------------------------------------------------------------------- + +type BranchRowProps = { + name: string; + isCurrent: boolean; + isRemote: boolean; + aheadCount: number; + behindCount: number; + isMobile: boolean; + onSwitch: () => void; + onDelete: () => void; +}; + +function BranchRow({ name, isCurrent, isRemote, aheadCount, behindCount, isMobile, onSwitch, onDelete }: BranchRowProps) { + return ( +
+ {/* Branch icon */} +
+ {isRemote ? : } +
+ + {/* Name + pills */} +
+
+ + {name} + + {isCurrent && ( + + current + + )} + {isRemote && !isCurrent && ( + + remote + + )} +
+ {/* Ahead/behind — only meaningful for the current branch */} + {isCurrent && (aheadCount > 0 || behindCount > 0) && ( +
+ {aheadCount > 0 && ( + ↑{aheadCount} ahead + )} + {behindCount > 0 && ( + ↓{behindCount} behind + )} +
+ )} +
+ + {/* Actions */} +
+ {isCurrent ? ( + + ) : !isRemote ? ( + <> + + + + ) : null} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Section header +// --------------------------------------------------------------------------- + +function SectionHeader({ label, count }: { label: string; count: number }) { + return ( +
+ {label} + {count} +
+ ); +} + +// --------------------------------------------------------------------------- +// BranchesView +// --------------------------------------------------------------------------- + +export default function BranchesView({ + isMobile, + isLoading, + currentBranch, + localBranches, + remoteBranches, + remoteStatus, + isCreatingBranch, + onSwitchBranch, + onCreateBranch, + onDeleteBranch, + onRequestConfirmation, +}: BranchesViewProps) { + const [showNewBranchModal, setShowNewBranchModal] = useState(false); + + const aheadCount = remoteStatus?.ahead ?? 0; + const behindCount = remoteStatus?.behind ?? 0; + + const requestSwitch = (branch: string) => { + onRequestConfirmation({ + type: 'commit', // reuse neutral type for switch + message: `Switch to branch "${branch}"? Make sure you have no uncommitted changes.`, + onConfirm: () => void onSwitchBranch(branch), + }); + }; + + const requestDelete = (branch: string) => { + onRequestConfirmation({ + type: 'deleteBranch', + message: `Delete branch "${branch}"? This cannot be undone.`, + onConfirm: () => void onDeleteBranch(branch), + }); + }; + + if (isLoading && localBranches.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Create branch button */} +
+ + {localBranches.length} local{remoteBranches.length > 0 ? `, ${remoteBranches.length} remote` : ''} + + +
+ + {/* Branch list */} +
+ {localBranches.length > 0 && ( + <> + + {localBranches.map((branch) => ( + requestSwitch(branch)} + onDelete={() => requestDelete(branch)} + /> + ))} + + )} + + {remoteBranches.length > 0 && ( + <> + + {remoteBranches.map((branch) => ( + requestSwitch(branch)} + onDelete={() => requestDelete(branch)} + /> + ))} + + )} + + {localBranches.length === 0 && remoteBranches.length === 0 && ( +
+ +

No branches found

+
+ )} +
+ + setShowNewBranchModal(false)} + onCreateBranch={onCreateBranch} + /> +
+ ); +} diff --git a/src/components/git-panel/view/changes/ChangesView.tsx b/src/components/git-panel/view/changes/ChangesView.tsx index 4810bb2..cfcb29f 100644 --- a/src/components/git-panel/view/changes/ChangesView.tsx +++ b/src/components/git-panel/view/changes/ChangesView.tsx @@ -4,7 +4,6 @@ import type { ConfirmationRequest, FileStatusCode, GitDiffMap, GitStatusResponse import { getAllChangedFiles, hasChangedFiles } from '../../utils/gitPanelUtils'; import CommitComposer from './CommitComposer'; import FileChangeList from './FileChangeList'; -import FileSelectionControls from './FileSelectionControls'; import FileStatusLegend from './FileStatusLegend'; type ChangesViewProps = { @@ -56,8 +55,12 @@ export default function ChangesView({ return; } - // Preserve previous behavior: every fresh status snapshot reselects changed files. - setSelectedFiles(new Set(getAllChangedFiles(gitStatus))); + // Remove any selected files that no longer exist in the status + setSelectedFiles((prev) => { + const allFiles = new Set(getAllChangedFiles(gitStatus)); + const next = new Set([...prev].filter((f) => allFiles.has(f))); + return next; + }); }, [gitStatus]); useEffect(() => { @@ -129,6 +132,11 @@ export default function ChangesView({ return onGenerateCommitMessage(Array.from(selectedFiles)); }, [onGenerateCommitMessage, selectedFiles]); + const unstagedFiles = useMemo( + () => new Set(changedFiles.filter((f) => !selectedFiles.has(f))), + [changedFiles, selectedFiles], + ); + return ( <> - {gitStatus && !gitStatus.error && ( - setSelectedFiles(new Set(changedFiles))} - onDeselectAll={() => setSelectedFiles(new Set())} - /> - )} - {!gitStatus?.error && }
@@ -193,21 +190,71 @@ export default function ChangesView({
) : (
- { - void onOpenFile(filePath); - }} - onToggleWrapText={() => onWrapTextChange(!wrapText)} - onRequestFileAction={requestFileAction} - /> + {/* STAGED section */} +
+ + Staged ({selectedFiles.size}) + + {selectedFiles.size > 0 && ( + + )} +
+ {selectedFiles.size === 0 ? ( +
No staged files
+ ) : ( + { void onOpenFile(filePath); }} + onToggleWrapText={() => onWrapTextChange(!wrapText)} + onRequestFileAction={requestFileAction} + /> + )} + + {/* CHANGES section */} +
+ + Changes ({unstagedFiles.size}) + + {unstagedFiles.size > 0 && ( + + )} +
+ {unstagedFiles.size === 0 ? ( +
All changes staged
+ ) : ( + { void onOpenFile(filePath); }} + onToggleWrapText={() => onWrapTextChange(!wrapText)} + onRequestFileAction={requestFileAction} + /> + )}
)}
diff --git a/src/components/git-panel/view/changes/FileChangeList.tsx b/src/components/git-panel/view/changes/FileChangeList.tsx index 0438382..59f7b3d 100644 --- a/src/components/git-panel/view/changes/FileChangeList.tsx +++ b/src/components/git-panel/view/changes/FileChangeList.tsx @@ -9,6 +9,7 @@ type FileChangeListProps = { selectedFiles: Set; isMobile: boolean; wrapText: boolean; + filePaths?: Set; onToggleSelected: (filePath: string) => void; onToggleExpanded: (filePath: string) => void; onOpenFile: (filePath: string) => void; @@ -23,6 +24,7 @@ export default function FileChangeList({ selectedFiles, isMobile, wrapText, + filePaths, onToggleSelected, onToggleExpanded, onOpenFile, @@ -32,23 +34,25 @@ export default function FileChangeList({ return ( <> {FILE_STATUS_GROUPS.map(({ key, status }) => - (gitStatus[key] || []).map((filePath) => ( - - )), + (gitStatus[key] || []) + .filter((filePath) => !filePaths || filePaths.has(filePath)) + .map((filePath) => ( + + )), )} ); diff --git a/src/components/git-panel/view/history/CommitHistoryItem.tsx b/src/components/git-panel/view/history/CommitHistoryItem.tsx index 60fd5ab..33a4139 100644 --- a/src/components/git-panel/view/history/CommitHistoryItem.tsx +++ b/src/components/git-panel/view/history/CommitHistoryItem.tsx @@ -1,7 +1,16 @@ import { ChevronDown, ChevronRight } from 'lucide-react'; +import { useMemo } from 'react'; import type { GitCommitSummary } from '../../types/types'; +import { getStatusBadgeClass, parseCommitFiles } from '../../utils/gitPanelUtils'; import GitDiffViewer from '../shared/GitDiffViewer'; +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} type CommitHistoryItemProps = { commit: GitCommitSummary; @@ -20,6 +29,11 @@ export default function CommitHistoryItem({ wrapText, onToggle, }: CommitHistoryItemProps) { + const fileSummary = useMemo(() => { + if (!diff) return null; + return parseCommitFiles(diff); + }, [diff]); + return (
+ {isPushSubscribed && ( + + {t('notifications.webPush.enabled')} + + )} +
+ )} + + +
+

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

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