mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-13 01:22:06 +08:00
Compare commits
66 Commits
fix/redact
...
feat/upgra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a0c8b20e5 | ||
|
|
00e526b6e9 | ||
|
|
591b18e9e3 | ||
|
|
881e72d4a0 | ||
|
|
f5eac2ec12 | ||
|
|
3d948217ef | ||
|
|
afc717e69e | ||
|
|
6a53c31e90 | ||
|
|
92de0ed613 | ||
|
|
b6a45b3183 | ||
|
|
ce327b6fa9 | ||
|
|
276639099b | ||
|
|
f4f88318c2 | ||
|
|
029d159592 | ||
|
|
7c9ec8fa12 | ||
|
|
1b4d4b7278 | ||
|
|
b1a0afe9e0 | ||
|
|
88eb2009bb | ||
|
|
602e6ad4ac | ||
|
|
4a2453fe32 | ||
|
|
f439a8a3d5 | ||
|
|
23210bc40e | ||
|
|
beae8c6513 | ||
|
|
33a4e72ca4 | ||
|
|
f7c0024fe1 | ||
|
|
ca8fd0ee23 | ||
|
|
b7e6bca2e3 | ||
|
|
84c166c4cb | ||
|
|
231ed04002 | ||
|
|
d70dc077bf | ||
|
|
1faa1a6a00 | ||
|
|
3cd89956ba | ||
|
|
01dbe2a8bf | ||
|
|
f4a1614a0a | ||
|
|
c235b05e1d | ||
|
|
dd77649053 | ||
|
|
af3a28abc7 | ||
|
|
371ff034e4 | ||
|
|
3b4d6885aa | ||
|
|
bc9d2dd830 | ||
|
|
c21a9f4561 | ||
|
|
ed9cdf0114 | ||
|
|
b39997c429 | ||
|
|
d638a8982c | ||
|
|
f238050b85 | ||
|
|
beaa2d2533 | ||
|
|
c90b34108e | ||
|
|
323357384e | ||
|
|
d509aa635b | ||
|
|
2149b8776b | ||
|
|
2b416f2dcb | ||
|
|
bb8db5815c | ||
|
|
b3d0f9037d | ||
|
|
3ec76b5bb1 | ||
|
|
14ddbc7c57 | ||
|
|
ebb0e59e80 | ||
|
|
957f53fb99 | ||
|
|
ef2fd48b46 | ||
|
|
cdcac182d4 | ||
|
|
94785bfa57 | ||
|
|
9e608b8426 | ||
|
|
c667b6a179 | ||
|
|
fa9eaf5573 | ||
|
|
2edfef2e3f | ||
|
|
96b16b42e4 | ||
|
|
f082cdc63b |
68
CHANGELOG.md
68
CHANGELOG.md
@@ -3,6 +3,74 @@
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09)
|
||||
|
||||
### New Features
|
||||
|
||||
* adding Fable 5 in claude code ([ce327b6](https://github.com/siteboon/claudecodeui/commit/ce327b6fa9329aa3e9a3a1da7225ca01d3b06ac5))
|
||||
|
||||
## [1.33.3](https://github.com/siteboon/claudecodeui/compare/v1.33.2...v1.33.3) (2026-06-09)
|
||||
|
||||
### New Features
|
||||
|
||||
* add file tree upload progress ([c235b05](https://github.com/siteboon/claudecodeui/commit/c235b05e1d3b626667dba4043b685512e3cd3d5d))
|
||||
* signal when chat runs complete ([d70dc07](https://github.com/siteboon/claudecodeui/commit/d70dc077bfbbfcf2ff4fa5514fabf7b4485861fa))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address notification review feedback ([602e6ad](https://github.com/siteboon/claudecodeui/commit/602e6ad4acba612a7ea66fb3bc7485054f5675ee))
|
||||
* align prism plugin name and id with manifest.json ([ca8fd0e](https://github.com/siteboon/claudecodeui/commit/ca8fd0ee235b6a3210157bd0d9af83024d4a2248))
|
||||
* **chat:** re-anchor initial scroll across lazy content reflow ([33a4e72](https://github.com/siteboon/claudecodeui/commit/33a4e72ca4f84df60aadfc4ff3f3467d6f5ae948))
|
||||
* keep editor toolbar in view on long unwrapped lines ([beae8c6](https://github.com/siteboon/claudecodeui/commit/beae8c6513daa7518b9de40d8bfde3bf08e7bc87))
|
||||
* **sandbox:** prevent server SIGHUP on sbx exec exit ([#792](https://github.com/siteboon/claudecodeui/issues/792)) ([f4a1614](https://github.com/siteboon/claudecodeui/commit/f4a1614a0a4ab4b65e8368d5e4221f015cb7555d)), closes [#791](https://github.com/siteboon/claudecodeui/issues/791)
|
||||
* slash command suggestions trigger at any / in input, not only at start ([#843](https://github.com/siteboon/claudecodeui/issues/843)) ([f7c0024](https://github.com/siteboon/claudecodeui/commit/f7c0024fe15057ad049c71e15e88adb482a4497f))
|
||||
* update naming convention ([3cd8995](https://github.com/siteboon/claudecodeui/commit/3cd89956ba06f0fc3e17d349b0c50baab4012658))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* add prism plugin ([01dbe2a](https://github.com/siteboon/claudecodeui/commit/01dbe2a8bfcb3b265995f01f905b218d5f576f7b))
|
||||
|
||||
## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08)
|
||||
|
||||
### New Features
|
||||
|
||||
* **chat:** open cost modal from token usage ([f238050](https://github.com/siteboon/claudecodeui/commit/f238050b85c3b99a702a8635059735e1a3b3a4f4))
|
||||
* **i18n:** add Traditional Chinese (zh-TW) locale ([#773](https://github.com/siteboon/claudecodeui/issues/773)) ([c21a9f4](https://github.com/siteboon/claudecodeui/commit/c21a9f45610eb1eeb650d8e6cf8650e798f77f6f))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* do not show model description in chat view ([d638a89](https://github.com/siteboon/claudecodeui/commit/d638a8982c7f75b08fc7f65f01d6d54989c790d1))
|
||||
* include Claude cache tokens in usage ([ed9cdf0](https://github.com/siteboon/claudecodeui/commit/ed9cdf01145fa0d063580bb76d30cfa7ee67af86))
|
||||
|
||||
## [1.33.1](https://github.com/siteboon/claudecodeui/compare/v1.33.0...v1.33.1) (2026-06-05)
|
||||
|
||||
### New Features
|
||||
|
||||
* **chat:** auto-detect text direction for RTL languages ([#729](https://github.com/siteboon/claudecodeui/issues/729)) ([fa9eaf5](https://github.com/siteboon/claudecodeui/commit/fa9eaf5573a6f870a19fb62ab430ffd87c466582))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* file tree concurrency ([#828](https://github.com/siteboon/claudecodeui/issues/828)) ([ebb0e59](https://github.com/siteboon/claudecodeui/commit/ebb0e59e8023c0a8040d168a5adffb7102e80561))
|
||||
* load claude models directly from provider ([cdcac18](https://github.com/siteboon/claudecodeui/commit/cdcac182d458a24908777568979c8e756f94428c))
|
||||
* plugin svg icon sanitization ([#817](https://github.com/siteboon/claudecodeui/issues/817)) ([d9e9df1](https://github.com/siteboon/claudecodeui/commit/d9e9df183f462c88c3b60975eb8254faa9168717))
|
||||
* recognize claude auth token env ([#818](https://github.com/siteboon/claudecodeui/issues/818)) ([43c33d5](https://github.com/siteboon/claudecodeui/commit/43c33d5cb1b41835dfe3bccd450c5a9c2441509b))
|
||||
* redact websocket auth token in logs ([#827](https://github.com/siteboon/claudecodeui/issues/827)) ([14ddbc7](https://github.com/siteboon/claudecodeui/commit/14ddbc7c57a01da9fb65fd87d8588532b11833fa))
|
||||
* remove thinking mode ([#835](https://github.com/siteboon/claudecodeui/issues/835)) ([2149b87](https://github.com/siteboon/claudecodeui/commit/2149b8776b7ebfec0eace413f4fc527ccb2324c0))
|
||||
* **shell:** disconnect and restart buttons ([#831](https://github.com/siteboon/claudecodeui/issues/831)) ([ef2fd48](https://github.com/siteboon/claudecodeui/commit/ef2fd48b46452d4b9e2bf1f5e3c30fafe19f27f2))
|
||||
* show Claude tool result errors ([bb8db58](https://github.com/siteboon/claudecodeui/commit/bb8db5815c2d20ee4fbfa02d14c886a56ef352e0))
|
||||
* **vite:** proxy /plugin-ws WebSocket requests to the backend in dev ([#757](https://github.com/siteboon/claudecodeui/issues/757)) ([96b16b4](https://github.com/siteboon/claudecodeui/commit/96b16b42e4f807d04ec743a5a4117a37a3f5e0d9))
|
||||
* **websocket:** add 30s server-side heartbeat to prevent proxy idle disconnects ([#770](https://github.com/siteboon/claudecodeui/issues/770)) ([2edfef2](https://github.com/siteboon/claudecodeui/commit/2edfef2e3f4271c29ae8670df9dd382a9eef7c3c)), closes [#769](https://github.com/siteboon/claudecodeui/issues/769)
|
||||
* **websocket:** reset unmountedRef on each effect re-run so token refresh reconnects ([#721](https://github.com/siteboon/claudecodeui/issues/721)) ([f082cdc](https://github.com/siteboon/claudecodeui/commit/f082cdc63bd0de90f8b3da1df6071e91ab545831))
|
||||
|
||||
### Documentation
|
||||
|
||||
* add nginx subpath deployment template ([#820](https://github.com/siteboon/claudecodeui/issues/820)) ([3ec76b5](https://github.com/siteboon/claudecodeui/commit/3ec76b5bb15a13cec41056f4c9b9c425195022fa))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* update Claude fallback models ([94785bf](https://github.com/siteboon/claudecodeui/commit/94785bfa579d1f39a2bee0f9dd0f09fd0243bc79))
|
||||
* update package-lock.json ([c90b341](https://github.com/siteboon/claudecodeui/commit/c90b34108e86a3effdb5c6979ea7b1692d2b9da0))
|
||||
|
||||
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **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 [`public/modelConstants.js`](public/modelConstants.js))
|
||||
- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle zur Laufzeit über `GET /api/providers/:provider/models`)
|
||||
|
||||
|
||||
## Schnellstart
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
- **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적
|
||||
- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리
|
||||
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`public/modelConstants.js`에서 전체 지원 모델 확인)
|
||||
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`GET /api/providers/:provider/models` API에서 전체 지원 모델 확인)
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](public/modelConstants.js) for the full list of supported models)
|
||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю
|
||||
- **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow
|
||||
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`public/modelConstants.js`](public/modelConstants.js) для полного списка поддерживаемых моделей)
|
||||
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (полный список поддерживаемых моделей доступен через `GET /api/providers/:provider/models`)
|
||||
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
|
||||
|
||||
---
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **Oturum Yönetimi** — Konuşmalara devam et, birden fazla oturumu yönet ve geçmişi takip et
|
||||
- **Eklenti Sistemi** — CloudCLI'ı özel eklentilerle genişlet: yeni sekmeler, arka uç servisleri ve entegrasyonlar ekle. [Kendi eklentini yaz →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI Entegrasyonu** *(İsteğe Bağlı)* — AI destekli görev planlama, PRD ayrıştırma ve iş akışı otomasyonu ile gelişmiş proje yönetimi
|
||||
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için [`public/modelConstants.js`](public/modelConstants.js) dosyasına bak)
|
||||
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için `GET /api/providers/:provider/models` API'sine bak)
|
||||
|
||||
|
||||
## Hızlı Başlangıç
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>简体中文</b> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
|
||||
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理
|
||||
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`public/modelConstants.js`](public/modelConstants.js))
|
||||
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表可通过 `GET /api/providers/:provider/models` 接口获取)
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
242
README.zh-TW.md
Normal file
242
README.zh-TW.md
Normal file
@@ -0,0 +1,242 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
|
||||
<h1>Cloud CLI(又名 Claude Code UI)</h1>
|
||||
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>、<a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>、<a href="https://developers.openai.com/codex">Codex</a> 和 <a href="https://geminicli.com/">Gemini-CLI</a> 的桌面和行動裝置 UI。可在本機或遠端使用,從任何地方查看您的專案與工作階段。</p>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">文件</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug 回報</a> · <a href="CONTRIBUTING.md">貢獻指南</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁️_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
|
||||
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 Discord 社群"></a>
|
||||
<br><br>
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <b>繁體中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
## 截圖
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>桌面檢視</h3>
|
||||
<img src="public/screenshots/desktop-main.png" alt="桌面介面" width="400">
|
||||
<br>
|
||||
<em>顯示專案總覽和聊天的主介面</em>
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>行動裝置體驗</h3>
|
||||
<img src="public/screenshots/mobile-chat.png" alt="行動裝置介面" width="250">
|
||||
<br>
|
||||
<em>具有觸控導覽的響應式行動裝置設計</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<h3>CLI 選擇</h3>
|
||||
<img src="public/screenshots/cli-selection.png" alt="CLI 選擇" width="400">
|
||||
<br>
|
||||
<em>在 Claude Code、Gemini、Cursor CLI 與 Codex 之間進行選擇</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
## 功能
|
||||
|
||||
- **響應式設計** — 在桌面、平板和行動裝置上無縫運作,讓您隨時隨地使用 Agents
|
||||
- **互動聊天介面** — 內建聊天 UI,輕鬆與 Agents 交流
|
||||
- **整合 Shell 終端機** — 透過內建 shell 功能直接存取 Agents CLI
|
||||
- **檔案瀏覽器** — 互動式檔案樹,支援語法醒目提示與即時編輯
|
||||
- **Git 瀏覽器** — 檢視、暫存並提交變更,還可切換分支
|
||||
- **工作階段管理** — 恢復對話、管理多個工作階段並追蹤歷史紀錄
|
||||
- **外掛系統** — 透過自訂分頁、後端服務與整合來擴充 CloudCLI。[開始建構 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI 整合** *(選用)* — 結合 AI 任務規劃、PRD 分析與工作流程自動化,實現進階專案管理
|
||||
- **模型相容性** — 支援 Claude、GPT、Gemini 模型家族(完整支援列表可透過 `GET /api/providers/:provider/models` 介面取得)
|
||||
|
||||
## 快速開始
|
||||
|
||||
### CloudCLI Cloud(推薦)
|
||||
|
||||
無需本機設定即可快速啟動。提供可透過網路瀏覽器、行動應用程式、API 或慣用的 IDE 存取的完全容器化託管開發環境。
|
||||
|
||||
**[立即開始 CloudCLI Cloud](https://cloudcli.ai)**
|
||||
|
||||
### 自架(開源)
|
||||
|
||||
#### npm
|
||||
|
||||
啟動 CloudCLI UI,只需一行 `npx`(需要 Node.js v22+):
|
||||
|
||||
```bash
|
||||
npx @cloudcli-ai/cloudcli
|
||||
```
|
||||
|
||||
或進行全域安裝,便於日常使用:
|
||||
|
||||
```bash
|
||||
npm install -g @cloudcli-ai/cloudcli
|
||||
cloudcli
|
||||
```
|
||||
|
||||
開啟 `http://localhost:3001`,系統會自動發現所有現有工作階段。
|
||||
|
||||
更多設定選項、PM2、遠端伺服器設定等,請參閱 **[文件 →](https://cloudcli.ai/docs)**。
|
||||
|
||||
#### Docker Sandboxes(實驗性)
|
||||
|
||||
在隔離的沙箱中執行代理,具有虛擬機管理程式等級的隔離。預設啟動 Claude Code。需要 [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)。
|
||||
|
||||
```bash
|
||||
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
||||
```
|
||||
|
||||
支援 Claude Code、Codex 和 Gemini CLI。詳情請參閱[沙箱文件](docker/)。
|
||||
|
||||
---
|
||||
|
||||
## 哪個選項更適合你?
|
||||
|
||||
CloudCLI UI 是 CloudCLI Cloud 的開源 UI 層。你可以在本機上自架它,也可以使用提供團隊功能與深入整合的 CloudCLI Cloud。
|
||||
|
||||
| | CloudCLI UI(自架) | CloudCLI Cloud |
|
||||
|---|---|---|
|
||||
| **適合對象** | 需要為本機代理工作階段提供完整 UI 的開發者 | 需要部署在雲端,隨時從任何地方存取代理的團隊與開發者 |
|
||||
| **存取方式** | 透過 `[yourip]:port` 在瀏覽器中存取 | 瀏覽器、任意 IDE、REST API、n8n |
|
||||
| **設定** | `npx @cloudcli-ai/cloudcli` | 無需設定 |
|
||||
| **機器需保持開機嗎** | 是 | 否 |
|
||||
| **行動裝置存取** | 網路內任意瀏覽器 | 任意裝置(原生應用程式即將推出) |
|
||||
| **可用工作階段** | 自動發現 `~/.claude` 中的所有工作階段 | 雲端環境內的工作階段 |
|
||||
| **支援的 Agents** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI |
|
||||
| **檔案瀏覽與 Git** | 內建於 UI | 內建於 UI |
|
||||
| **MCP 設定** | UI 管理,與本機 `~/.claude` 設定同步 | UI 管理 |
|
||||
| **IDE 存取** | 本機 IDE | 任何連線到雲端環境的 IDE |
|
||||
| **REST API** | 是 | 是 |
|
||||
| **n8n 節點** | 否 | 是 |
|
||||
| **團隊共享** | 否 | 是 |
|
||||
| **平台費用** | 免費開源 | 起價 $7/月 |
|
||||
|
||||
> 兩種方式都使用你自己的 AI 訂閱(Claude、Cursor 等)— CloudCLI 提供環境,而非 AI。
|
||||
|
||||
---
|
||||
|
||||
## 安全與工具設定
|
||||
|
||||
**🔒 重要提示**:所有 Claude Code 工具預設**停用**,可防止潛在的有害操作自動執行。
|
||||
|
||||
### 啟用工具
|
||||
|
||||
1. **開啟工具設定** — 點擊側邊欄齒輪圖示
|
||||
2. **選擇性啟用** — 僅啟用所需工具
|
||||
3. **套用設定** — 偏好設定儲存在本機
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
*工具設定介面 — 只啟用你需要的內容*
|
||||
|
||||
</div>
|
||||
|
||||
**建議做法**:先啟用基礎工具,再根據需要新增其他工具。隨時可以調整。
|
||||
|
||||
---
|
||||
|
||||
## 外掛
|
||||
|
||||
CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Node.js 後端的分頁。在 Settings > Plugins 中直接從 Git 儲存庫安裝外掛,或自行開發。
|
||||
|
||||
### 可用外掛
|
||||
|
||||
| 外掛 | 描述 |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 |
|
||||
|
||||
### 自行建構
|
||||
|
||||
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — Fork 該儲存庫以建構自己的外掛。範例包括前端渲染、即時上下文更新和 RPC 通訊。
|
||||
|
||||
**[外掛文件 →](https://cloudcli.ai/docs/plugin-overview)** — 提供外掛 API、清單格式、安全模型等完整指南。
|
||||
|
||||
---
|
||||
|
||||
## 常見問題
|
||||
|
||||
<details>
|
||||
<summary>與 Claude Code Remote Control 有何不同?</summary>
|
||||
|
||||
Claude Code Remote Control 讓你傳送訊息到本機終端機中已經執行的工作階段。該方式要求你的機器保持開機,終端機保持開啟,中斷網路後約 10 分鐘工作階段會逾時。
|
||||
|
||||
CloudCLI UI 與 CloudCLI Cloud 是對 Claude Code 的擴充,而非旁觀 — MCP 伺服器、權限、設定、工作階段與 Claude Code 完全一致。
|
||||
|
||||
- **涵蓋全部工作階段** — CloudCLI UI 會自動掃描 `~/.claude` 資料夾中的每個工作階段。Remote Control 只暴露目前活動的工作階段。
|
||||
- **設定統一** — 在 CloudCLI UI 中修改的 MCP、工具權限等設定會立即寫入 Claude Code。
|
||||
- **支援更多 Agents** — Claude Code、Cursor CLI、Codex、Gemini CLI。
|
||||
- **完整 UI** — 除了聊天介面,還包括檔案瀏覽器、Git 整合、MCP 管理和 Shell 終端機。
|
||||
- **CloudCLI Cloud 持續運作於雲端** — 關閉本機裝置也不會中斷代理執行,無需監控終端機。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>需要額外購買 AI 訂閱嗎?</summary>
|
||||
|
||||
需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 $7/月起提供託管環境。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>能在手機上使用 CloudCLI UI 嗎?</summary>
|
||||
|
||||
可以。自架時,在你的裝置上執行伺服器,然後在網路中的任意瀏覽器開啟 `[yourip]:port`。CloudCLI Cloud 可從任意裝置存取,內建原生應用程式也在開發中。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>UI 中的變更會影響本機 Claude Code 設定嗎?</summary>
|
||||
|
||||
會的。自架模式下,CloudCLI UI 讀取並寫入 Claude Code 使用的 `~/.claude` 設定。透過 UI 新增的 MCP 伺服器會立即在 Claude Code 中可見。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 社群與支援
|
||||
|
||||
- **[文件](https://cloudcli.ai/docs)** — 安裝、設定、功能與疑難排解指南
|
||||
- **[Discord](https://discord.gg/buxwujPNRE)** — 取得協助並與社群交流
|
||||
- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — 回報 Bug 與建議功能
|
||||
- **[貢獻指南](CONTRIBUTING.md)** — 如何參與專案貢獻
|
||||
|
||||
## 授權條款
|
||||
|
||||
GNU 通用公共授權條款 v3.0 — 詳見 [LICENSE](LICENSE) 檔案。
|
||||
|
||||
該專案為開源軟體,在 GPL v3 授權條款下可自由使用、修改與散布。
|
||||
|
||||
## 致謝
|
||||
|
||||
### 使用技術
|
||||
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic 官方 CLI
|
||||
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** — Cursor 官方 CLI
|
||||
- **[Codex](https://developers.openai.com/codex)** — OpenAI Codex
|
||||
- **[Gemini-CLI](https://geminicli.com/)** — Google Gemini CLI
|
||||
- **[React](https://react.dev/)** — 使用者介面函式庫
|
||||
- **[Vite](https://vitejs.dev/)** — 快速建構工具與開發伺服器
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** — 實用優先 CSS 框架
|
||||
- **[CodeMirror](https://codemirror.net/)** — 進階程式碼編輯器
|
||||
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(選用)* — AI 驅動的專案管理與任務規劃
|
||||
|
||||
### 贊助商
|
||||
- [Siteboon - AI powered website builder](https://siteboon.ai)
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<strong>為 Claude Code、Cursor 和 Codex 社群精心打造。</strong>
|
||||
</div>
|
||||
218
docs/nginx-subpath-template.conf
Normal file
218
docs/nginx-subpath-template.conf
Normal file
@@ -0,0 +1,218 @@
|
||||
# CloudCLI UI Nginx subpath deployment template.
|
||||
#
|
||||
# Purpose:
|
||||
# Serve CloudCLI UI from a path prefix such as:
|
||||
# http://localhost/ai/
|
||||
# https://example.com/ai/
|
||||
#
|
||||
# CloudCLI itself still runs at the root of its own HTTP server, for example:
|
||||
# http://127.0.0.1:3001/
|
||||
#
|
||||
# Nginx receives public requests under /ai, strips that prefix, and forwards the
|
||||
# remaining path to CloudCLI. For example:
|
||||
# /ai/ -> /
|
||||
# /ai/session/abc -> /session/abc
|
||||
# /ai/assets/index.js -> /assets/index.js
|
||||
#
|
||||
# Important Nginx limitation:
|
||||
# Nginx does not allow variables in `location` matchers or `rewrite` regexes.
|
||||
# The configurable variables below are still useful for proxy/filter values,
|
||||
# but if you change /ai to a different subpath, also update every line marked:
|
||||
# [SUBPATH LITERAL]
|
||||
#
|
||||
# To use a different subpath, replace these literal matchers:
|
||||
# location = /ai
|
||||
# location ^~ /ai/
|
||||
# rewrite ^/ai(?<cloudcli_path>/.*)$ ...
|
||||
#
|
||||
# Recommended deployment shape:
|
||||
# CloudCLI is the only app using /ai, while root paths /api, /ws, and /shell
|
||||
# are also proxied because the current frontend still calls those endpoints
|
||||
# with root-relative URLs.
|
||||
|
||||
worker_processes 1;
|
||||
|
||||
events {
|
||||
# Maximum simultaneous connections handled by each worker process.
|
||||
# The default is enough for local testing and small self-hosted deployments.
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# WebSocket requests include an Upgrade header. Normal HTTP requests do not.
|
||||
# This map gives us the right Connection header for both cases:
|
||||
# Upgrade present -> "upgrade"
|
||||
# Upgrade absent -> "close"
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
# For HTTPS deployments, replace this with `listen 443 ssl http2;` and
|
||||
# add ssl_certificate / ssl_certificate_key lines.
|
||||
listen 80 default_server;
|
||||
|
||||
# Use your real hostname in production, for example:
|
||||
# server_name cloudcli.example.com;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
# ---- User settings -------------------------------------------------
|
||||
#
|
||||
# Public path prefix where users access CloudCLI.
|
||||
# Do not add a trailing slash.
|
||||
#
|
||||
# This variable can be used in redirects and response rewrites. It
|
||||
# cannot be used in `location` matchers, so update the [SUBPATH LITERAL]
|
||||
# lines too if you change it.
|
||||
set $cloudcli_subpath /ai;
|
||||
|
||||
# Private upstream URL where the CloudCLI server is listening.
|
||||
# For a default local server this is usually http://127.0.0.1:3001.
|
||||
set $cloudcli_upstream http://127.0.0.1:3001;
|
||||
|
||||
# Allow larger file uploads through the code editor/project file APIs.
|
||||
client_max_body_size 200m;
|
||||
|
||||
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
|
||||
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
|
||||
location = /ai {
|
||||
return 301 $cloudcli_subpath/;
|
||||
}
|
||||
|
||||
# Main prefixed CloudCLI UI route.
|
||||
#
|
||||
# [SUBPATH LITERAL] Change `/ai/` and the `^/ai` rewrite if you change
|
||||
# $cloudcli_subpath.
|
||||
location ^~ /ai/ {
|
||||
# Strip the public subpath before proxying. CloudCLI expects to see
|
||||
# root paths such as /, /session/:id, /assets/..., /manifest.json.
|
||||
rewrite ^/ai(?<cloudcli_path>/.*)$ $cloudcli_path break;
|
||||
|
||||
# Forward the rewritten request to the private CloudCLI server.
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
# Use HTTP/1.1 so WebSocket upgrade requests can pass through if a
|
||||
# browser reaches a socket endpoint under the subpath.
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Preserve useful request metadata for logs and future app support.
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
|
||||
# WebSocket upgrade headers. Harmless for normal HTTP requests.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Long-running agent and terminal sessions can stay open for a long
|
||||
# time, so avoid closing idle proxied connections too aggressively.
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
|
||||
# Disable gzip from the upstream response so sub_filter can inspect
|
||||
# and rewrite HTML/JSON/JS response bodies.
|
||||
proxy_set_header Accept-Encoding "";
|
||||
|
||||
# Rewrite browser-visible root-relative URLs so the runtime can
|
||||
# discover that the app is mounted under the subpath.
|
||||
#
|
||||
# Examples:
|
||||
# href="/manifest.json" -> href="/ai/manifest.json"
|
||||
# src="/assets/app.js" -> src="/ai/assets/app.js"
|
||||
#
|
||||
# These rewrites are important for React Router basename detection.
|
||||
sub_filter_once off;
|
||||
sub_filter_types
|
||||
application/json
|
||||
application/manifest+json
|
||||
application/javascript
|
||||
text/javascript;
|
||||
|
||||
sub_filter 'href="/' 'href="$cloudcli_subpath/';
|
||||
sub_filter 'src="/' 'src="$cloudcli_subpath/';
|
||||
|
||||
# The production HTML and JS register the service worker at /sw.js.
|
||||
# Rewrite that registration so the worker is served from /ai/sw.js.
|
||||
sub_filter "register('/sw.js')" "register('$cloudcli_subpath/sw.js')";
|
||||
sub_filter 'register("/sw.js")' 'register("$cloudcli_subpath/sw.js")';
|
||||
|
||||
# The manifest and service worker contain root-relative paths too.
|
||||
# Rewriting them keeps PWA metadata and cached manifest requests
|
||||
# under the same public subpath.
|
||||
sub_filter '"start_url": "/"' '"start_url": "$cloudcli_subpath/"';
|
||||
sub_filter '"scope": "/"' '"scope": "$cloudcli_subpath/"';
|
||||
sub_filter '"src": "/' '"src": "$cloudcli_subpath/';
|
||||
sub_filter "'/manifest.json'" "'$cloudcli_subpath/manifest.json'";
|
||||
sub_filter '"/manifest.json"' '"$cloudcli_subpath/manifest.json"';
|
||||
}
|
||||
|
||||
# Root API proxy.
|
||||
#
|
||||
# The current CloudCLI frontend calls APIs with root-relative URLs such
|
||||
# as /api/auth/login. Keep this location unless the frontend becomes
|
||||
# fully prefix-aware for API requests.
|
||||
location ^~ /api/ {
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Main app WebSocket proxy.
|
||||
#
|
||||
# The frontend opens /ws for realtime chat/session/task updates.
|
||||
location /ws {
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Shell WebSocket proxy.
|
||||
#
|
||||
# The browser terminal uses /shell. It requires the same WebSocket
|
||||
# upgrade handling as /ws.
|
||||
location /shell {
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Optional health endpoint proxy used by the frontend version checker.
|
||||
location = /health {
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
}
|
||||
}
|
||||
}
|
||||
244
package-lock.json
generated
244
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.33.0",
|
||||
"version": "1.34.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.33.0",
|
||||
"version": "1.34.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -139,35 +139,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.116.tgz",
|
||||
"integrity": "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.165.tgz",
|
||||
"integrity": "sha512-wEUJNTAWkE6KMV35abqGi30lwhZz+jQLMtLh4SuTN2Hllzsysq8kmQFgcWulza3FLHG/GHzGHPi0+Sp2fb8xlw==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.81.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.165"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.116.tgz",
|
||||
"integrity": "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.165.tgz",
|
||||
"integrity": "sha512-obVodJmppNc6lgcM6Y5y3VCQLrYO2curOXrRaziKtjxYbuZP7kYsUhnonMvGoVAQh3uHKz2tivQDeztvWe3f9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -178,9 +176,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.116.tgz",
|
||||
"integrity": "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.165.tgz",
|
||||
"integrity": "sha512-0jc1tlYLXzPvZIkHKGHzsEEKq2YqTS8oHSNFroqLgbhrIk1Zy05ZXbciI289VDAe1Fq2a+qcUhkXct8Parx1Rg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -191,9 +189,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.116.tgz",
|
||||
"integrity": "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.165.tgz",
|
||||
"integrity": "sha512-t87HgDPPaRYMTTB5cqA0M36Fyq4DOny89yk71BMgA8hAzhOjV9bla8pMVZTuX3xYYPjsa/TOmxSzwI8GZLf4Aw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -204,9 +202,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.2.116.tgz",
|
||||
"integrity": "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.165.tgz",
|
||||
"integrity": "sha512-Rccmr5chZdZJVRvoB0nildB5PTKX+amatUho9JIcNOf1iX/6ej39fwf8q9W1MRHYP7AEc4t9GrSAGLcn7/JO4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -217,9 +215,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.116.tgz",
|
||||
"integrity": "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.165.tgz",
|
||||
"integrity": "sha512-Y8fEW0zKBn0XZI5AOQWHep0Srz0qsCauynTWkhsC6J2vSPxkTiOxv2hmb7qdfiNlFn0k1etCWVFoRkhhFJzGfg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -230,9 +228,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.2.116.tgz",
|
||||
"integrity": "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.165.tgz",
|
||||
"integrity": "sha512-Y9Acr1RmydfEX+t+3mFn0K9VOx6nfyo08QuQH9R6ap1YYZWuobze++pNUY/rzwbQjXqcbjORtPKbO/kLQtSr9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -243,9 +241,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.116.tgz",
|
||||
"integrity": "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.165.tgz",
|
||||
"integrity": "sha512-4Q01L3xaDDCvlOhABf2MnO7v7yJxKwwDyiMr+DaneUSvuh1qH0YE7qErSYLf6D9VfH8TdRwKZXwQplVVwCoHWw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -256,9 +254,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.116.tgz",
|
||||
"integrity": "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.165.tgz",
|
||||
"integrity": "sha512-Y0uOx7b7ZnkguvFFI5T5fSLnRA/e0uvMC++gSnyz6XMpNekgWc3+Mny7Dv2NO22nKbV2YiFsj6MkYYFEd51BDw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -269,12 +267,14 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.81.0.tgz",
|
||||
"integrity": "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==",
|
||||
"version": "0.100.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.100.1.tgz",
|
||||
"integrity": "sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"standardwebhooks": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
@@ -1837,6 +1837,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
@@ -2601,6 +2602,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -2641,6 +2643,7 @@
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-types": "^3.0.0",
|
||||
"negotiator": "^1.0.0"
|
||||
@@ -2650,10 +2653,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -2670,6 +2674,7 @@
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
@@ -2694,6 +2699,7 @@
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -2707,6 +2713,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
@@ -2716,6 +2723,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -2759,6 +2767,7 @@
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
@@ -2780,6 +2789,7 @@
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -2789,6 +2799,7 @@
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
@@ -2809,6 +2820,7 @@
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
@@ -2824,13 +2836,15 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -2840,6 +2854,7 @@
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -2852,6 +2867,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
@@ -2868,15 +2884,17 @@
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
@@ -2892,6 +2910,7 @@
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
@@ -2907,6 +2926,7 @@
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"encodeurl": "^2.0.0",
|
||||
@@ -2933,6 +2953,7 @@
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
@@ -2952,22 +2973,42 @@
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
@@ -4284,6 +4325,13 @@
|
||||
"url": "https://ko-fi.com/dangreen"
|
||||
}
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
|
||||
@@ -5417,6 +5465,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
@@ -5430,10 +5479,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats/node_modules/ajv": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -5449,7 +5499,8 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.3.0",
|
||||
@@ -8515,6 +8566,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"eventsource-parser": "^3.0.1"
|
||||
},
|
||||
@@ -8523,10 +8575,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
|
||||
"integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
@@ -8634,12 +8687,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
|
||||
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ip-address": "10.1.0"
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -8755,6 +8809,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
@@ -9812,10 +9873,11 @@
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.14",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
||||
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||
"version": "4.12.23",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
||||
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -10187,9 +10249,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -10632,7 +10694,8 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.2.1",
|
||||
@@ -10903,10 +10966,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
@@ -10968,6 +11032,7 @@
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
@@ -10987,7 +11052,8 @@
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
@@ -13979,6 +14045,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
@@ -15279,6 +15346,7 @@
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
@@ -15295,6 +15363,7 @@
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
@@ -16353,6 +16422,17 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
@@ -17063,7 +17143,8 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.4.0",
|
||||
@@ -18949,6 +19030,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
|
||||
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.33.0",
|
||||
"version": "1.34.0",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "dist-server/server/index.js",
|
||||
@@ -11,7 +11,6 @@
|
||||
"server/",
|
||||
"shared/",
|
||||
"public/api-docs.html",
|
||||
"public/modelConstants.js",
|
||||
"dist/",
|
||||
"dist-server/",
|
||||
"scripts/",
|
||||
@@ -67,7 +66,7 @@
|
||||
"author": "CloudCLI UI Contributors",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
|
||||
@@ -820,31 +820,49 @@ data: {"type":"done"}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// Import model constants
|
||||
import { PROVIDERS } from './modelConstants.js';
|
||||
|
||||
<script>
|
||||
// Dynamic URL replacement
|
||||
const apiUrl = window.location.origin;
|
||||
document.querySelectorAll('.api-url').forEach(el => {
|
||||
el.textContent = apiUrl;
|
||||
});
|
||||
|
||||
// Dynamically populate model documentation
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const modelCell = document.getElementById('model-options-cell');
|
||||
if (modelCell) {
|
||||
const providerModels = PROVIDERS.map(provider => {
|
||||
const models = provider.models.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
||||
return `<strong>${provider.name}:</strong> ${models} (default: <code>${provider.models.DEFAULT}</code>)`;
|
||||
}).join('<br><br>');
|
||||
// Populate model documentation from the live provider API
|
||||
const PROVIDER_ORDER = [
|
||||
{ id: 'claude', name: 'Anthropic' },
|
||||
{ id: 'codex', name: 'OpenAI' },
|
||||
{ id: 'gemini', name: 'Google' },
|
||||
{ id: 'cursor', name: 'Cursor' },
|
||||
{ id: 'opencode', name: 'OpenCode' },
|
||||
];
|
||||
|
||||
modelCell.innerHTML = `
|
||||
Model identifier for the AI provider:<br><br>
|
||||
${providerModels}
|
||||
`;
|
||||
}
|
||||
});
|
||||
async function populateModels() {
|
||||
const modelCell = document.getElementById('model-options-cell');
|
||||
if (!modelCell) return;
|
||||
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
PROVIDER_ORDER.map(({ id }) =>
|
||||
fetch(`/api/providers/${id}/models`, { headers }).then(r => r.json())
|
||||
)
|
||||
);
|
||||
|
||||
const providerModels = results.map((result, i) => {
|
||||
const { name } = PROVIDER_ORDER[i];
|
||||
if (result.status === 'rejected' || !result.value?.data?.models) {
|
||||
return `<strong>${name}:</strong> <em>unavailable</em>`;
|
||||
}
|
||||
const { OPTIONS, DEFAULT } = result.value.data.models;
|
||||
const models = OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
||||
return `<strong>${name}:</strong> ${models} (default: <code>${DEFAULT}</code>)`;
|
||||
}).join('<br><br>');
|
||||
|
||||
modelCell.innerHTML = `Model identifier for the AI provider:<br><br>${providerModels}`;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', populateModels);
|
||||
|
||||
// Tab switching
|
||||
window.showTab = function(tabName) {
|
||||
|
||||
@@ -1,841 +0,0 @@
|
||||
/**
|
||||
* Documentation Model Definitions
|
||||
* Used by README links and the public API docs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Claude (Anthropic) Models
|
||||
*/
|
||||
export const CLAUDE_MODELS = {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: "default",
|
||||
label: "Default (recommended)",
|
||||
description: "Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
label: "Sonnet",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
|
||||
},
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
label: "Sonnet (1M context)",
|
||||
description: "Sonnet 4.6 for long sessions · $3/$15 per Mtok",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
label: "Haiku",
|
||||
description: "Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: "default",
|
||||
};
|
||||
|
||||
/**
|
||||
* Cursor Models
|
||||
*/
|
||||
export const CURSOR_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: "auto", label: "auto", description: "Auto" },
|
||||
{
|
||||
value: "composer-2-fast",
|
||||
label: "composer-2-fast",
|
||||
description: "Composer 2 Fast",
|
||||
},
|
||||
{
|
||||
value: "composer-2",
|
||||
label: "composer-2",
|
||||
description: "Composer 2",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-low",
|
||||
label: "gpt-5.3-codex-low",
|
||||
description: "Codex 5.3 Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-low-fast",
|
||||
label: "gpt-5.3-codex-low-fast",
|
||||
description: "Codex 5.3 Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex",
|
||||
label: "gpt-5.3-codex",
|
||||
description: "Codex 5.3",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-fast",
|
||||
label: "gpt-5.3-codex-fast",
|
||||
description: "Codex 5.3 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-high",
|
||||
label: "gpt-5.3-codex-high",
|
||||
description: "Codex 5.3 High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-high-fast",
|
||||
label: "gpt-5.3-codex-high-fast",
|
||||
description: "Codex 5.3 High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-xhigh",
|
||||
label: "gpt-5.3-codex-xhigh",
|
||||
description: "Codex 5.3 Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-xhigh-fast",
|
||||
label: "gpt-5.3-codex-xhigh-fast",
|
||||
description: "Codex 5.3 Extra High Fast",
|
||||
},
|
||||
{ value: "gpt-5.2", label: "gpt-5.2", description: "GPT-5.2" },
|
||||
{
|
||||
value: "gpt-5.2-codex-low",
|
||||
label: "gpt-5.2-codex-low",
|
||||
description: "Codex 5.2 Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-low-fast",
|
||||
label: "gpt-5.2-codex-low-fast",
|
||||
description: "Codex 5.2 Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex",
|
||||
label: "gpt-5.2-codex",
|
||||
description: "Codex 5.2",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-fast",
|
||||
label: "gpt-5.2-codex-fast",
|
||||
description: "Codex 5.2 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-high",
|
||||
label: "gpt-5.2-codex-high",
|
||||
description: "Codex 5.2 High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-high-fast",
|
||||
label: "gpt-5.2-codex-high-fast",
|
||||
description: "Codex 5.2 High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-xhigh",
|
||||
label: "gpt-5.2-codex-xhigh",
|
||||
description: "Codex 5.2 Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-xhigh-fast",
|
||||
label: "gpt-5.2-codex-xhigh-fast",
|
||||
description: "Codex 5.2 Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-low",
|
||||
label: "gpt-5.1-codex-max-low",
|
||||
description: "Codex 5.1 Max Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-low-fast",
|
||||
label: "gpt-5.1-codex-max-low-fast",
|
||||
description: "Codex 5.1 Max Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-medium",
|
||||
label: "gpt-5.1-codex-max-medium",
|
||||
description: "Codex 5.1 Max",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-medium-fast",
|
||||
label: "gpt-5.1-codex-max-medium-fast",
|
||||
description: "Codex 5.1 Max Medium Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-high",
|
||||
label: "gpt-5.1-codex-max-high",
|
||||
description: "Codex 5.1 Max High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-high-fast",
|
||||
label: "gpt-5.1-codex-max-high-fast",
|
||||
description: "Codex 5.1 Max High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-xhigh",
|
||||
label: "gpt-5.1-codex-max-xhigh",
|
||||
description: "Codex 5.1 Max Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-xhigh-fast",
|
||||
label: "gpt-5.1-codex-max-xhigh-fast",
|
||||
description: "Codex 5.1 Max Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "composer-2.5",
|
||||
label: "composer-2.5",
|
||||
description: "Composer 2.5",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-high",
|
||||
label: "gpt-5.5-high",
|
||||
description: "GPT-5.5 1M High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-high-fast",
|
||||
label: "gpt-5.5-high-fast",
|
||||
description: "GPT-5.5 High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-high",
|
||||
label: "claude-opus-4-7-thinking-high",
|
||||
description: "Opus 4.7 1M High Thinking",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-high",
|
||||
label: "gpt-5.4-high",
|
||||
description: "GPT-5.4 1M High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-high-fast",
|
||||
label: "gpt-5.4-high-fast",
|
||||
description: "GPT-5.4 High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-high-thinking",
|
||||
label: "claude-4.6-opus-high-thinking",
|
||||
description: "Opus 4.6 1M Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-high-thinking-fast",
|
||||
label: "claude-4.6-opus-high-thinking-fast",
|
||||
description: "Opus 4.6 1M Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "composer-2.5-fast",
|
||||
label: "composer-2.5-fast",
|
||||
description: "Composer 2.5 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-none",
|
||||
label: "gpt-5.5-none",
|
||||
description: "GPT-5.5 1M None",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-none-fast",
|
||||
label: "gpt-5.5-none-fast",
|
||||
description: "GPT-5.5 None Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-low",
|
||||
label: "gpt-5.5-low",
|
||||
description: "GPT-5.5 1M Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-low-fast",
|
||||
label: "gpt-5.5-low-fast",
|
||||
description: "GPT-5.5 Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-medium",
|
||||
label: "gpt-5.5-medium",
|
||||
description: "GPT-5.5 1M",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-medium-fast",
|
||||
label: "gpt-5.5-medium-fast",
|
||||
description: "GPT-5.5 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-extra-high",
|
||||
label: "gpt-5.5-extra-high",
|
||||
description: "GPT-5.5 1M Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-extra-high-fast",
|
||||
label: "gpt-5.5-extra-high-fast",
|
||||
description: "GPT-5.5 Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-sonnet-medium",
|
||||
label: "claude-4.6-sonnet-medium",
|
||||
description: "Sonnet 4.6 1M",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-sonnet-medium-thinking",
|
||||
label: "claude-4.6-sonnet-medium-thinking",
|
||||
description: "Sonnet 4.6 1M Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-low",
|
||||
label: "claude-opus-4-7-low",
|
||||
description: "Opus 4.7 1M Low",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-low-fast",
|
||||
label: "claude-opus-4-7-low-fast",
|
||||
description: "Opus 4.7 1M Low Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-medium",
|
||||
label: "claude-opus-4-7-medium",
|
||||
description: "Opus 4.7 1M Medium",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-medium-fast",
|
||||
label: "claude-opus-4-7-medium-fast",
|
||||
description: "Opus 4.7 1M Medium Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-high",
|
||||
label: "claude-opus-4-7-high",
|
||||
description: "Opus 4.7 1M High",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-high-fast",
|
||||
label: "claude-opus-4-7-high-fast",
|
||||
description: "Opus 4.7 1M High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-xhigh",
|
||||
label: "claude-opus-4-7-xhigh",
|
||||
description: "Opus 4.7 1M",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-xhigh-fast",
|
||||
label: "claude-opus-4-7-xhigh-fast",
|
||||
description: "Opus 4.7 1M Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-max",
|
||||
label: "claude-opus-4-7-max",
|
||||
description: "Opus 4.7 1M Max",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-max-fast",
|
||||
label: "claude-opus-4-7-max-fast",
|
||||
description: "Opus 4.7 1M Max Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-low",
|
||||
label: "claude-opus-4-7-thinking-low",
|
||||
description: "Opus 4.7 1M Low Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-low-fast",
|
||||
label: "claude-opus-4-7-thinking-low-fast",
|
||||
description: "Opus 4.7 1M Low Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-medium",
|
||||
label: "claude-opus-4-7-thinking-medium",
|
||||
description: "Opus 4.7 1M Medium Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-medium-fast",
|
||||
label: "claude-opus-4-7-thinking-medium-fast",
|
||||
description: "Opus 4.7 1M Medium Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-high-fast",
|
||||
label: "claude-opus-4-7-thinking-high-fast",
|
||||
description: "Opus 4.7 1M High Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-xhigh",
|
||||
label: "claude-opus-4-7-thinking-xhigh",
|
||||
description: "Opus 4.7 1M Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-xhigh-fast",
|
||||
label: "claude-opus-4-7-thinking-xhigh-fast",
|
||||
description: "Opus 4.7 1M Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-max",
|
||||
label: "claude-opus-4-7-thinking-max",
|
||||
description: "Opus 4.7 1M Max Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-max-fast",
|
||||
label: "claude-opus-4-7-thinking-max-fast",
|
||||
description: "Opus 4.7 1M Max Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "grok-build-0.1",
|
||||
label: "grok-build-0.1",
|
||||
description: "Grok Build 0.1 1M",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-low",
|
||||
label: "gpt-5.4-low",
|
||||
description: "GPT-5.4 1M Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-medium",
|
||||
label: "gpt-5.4-medium",
|
||||
description: "GPT-5.4 1M",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-medium-fast",
|
||||
label: "gpt-5.4-medium-fast",
|
||||
description: "GPT-5.4 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-xhigh",
|
||||
label: "gpt-5.4-xhigh",
|
||||
description: "GPT-5.4 1M Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-xhigh-fast",
|
||||
label: "gpt-5.4-xhigh-fast",
|
||||
description: "GPT-5.4 Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-high",
|
||||
label: "claude-4.6-opus-high",
|
||||
description: "Opus 4.6 1M",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-max",
|
||||
label: "claude-4.6-opus-max",
|
||||
description: "Opus 4.6 1M Max",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-max-thinking",
|
||||
label: "claude-4.6-opus-max-thinking",
|
||||
description: "Opus 4.6 1M Max Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-max-thinking-fast",
|
||||
label: "claude-4.6-opus-max-thinking-fast",
|
||||
description: "Opus 4.6 1M Max Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-4.5-opus-high",
|
||||
label: "claude-4.5-opus-high",
|
||||
description: "Opus 4.5",
|
||||
},
|
||||
{
|
||||
value: "claude-4.5-opus-high-thinking",
|
||||
label: "claude-4.5-opus-high-thinking",
|
||||
description: "Opus 4.5 Thinking",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-low",
|
||||
label: "gpt-5.2-low",
|
||||
description: "GPT-5.2 Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-low-fast",
|
||||
label: "gpt-5.2-low-fast",
|
||||
description: "GPT-5.2 Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-fast",
|
||||
label: "gpt-5.2-fast",
|
||||
description: "GPT-5.2 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-high",
|
||||
label: "gpt-5.2-high",
|
||||
description: "GPT-5.2 High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-high-fast",
|
||||
label: "gpt-5.2-high-fast",
|
||||
description: "GPT-5.2 High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-xhigh",
|
||||
label: "gpt-5.2-xhigh",
|
||||
description: "GPT-5.2 Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-xhigh-fast",
|
||||
label: "gpt-5.2-xhigh-fast",
|
||||
description: "GPT-5.2 Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "gemini-3.1-pro",
|
||||
label: "gemini-3.1-pro",
|
||||
description: "Gemini 3.1 Pro",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-none",
|
||||
label: "gpt-5.4-mini-none",
|
||||
description: "GPT-5.4 Mini None",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-low",
|
||||
label: "gpt-5.4-mini-low",
|
||||
description: "GPT-5.4 Mini Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-medium",
|
||||
label: "gpt-5.4-mini-medium",
|
||||
description: "GPT-5.4 Mini",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-high",
|
||||
label: "gpt-5.4-mini-high",
|
||||
description: "GPT-5.4 Mini High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-xhigh",
|
||||
label: "gpt-5.4-mini-xhigh",
|
||||
description: "GPT-5.4 Mini Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-none",
|
||||
label: "gpt-5.4-nano-none",
|
||||
description: "GPT-5.4 Nano None",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-low",
|
||||
label: "gpt-5.4-nano-low",
|
||||
description: "GPT-5.4 Nano Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-medium",
|
||||
label: "gpt-5.4-nano-medium",
|
||||
description: "GPT-5.4 Nano",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-high",
|
||||
label: "gpt-5.4-nano-high",
|
||||
description: "GPT-5.4 Nano High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-xhigh",
|
||||
label: "gpt-5.4-nano-xhigh",
|
||||
description: "GPT-5.4 Nano Extra High",
|
||||
},
|
||||
{
|
||||
value: "grok-4.3",
|
||||
label: "grok-4.3",
|
||||
description: "Grok 4.3 1M",
|
||||
},
|
||||
{
|
||||
value: "claude-4.5-sonnet",
|
||||
label: "claude-4.5-sonnet",
|
||||
description: "Sonnet 4.5",
|
||||
},
|
||||
{
|
||||
value: "claude-4.5-sonnet-thinking",
|
||||
label: "claude-4.5-sonnet-thinking",
|
||||
description: "Sonnet 4.5 Thinking",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-low",
|
||||
label: "gpt-5.1-low",
|
||||
description: "GPT-5.1 Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1",
|
||||
label: "gpt-5.1",
|
||||
description: "GPT-5.1",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-high",
|
||||
label: "gpt-5.1-high",
|
||||
description: "GPT-5.1 High",
|
||||
},
|
||||
{
|
||||
value: "gemini-3-flash",
|
||||
label: "gemini-3-flash",
|
||||
description: "Gemini 3 Flash",
|
||||
},
|
||||
{
|
||||
value: "gemini-3.5-flash",
|
||||
label: "gemini-3.5-flash",
|
||||
description: "Gemini 3.5 Flash",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-mini-low",
|
||||
label: "gpt-5.1-codex-mini-low",
|
||||
description: "Codex 5.1 Mini Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-mini",
|
||||
label: "gpt-5.1-codex-mini",
|
||||
description: "Codex 5.1 Mini",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-mini-high",
|
||||
label: "gpt-5.1-codex-mini-high",
|
||||
description: "Codex 5.1 Mini High",
|
||||
},
|
||||
{
|
||||
value: "claude-4-sonnet",
|
||||
label: "claude-4-sonnet",
|
||||
description: "Sonnet 4",
|
||||
},
|
||||
{
|
||||
value: "claude-4-sonnet-thinking",
|
||||
label: "claude-4-sonnet-thinking",
|
||||
description: "Sonnet 4 Thinking",
|
||||
},
|
||||
{
|
||||
value: "gpt-5-mini",
|
||||
label: "gpt-5-mini",
|
||||
description: "GPT-5 Mini",
|
||||
},
|
||||
{
|
||||
value: "kimi-k2.5",
|
||||
label: "kimi-k2.5",
|
||||
description: "Kimi K2.5",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: "composer-2.5-fast",
|
||||
};
|
||||
|
||||
/**
|
||||
* Codex (OpenAI) Models
|
||||
*/
|
||||
export const CODEX_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: "gpt-5.5", label: "gpt-5.5" },
|
||||
{ value: "gpt-5.4", label: "gpt-5.4" },
|
||||
{ value: "gpt-5.4-mini", label: "gpt-5.4-mini" },
|
||||
{ value: "gpt-5.3-codex", label: "gpt-5.3-codex" },
|
||||
{ value: "gpt-5.2", label: "gpt-5.2" },
|
||||
],
|
||||
|
||||
DEFAULT: "gpt-5.4",
|
||||
};
|
||||
|
||||
/**
|
||||
* Gemini Models
|
||||
*/
|
||||
export const GEMINI_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
|
||||
{ value: "gemini-3-pro-preview", label: "Gemini 3 Pro Preview" },
|
||||
{ value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
|
||||
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
||||
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
||||
{ value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
|
||||
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
||||
{ value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" },
|
||||
{
|
||||
value: "gemini-2.0-flash-thinking-exp",
|
||||
label: "Gemini 2.0 Flash Thinking",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: "gemini-3.1-pro-preview",
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenCode Models
|
||||
*
|
||||
* OpenCode model ids include the upstream provider prefix.
|
||||
*/
|
||||
export const OPENCODE_MODELS = {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: "opencode/big-pickle",
|
||||
label: "Big Pickle",
|
||||
description: "opencode - opencode/big-pickle",
|
||||
},
|
||||
{
|
||||
value: "opencode/deepseek-v4-flash-free",
|
||||
label: "Deepseek V4 Flash Free",
|
||||
description: "opencode - opencode/deepseek-v4-flash-free",
|
||||
},
|
||||
{
|
||||
value: "opencode/nemotron-3-super-free",
|
||||
label: "Nemotron 3 Super Free",
|
||||
description: "opencode - opencode/nemotron-3-super-free",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-5-haiku-20241022",
|
||||
label: "Claude 3.5 Haiku (2024-10-22)",
|
||||
description: "anthropic - anthropic/claude-3-5-haiku-20241022",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-5-haiku-latest",
|
||||
label: "Claude 3.5 Haiku Latest",
|
||||
description: "anthropic - anthropic/claude-3-5-haiku-latest",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-5-sonnet-20240620",
|
||||
label: "Claude 3.5 Sonnet (2024-06-20)",
|
||||
description: "anthropic - anthropic/claude-3-5-sonnet-20240620",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-5-sonnet-20241022",
|
||||
label: "Claude 3.5 Sonnet (2024-10-22)",
|
||||
description: "anthropic - anthropic/claude-3-5-sonnet-20241022",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-7-sonnet-20250219",
|
||||
label: "Claude 3.7 Sonnet (2025-02-19)",
|
||||
description: "anthropic - anthropic/claude-3-7-sonnet-20250219",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-haiku-20240307",
|
||||
label: "Claude 3 Haiku (2024-03-07)",
|
||||
description: "anthropic - anthropic/claude-3-haiku-20240307",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-opus-20240229",
|
||||
label: "Claude 3 Opus (2024-02-29)",
|
||||
description: "anthropic - anthropic/claude-3-opus-20240229",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-sonnet-20240229",
|
||||
label: "Claude 3 Sonnet (2024-02-29)",
|
||||
description: "anthropic - anthropic/claude-3-sonnet-20240229",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-haiku-4-5",
|
||||
label: "Claude Haiku 4.5",
|
||||
description: "anthropic - anthropic/claude-haiku-4-5",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-haiku-4-5-20251001",
|
||||
label: "Claude Haiku 4.5 (2025-10-01)",
|
||||
description: "anthropic - anthropic/claude-haiku-4-5-20251001",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-0",
|
||||
label: "Claude Opus 4.0",
|
||||
description: "anthropic - anthropic/claude-opus-4-0",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-1",
|
||||
label: "Claude Opus 4.1",
|
||||
description: "anthropic - anthropic/claude-opus-4-1",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-1-20250805",
|
||||
label: "Claude Opus 4.1 (2025-08-05)",
|
||||
description: "anthropic - anthropic/claude-opus-4-1-20250805",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-20250514",
|
||||
label: "Claude Opus 4 (2025-05-14)",
|
||||
description: "anthropic - anthropic/claude-opus-4-20250514",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-5",
|
||||
label: "Claude Opus 4.5",
|
||||
description: "anthropic - anthropic/claude-opus-4-5",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-5-20251101",
|
||||
label: "Claude Opus 4.5 (2025-11-01)",
|
||||
description: "anthropic - anthropic/claude-opus-4-5-20251101",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-6",
|
||||
label: "Claude Opus 4.6",
|
||||
description: "anthropic - anthropic/claude-opus-4-6",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-6-fast",
|
||||
label: "Claude Opus 4.6 Fast",
|
||||
description: "anthropic - anthropic/claude-opus-4-6-fast",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-7",
|
||||
label: "Claude Opus 4.7",
|
||||
description: "anthropic - anthropic/claude-opus-4-7",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-7-fast",
|
||||
label: "Claude Opus 4.7 Fast",
|
||||
description: "anthropic - anthropic/claude-opus-4-7-fast",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-0",
|
||||
label: "Claude Sonnet 4.0",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-0",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-20250514",
|
||||
label: "Claude Sonnet 4 (2025-05-14)",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-20250514",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-5",
|
||||
label: "Claude Sonnet 4.5",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-5-20250929",
|
||||
label: "Claude Sonnet 4.5 (2025-09-29)",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-5-20250929",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-6",
|
||||
label: "Claude Sonnet 4.6",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.2",
|
||||
label: "GPT-5.2",
|
||||
description: "openai - openai/gpt-5.2",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.3-codex",
|
||||
label: "GPT-5.3 Codex",
|
||||
description: "openai - openai/gpt-5.3-codex",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.3-codex-spark",
|
||||
label: "GPT-5.3 Codex Spark",
|
||||
description: "openai - openai/gpt-5.3-codex-spark",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
description: "openai - openai/gpt-5.4",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4-fast",
|
||||
label: "GPT-5.4 Fast",
|
||||
description: "openai - openai/gpt-5.4-fast",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4-mini",
|
||||
label: "GPT-5.4 Mini",
|
||||
description: "openai - openai/gpt-5.4-mini",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4-mini-fast",
|
||||
label: "GPT-5.4 Mini Fast",
|
||||
description: "openai - openai/gpt-5.4-mini-fast",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.5",
|
||||
label: "GPT-5.5",
|
||||
description: "openai - openai/gpt-5.5",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.5-fast",
|
||||
label: "GPT-5.5 Fast",
|
||||
description: "openai - openai/gpt-5.5-fast",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.5-pro",
|
||||
label: "GPT-5.5 Pro",
|
||||
description: "openai - openai/gpt-5.5-pro",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: "anthropic/claude-sonnet-4-5",
|
||||
};
|
||||
|
||||
/**
|
||||
* Ordered provider registry. Display order in documentation.
|
||||
*/
|
||||
export const PROVIDERS = [
|
||||
{ id: "claude", name: "Anthropic", models: CLAUDE_MODELS },
|
||||
{ id: "codex", name: "OpenAI", models: CODEX_MODELS },
|
||||
{ id: "gemini", name: "Google", models: GEMINI_MODELS },
|
||||
{ id: "cursor", name: "Cursor", models: CURSOR_MODELS },
|
||||
{ id: "opencode", name: "OpenCode", models: OPENCODE_MODELS },
|
||||
];
|
||||
@@ -75,7 +75,7 @@
|
||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](https://github.com/siteboon/claudecodeui/blob/main/public/modelConstants.js) for the full list of supported models)
|
||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -28,10 +28,14 @@ import {
|
||||
} from './services/notification-orchestrator.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
const activeSessions = new Map();
|
||||
const pendingToolApprovals = new Map();
|
||||
// Sessions cancelled via abort-session. The abort handler already sent the
|
||||
// terminal `complete` (aborted: true) to the client, so the run loop must not
|
||||
// emit a second one when its generator winds down.
|
||||
const abortedSessionIds = new Set();
|
||||
|
||||
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
|
||||
|
||||
@@ -204,7 +208,7 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
||||
|
||||
// Map model (default to sonnet)
|
||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
|
||||
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
|
||||
// Model logged at query start below
|
||||
|
||||
@@ -304,7 +308,11 @@ function extractTokenBudget(sdkMessage) {
|
||||
|
||||
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
|
||||
if (messageUsage && typeof messageUsage === 'object') {
|
||||
const inputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
|
||||
const directInputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
|
||||
const cacheCreationTokens = readNumber(messageUsage.cache_creation_input_tokens ?? messageUsage.cacheCreationInputTokens ?? messageUsage.cacheCreationTokens);
|
||||
const cacheReadTokens = readNumber(messageUsage.cache_read_input_tokens ?? messageUsage.cacheReadInputTokens ?? messageUsage.cacheReadTokens);
|
||||
const cacheTokens = cacheCreationTokens + cacheReadTokens;
|
||||
const inputTokens = directInputTokens + cacheTokens;
|
||||
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
|
||||
const totalUsed = inputTokens + outputTokens;
|
||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
||||
@@ -314,6 +322,9 @@ function extractTokenBudget(sdkMessage) {
|
||||
total: contextWindow,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
cacheTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
@@ -724,14 +735,18 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
// Clean up temporary image files
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
// Send completion event
|
||||
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
|
||||
// Send the terminal completion event — skipped for aborted runs, whose
|
||||
// terminal `complete` (aborted: true) was already sent by abort-session.
|
||||
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
|
||||
if (!wasAborted) {
|
||||
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
|
||||
}
|
||||
notifyRunStopped({
|
||||
userId: ws?.userId || null,
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
sessionName: sessionSummary,
|
||||
stopReason: 'completed'
|
||||
stopReason: wasAborted ? 'aborted' : 'completed'
|
||||
});
|
||||
// Complete
|
||||
|
||||
@@ -746,14 +761,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
// Clean up temporary image files on error
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
|
||||
if (wasAborted) {
|
||||
// The abort already produced the terminal complete; a generator throw
|
||||
// caused by interrupt() is expected noise, not a user-facing error.
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Claude CLI is installed for a clearer error message
|
||||
const installed = await providerAuthService.isProviderInstalled('claude');
|
||||
const errorContent = !installed
|
||||
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
|
||||
: error.message;
|
||||
|
||||
// Send error to WebSocket
|
||||
// Send error to WebSocket, then the terminal complete
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
|
||||
notifyRunFailed({
|
||||
userId: ws?.userId || null,
|
||||
provider: 'claude',
|
||||
@@ -780,6 +803,10 @@ async function abortClaudeSDKSession(sessionId) {
|
||||
try {
|
||||
console.log(`Aborting SDK session: ${sessionId}`);
|
||||
|
||||
// Mark before interrupting so the run loop knows not to emit its own
|
||||
// terminal complete (the abort handler sends the aborted one).
|
||||
abortedSessionIds.add(sessionId);
|
||||
|
||||
// Call interrupt() on the query instance
|
||||
await session.instance.interrupt();
|
||||
|
||||
@@ -795,6 +822,8 @@ async function abortClaudeSDKSession(sessionId) {
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error aborting session ${sessionId}:`, error);
|
||||
// The run keeps going; let it emit its own terminal complete.
|
||||
abortedSessionIds.delete(sessionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ async function sandboxCommand(args) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
||||
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
|
||||
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
|
||||
|
||||
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
||||
try {
|
||||
@@ -554,7 +554,7 @@ async function sandboxCommand(args) {
|
||||
|
||||
// Step 3: Start CloudCLI inside the sandbox
|
||||
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
||||
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
|
||||
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
|
||||
|
||||
// Step 4: Forward port
|
||||
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Use cross-spawn on Windows for better command execution
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
@@ -34,6 +34,10 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
let hasRetriedWithTrust = false;
|
||||
let settled = false;
|
||||
// The unified lifecycle contract requires exactly one terminal `complete`
|
||||
// per run. Cursor surfaces completion twice (the `result` JSON line and
|
||||
// the process close), so the first emission wins.
|
||||
let completeSent = false;
|
||||
|
||||
// Use tools settings passed from frontend, or defaults
|
||||
const settings = toolsSettings || {
|
||||
@@ -197,15 +201,15 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
break;
|
||||
|
||||
case 'result': {
|
||||
// Session complete — send stream end + lifecycle complete with result payload
|
||||
const resultText = typeof response.result === 'string' ? response.result : '';
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'complete',
|
||||
exitCode: response.subtype === 'success' ? 0 : 1,
|
||||
resultText,
|
||||
isError: response.subtype !== 'success',
|
||||
sessionId: capturedSessionId || sessionId, provider: 'cursor',
|
||||
}));
|
||||
// Session complete — terminal lifecycle event for this run
|
||||
if (!completeSent) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({
|
||||
provider: 'cursor',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
exitCode: response.subtype === 'success' ? 0 : 1,
|
||||
}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -271,7 +275,12 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' }));
|
||||
// Terminal complete — unless the `result` line already sent it, or the
|
||||
// run was aborted (abort-session sent the aborted complete).
|
||||
if (!completeSent && !cursorProcess.aborted) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: finalSessionId, exitCode: code }));
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
notifyTerminalState({ code });
|
||||
@@ -297,6 +306,10 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
: error.message;
|
||||
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
|
||||
if (!completeSent && !cursorProcess.aborted) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
|
||||
}
|
||||
notifyTerminalState({ error });
|
||||
|
||||
settleOnce(() => reject(error));
|
||||
@@ -314,6 +327,9 @@ function abortCursorSession(sessionId) {
|
||||
const process = activeCursorProcesses.get(sessionId);
|
||||
if (process) {
|
||||
console.log(`Aborting Cursor session: ${sessionId}`);
|
||||
// The abort handler sends the terminal complete (aborted: true); flag the
|
||||
// process so its close handler does not emit a second one.
|
||||
process.aborted = true;
|
||||
process.kill('SIGTERM');
|
||||
activeCursorProcesses.delete(sessionId);
|
||||
return true;
|
||||
|
||||
@@ -10,7 +10,7 @@ import GeminiResponseHandler from './gemini-response-handler.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
@@ -129,6 +129,9 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
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
|
||||
// Unified lifecycle contract: exactly one terminal `complete` per run
|
||||
// (close and error handlers can both fire for spawn failures).
|
||||
let completeSent = false;
|
||||
|
||||
// Use tools settings passed from frontend, or defaults
|
||||
const settings = toolsSettings || {
|
||||
@@ -486,7 +489,12 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
|
||||
}
|
||||
|
||||
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' }));
|
||||
// Terminal complete — skipped for aborted runs (abort-session
|
||||
// already sent the aborted complete on this run's behalf).
|
||||
if (!completeSent && !geminiProcess.aborted) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: finalSessionId, exitCode: code }));
|
||||
}
|
||||
|
||||
// Clean up temporary image files if any
|
||||
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
|
||||
@@ -566,6 +574,10 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
|
||||
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
|
||||
if (!completeSent && !geminiProcess.aborted) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: errorSessionId, exitCode: 1 }));
|
||||
}
|
||||
notifyTerminalState({ error });
|
||||
|
||||
reject(error);
|
||||
@@ -590,6 +602,9 @@ function abortGeminiSession(sessionId) {
|
||||
|
||||
if (geminiProc) {
|
||||
try {
|
||||
// The abort handler sends the terminal complete (aborted: true);
|
||||
// flag the process so its close handler does not emit a second one.
|
||||
geminiProc.aborted = true;
|
||||
geminiProc.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (activeGeminiProcesses.has(processKey)) {
|
||||
|
||||
288
server/index.js
288
server/index.js
@@ -22,35 +22,24 @@ import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
|
||||
import {
|
||||
queryClaudeSDK,
|
||||
abortClaudeSDKSession,
|
||||
isClaudeSDKSessionActive,
|
||||
getActiveClaudeSDKSessions,
|
||||
resolveToolApproval,
|
||||
getPendingApprovalsForSession,
|
||||
reconnectSessionWriter,
|
||||
} from './claude-sdk.js';
|
||||
import {
|
||||
spawnCursor,
|
||||
abortCursorSession,
|
||||
isCursorSessionActive,
|
||||
getActiveCursorSessions,
|
||||
} from './cursor-cli.js';
|
||||
import {
|
||||
queryCodex,
|
||||
abortCodexSession,
|
||||
isCodexSessionActive,
|
||||
getActiveCodexSessions,
|
||||
} from './openai-codex.js';
|
||||
import {
|
||||
spawnGemini,
|
||||
abortGeminiSession,
|
||||
isGeminiSessionActive,
|
||||
getActiveGeminiSessions,
|
||||
} from './gemini-cli.js';
|
||||
import {
|
||||
spawnOpenCode,
|
||||
abortOpenCodeSession,
|
||||
isOpenCodeSessionActive,
|
||||
getActiveOpenCodeSessions,
|
||||
} from './opencode-cli.js';
|
||||
import sessionManager from './sessionManager.js';
|
||||
import {
|
||||
@@ -84,9 +73,17 @@ const __dirname = getModuleDir(import.meta.url);
|
||||
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
||||
const APP_ROOT = findAppRoot(__dirname);
|
||||
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
||||
const MAX_FILE_UPLOAD_SIZE_MB = 200;
|
||||
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
|
||||
const MAX_FILE_UPLOAD_COUNT = 20;
|
||||
|
||||
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
|
||||
|
||||
function readUsageNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
@@ -97,29 +94,22 @@ const wss = createWebSocketServer(server, {
|
||||
authenticateWebSocket,
|
||||
},
|
||||
chat: {
|
||||
queryClaudeSDK,
|
||||
spawnCursor,
|
||||
queryCodex,
|
||||
spawnGemini,
|
||||
spawnOpenCode,
|
||||
abortClaudeSDKSession,
|
||||
abortCursorSession,
|
||||
abortCodexSession,
|
||||
abortGeminiSession,
|
||||
abortOpenCodeSession,
|
||||
spawnFns: {
|
||||
claude: queryClaudeSDK,
|
||||
cursor: spawnCursor,
|
||||
codex: queryCodex,
|
||||
gemini: spawnGemini,
|
||||
opencode: spawnOpenCode,
|
||||
},
|
||||
abortFns: {
|
||||
claude: abortClaudeSDKSession,
|
||||
cursor: abortCursorSession,
|
||||
codex: abortCodexSession,
|
||||
gemini: abortGeminiSession,
|
||||
opencode: abortOpenCodeSession,
|
||||
},
|
||||
resolveToolApproval,
|
||||
isClaudeSDKSessionActive,
|
||||
isCursorSessionActive,
|
||||
isCodexSessionActive,
|
||||
isGeminiSessionActive,
|
||||
isOpenCodeSessionActive,
|
||||
reconnectSessionWriter,
|
||||
getPendingApprovalsForSession,
|
||||
getActiveClaudeSDKSessions,
|
||||
getActiveCursorSessions,
|
||||
getActiveCodexSessions,
|
||||
getActiveGeminiSessions,
|
||||
getActiveOpenCodeSessions,
|
||||
},
|
||||
shell: {
|
||||
getSessionById: (sessionId) => sessionManager.getSession(sessionId),
|
||||
@@ -892,27 +882,27 @@ const uploadFilesHandler = async (req, res) => {
|
||||
}
|
||||
}),
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB limit
|
||||
files: 20 // Max 20 files at once
|
||||
fileSize: MAX_FILE_UPLOAD_SIZE_BYTES,
|
||||
files: MAX_FILE_UPLOAD_COUNT
|
||||
}
|
||||
});
|
||||
|
||||
// Use multer middleware
|
||||
uploadMiddleware.array('files', 20)(req, res, async (err) => {
|
||||
uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => {
|
||||
if (err) {
|
||||
console.error('Multer error:', err);
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
|
||||
return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_UPLOAD_SIZE_MB}MB.` });
|
||||
}
|
||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
||||
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
|
||||
return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` });
|
||||
}
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const { targetPath, relativePaths } = req.body;
|
||||
const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body;
|
||||
|
||||
// Parse relative paths if provided (for folder uploads)
|
||||
let filePaths = [];
|
||||
@@ -936,6 +926,11 @@ const uploadFilesHandler = async (req, res) => {
|
||||
return res.status(400).json({ error: 'No files provided' });
|
||||
}
|
||||
|
||||
const parsedRequestedFileCount = Number.parseInt(requestedFileCountRaw, 10);
|
||||
const requestedFileCount = Number.isFinite(parsedRequestedFileCount) && parsedRequestedFileCount > 0
|
||||
? parsedRequestedFileCount
|
||||
: req.files.length;
|
||||
|
||||
// Resolve the project directory through the DB using the new projectId.
|
||||
const projectRoot = await projectsDb.getProjectPathById(projectId);
|
||||
if (!projectRoot) {
|
||||
@@ -1014,8 +1009,10 @@ const uploadFilesHandler = async (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
files: uploadedFiles,
|
||||
uploadedCount: uploadedFiles.length,
|
||||
requestedFileCount,
|
||||
targetPath: resolvedTargetDir,
|
||||
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
|
||||
message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading files:', error);
|
||||
@@ -1137,6 +1134,12 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||
}
|
||||
|
||||
// Provider artifacts on disk (JSONL file names, OpenCode sqlite rows)
|
||||
// are keyed by the provider-native session id, while the caller sends
|
||||
// the app-facing id. Resolve the mapping once for all branches below.
|
||||
const sessionRow = sessionsDb.getSessionById(safeSessionId);
|
||||
const providerNativeSessionId = sessionRow?.provider_session_id || safeSessionId;
|
||||
|
||||
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
||||
if (provider === 'cursor') {
|
||||
return res.json({
|
||||
@@ -1237,7 +1240,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
tokens_cache_write AS cacheWriteTokens
|
||||
FROM session
|
||||
WHERE id = ?
|
||||
`).get(safeSessionId);
|
||||
`).get(providerNativeSessionId);
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'OpenCode session not found', sessionId: safeSessionId });
|
||||
@@ -1278,7 +1281,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
if (entry.isDirectory()) {
|
||||
const found = await findSessionFile(fullPath);
|
||||
if (found) return found;
|
||||
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
||||
} else if (entry.name.includes(providerNativeSessionId) && entry.name.endsWith('.jsonl')) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
@@ -1362,12 +1365,19 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
||||
|
||||
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
||||
// Prefer the indexed transcript path (already produced by the trusted
|
||||
// session synchronizer); fall back to the conventional location
|
||||
// derived from the provider-native session id.
|
||||
let jsonlPath = sessionRow?.jsonl_path;
|
||||
if (!jsonlPath) {
|
||||
jsonlPath = path.join(projectDir, `${providerNativeSessionId}.jsonl`);
|
||||
|
||||
// Constrain to projectDir
|
||||
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
return res.status(400).json({ error: 'Invalid path' });
|
||||
// Constrain the constructed path to projectDir (the id is
|
||||
// caller-influenced in this fallback branch).
|
||||
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
return res.status(400).json({ error: 'Invalid path' });
|
||||
}
|
||||
}
|
||||
|
||||
// Read and parse the JSONL file
|
||||
@@ -1386,6 +1396,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
let cacheCreationTokens = 0;
|
||||
|
||||
// Find the latest assistant message with usage data (scan from end)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
@@ -1397,8 +1409,11 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
const usage = entry.message.usage;
|
||||
|
||||
// Use token counts from latest assistant message only
|
||||
inputTokens = usage.input_tokens || 0;
|
||||
outputTokens = usage.output_tokens || 0;
|
||||
const directInputTokens = readUsageNumber(usage.input_tokens ?? usage.inputTokens);
|
||||
cacheReadTokens = readUsageNumber(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? usage.cacheReadTokens);
|
||||
cacheCreationTokens = readUsageNumber(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens ?? usage.cacheCreationTokens);
|
||||
inputTokens = directInputTokens + cacheReadTokens + cacheCreationTokens;
|
||||
outputTokens = readUsageNumber(usage.output_tokens ?? usage.outputTokens);
|
||||
|
||||
break; // Stop after finding the latest assistant message
|
||||
}
|
||||
@@ -1409,12 +1424,16 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
}
|
||||
|
||||
const totalUsed = inputTokens + outputTokens;
|
||||
const cacheTokens = cacheReadTokens + cacheCreationTokens;
|
||||
|
||||
res.json({
|
||||
used: totalUsed,
|
||||
total: contextWindow,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
cacheTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens
|
||||
@@ -1483,74 +1502,133 @@ function permToRwx(perm) {
|
||||
return r + w + x;
|
||||
}
|
||||
|
||||
// Directories that are almost never interesting for a project tree but can
|
||||
// contain tens of thousands of files. Skipping them before recursion keeps
|
||||
// traversal time bounded on large monorepos and high-latency filesystems
|
||||
// (NFS / SMB).
|
||||
const IGNORED_DIRS = new Set([
|
||||
// JS / TS toolchains
|
||||
'node_modules', 'dist', 'build', '.next', '.nuxt', '.cache', '.parcel-cache',
|
||||
// VCS
|
||||
'.git', '.svn', '.hg',
|
||||
// Python
|
||||
'__pycache__', '.pytest_cache', '.mypy_cache', '.tox', 'venv', '.venv',
|
||||
// Rust / Go / Java / Ruby
|
||||
'target', 'vendor',
|
||||
// Build output / IDE
|
||||
'.gradle', '.idea', 'coverage', '.nyc_output'
|
||||
]);
|
||||
|
||||
const DEFAULT_FS_CONCURRENCY = 64;
|
||||
const parsedFsConcurrency = Number.parseInt(process.env.FS_CONCURRENCY || '', 10);
|
||||
const FS_CONCURRENCY = Number.isFinite(parsedFsConcurrency) && parsedFsConcurrency > 0
|
||||
? parsedFsConcurrency
|
||||
: DEFAULT_FS_CONCURRENCY;
|
||||
let activeFsOperations = 0;
|
||||
const pendingFsOperations = [];
|
||||
|
||||
async function acquire() {
|
||||
if (activeFsOperations < FS_CONCURRENCY) {
|
||||
activeFsOperations += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
pendingFsOperations.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function release() {
|
||||
const next = pendingFsOperations.shift();
|
||||
if (next) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
activeFsOperations = Math.max(0, activeFsOperations - 1);
|
||||
}
|
||||
|
||||
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
||||
// Using fsPromises from import
|
||||
const items = [];
|
||||
|
||||
let entries;
|
||||
try {
|
||||
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
// Debug: log all entries including hidden files
|
||||
|
||||
|
||||
// Skip heavy build directories and VCS directories
|
||||
if (entry.name === 'node_modules' ||
|
||||
entry.name === 'dist' ||
|
||||
entry.name === 'build' ||
|
||||
entry.name === '.git' ||
|
||||
entry.name === '.svn' ||
|
||||
entry.name === '.hg') continue;
|
||||
|
||||
const itemPath = path.join(dirPath, entry.name);
|
||||
const item = {
|
||||
name: entry.name,
|
||||
path: itemPath,
|
||||
type: entry.isDirectory() ? 'directory' : 'file'
|
||||
};
|
||||
|
||||
// Get file stats for additional metadata
|
||||
try {
|
||||
const stats = await fsPromises.stat(itemPath);
|
||||
item.size = stats.size;
|
||||
item.modified = stats.mtime.toISOString();
|
||||
|
||||
// Convert permissions to rwx format
|
||||
const mode = stats.mode;
|
||||
const ownerPerm = (mode >> 6) & 7;
|
||||
const groupPerm = (mode >> 3) & 7;
|
||||
const otherPerm = mode & 7;
|
||||
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
|
||||
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
|
||||
} catch (statError) {
|
||||
// If stat fails, provide default values
|
||||
item.size = 0;
|
||||
item.modified = null;
|
||||
item.permissions = '000';
|
||||
item.permissionsRwx = '---------';
|
||||
}
|
||||
|
||||
if (entry.isDirectory() && currentDepth < maxDepth) {
|
||||
// Recursively get subdirectories but limit depth
|
||||
try {
|
||||
// Check if we can access the directory before trying to read it
|
||||
await fsPromises.access(item.path, fs.constants.R_OK);
|
||||
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
|
||||
} catch (e) {
|
||||
// Silently skip directories we can't access (permission denied, etc.)
|
||||
item.children = [];
|
||||
}
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
await acquire();
|
||||
try {
|
||||
entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
} catch (error) {
|
||||
// Only log non-permission errors to avoid spam
|
||||
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
||||
console.error('Error reading directory:', error);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredEntries = entries.filter((entry) => !(entry.isDirectory() && IGNORED_DIRS.has(entry.name)));
|
||||
|
||||
// Process every entry in parallel. On high-latency filesystems (NFS/SMB)
|
||||
// serial stat() was the real bottleneck — issuing them concurrently lets
|
||||
// the kernel pipeline the round-trips and the recursive calls overlap too.
|
||||
const items = await Promise.all(filteredEntries.map(async (entry) => {
|
||||
const itemPath = path.join(dirPath, entry.name);
|
||||
const item = {
|
||||
name: entry.name,
|
||||
path: itemPath,
|
||||
type: entry.isDirectory() ? 'directory' : 'file'
|
||||
};
|
||||
|
||||
// Get file stats for additional metadata
|
||||
try {
|
||||
await acquire();
|
||||
try {
|
||||
const stats = await fsPromises.lstat(itemPath);
|
||||
item.size = stats.size;
|
||||
item.modified = stats.mtime.toISOString();
|
||||
|
||||
// Mark symlinks so UI can distinguish them
|
||||
if (stats.isSymbolicLink()) {
|
||||
item.isSymlink = true;
|
||||
}
|
||||
|
||||
// Convert permissions to rwx format
|
||||
const mode = stats.mode;
|
||||
const ownerPerm = (mode >> 6) & 7;
|
||||
const groupPerm = (mode >> 3) & 7;
|
||||
const otherPerm = mode & 7;
|
||||
item.permissions =
|
||||
((mode >> 6) & 7).toString() +
|
||||
((mode >> 3) & 7).toString() +
|
||||
(mode & 7).toString();
|
||||
item.permissionsRwx =
|
||||
permToRwx(ownerPerm) +
|
||||
permToRwx(groupPerm) +
|
||||
permToRwx(otherPerm);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
} catch (statError) {
|
||||
// If stat fails, provide default values
|
||||
item.size = 0;
|
||||
item.modified = null;
|
||||
item.permissions = '000';
|
||||
item.permissionsRwx = '---------';
|
||||
}
|
||||
|
||||
if (entry.isDirectory() && currentDepth < maxDepth) {
|
||||
// Recurse. Let readdir's own EACCES bubble up through the catch in
|
||||
// the recursive call rather than doing a separate access() probe
|
||||
// (which doubled the round-trip count on SMB without adding info).
|
||||
// The recursive call starts with a bounded readdir; holding a permit
|
||||
// for the whole subtree can deadlock when sibling directories are
|
||||
// waiting on their own children.
|
||||
item.children = await getFileTree(itemPath, maxDepth, currentDepth + 1, showHidden);
|
||||
}
|
||||
|
||||
return item;
|
||||
}));
|
||||
|
||||
return items.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'directory' ? -1 : 1;
|
||||
|
||||
@@ -382,6 +382,25 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds the `provider_session_id` mapping column used by the session gateway.
|
||||
*
|
||||
* Rows that existed before this migration were always keyed directly by the
|
||||
* provider-native session id, so backfilling `provider_session_id` with
|
||||
* `session_id` keeps every legacy row resolvable through the new mapping.
|
||||
*/
|
||||
const addProviderSessionIdMapping = (db: Database): void => {
|
||||
const sessionsTableInfo = getTableInfo(db, 'sessions');
|
||||
const columnNames = sessionsTableInfo.map((column) => column.name);
|
||||
|
||||
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'provider_session_id', 'TEXT');
|
||||
db.exec(`
|
||||
UPDATE sessions
|
||||
SET provider_session_id = session_id
|
||||
WHERE provider_session_id IS NULL
|
||||
`);
|
||||
};
|
||||
|
||||
const ensureProjectsForSessionPaths = (db: Database): void => {
|
||||
if (!tableExists(db, 'sessions')) {
|
||||
return;
|
||||
@@ -428,9 +447,11 @@ export const runMigrations = (db: Database) => {
|
||||
migrateLegacyWorkspaceTableIntoProjects(db);
|
||||
rebuildSessionsTableWithProjectSchema(db);
|
||||
migrateLegacySessionNames(db);
|
||||
addProviderSessionIdMapping(db);
|
||||
ensureProjectsForSessionPaths(db);
|
||||
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_provider_session_id ON sessions(provider_session_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');
|
||||
|
||||
@@ -10,6 +10,7 @@ type NotificationPreferences = {
|
||||
channels: {
|
||||
inApp: boolean;
|
||||
webPush: boolean;
|
||||
sound: boolean;
|
||||
};
|
||||
events: {
|
||||
actionRequired: boolean;
|
||||
@@ -22,6 +23,7 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
||||
channels: {
|
||||
inApp: false,
|
||||
webPush: false,
|
||||
sound: true,
|
||||
},
|
||||
events: {
|
||||
actionRequired: true,
|
||||
@@ -37,6 +39,7 @@ function normalizeNotificationPreferences(value: unknown): NotificationPreferenc
|
||||
channels: {
|
||||
inApp: source.channels?.inApp === true,
|
||||
webPush: source.channels?.webPush === true,
|
||||
sound: source.channels?.sound !== false,
|
||||
},
|
||||
events: {
|
||||
actionRequired: source.events?.actionRequired !== false,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { normalizeProjectPath } from '@/shared/utils.js';
|
||||
type SessionRow = {
|
||||
session_id: string;
|
||||
provider: string;
|
||||
provider_session_id: string | null;
|
||||
project_path: string | null;
|
||||
jsonl_path: string | null;
|
||||
custom_name: string | null;
|
||||
@@ -13,10 +14,8 @@ type SessionRow = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type SessionMetadataLookupRow = Pick<
|
||||
SessionRow,
|
||||
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at'
|
||||
>;
|
||||
const SESSION_ROW_COLUMNS =
|
||||
'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at';
|
||||
|
||||
function normalizeTimestamp(value?: string): string | null {
|
||||
if (!value) return null;
|
||||
@@ -35,8 +34,16 @@ function normalizeProjectPathForProvider(provider: string, projectPath: string):
|
||||
}
|
||||
|
||||
export const sessionsDb = {
|
||||
/**
|
||||
* Upserts one session row discovered on disk by a provider synchronizer.
|
||||
*
|
||||
* The given id is the provider-native session id. Rows are keyed by
|
||||
* `provider_session_id` so a session that was first created by the app
|
||||
* (with an app-allocated `session_id`) is updated in place once its
|
||||
* transcript shows up on disk, instead of producing a duplicate row.
|
||||
*/
|
||||
createSession(
|
||||
sessionId: string,
|
||||
providerSessionId: string,
|
||||
provider: string,
|
||||
projectPath: string,
|
||||
customName?: string,
|
||||
@@ -53,19 +60,54 @@ export const sessionsDb = {
|
||||
// since it's a foreign key in the sessions table.
|
||||
projectsDb.createProjectPath(normalizedProjectPath);
|
||||
|
||||
const existing = db
|
||||
.prepare(
|
||||
`SELECT session_id FROM sessions
|
||||
WHERE provider_session_id = ? AND provider = ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(providerSessionId, provider) as { session_id: string } | undefined;
|
||||
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE sessions SET
|
||||
provider = ?,
|
||||
updated_at = COALESCE(?, CURRENT_TIMESTAMP),
|
||||
project_path = ?,
|
||||
jsonl_path = ?,
|
||||
isArchived = 0,
|
||||
custom_name = COALESCE(?, custom_name)
|
||||
WHERE session_id = ?`
|
||||
).run(
|
||||
provider,
|
||||
updatedAtValue,
|
||||
normalizedProjectPath,
|
||||
jsonlPath ?? null,
|
||||
customName ?? null,
|
||||
existing.session_id
|
||||
);
|
||||
|
||||
return existing.session_id;
|
||||
}
|
||||
|
||||
// Sessions created outside the app (directly via the provider CLI) are
|
||||
// keyed by the provider-native id for both columns. The ON CONFLICT path
|
||||
// covers legacy rows that predate the provider_session_id mapping.
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
|
||||
`INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
provider = excluded.provider,
|
||||
provider_session_id = excluded.provider_session_id,
|
||||
updated_at = excluded.updated_at,
|
||||
project_path = excluded.project_path,
|
||||
jsonl_path = excluded.jsonl_path,
|
||||
isArchived = 0,
|
||||
custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
|
||||
).run(
|
||||
sessionId,
|
||||
providerSessionId,
|
||||
provider,
|
||||
providerSessionId,
|
||||
customName ?? null,
|
||||
normalizedProjectPath,
|
||||
jsonlPath ?? null,
|
||||
@@ -73,9 +115,77 @@ export const sessionsDb = {
|
||||
updatedAtValue
|
||||
);
|
||||
|
||||
return providerSessionId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Inserts one app-allocated session row before any provider run happens.
|
||||
*
|
||||
* The session gateway uses this when the frontend starts a brand-new chat:
|
||||
* `session_id` is the stable app-facing id, while `provider_session_id`
|
||||
* stays NULL until the provider runtime announces its own id and
|
||||
* `assignProviderSessionId` records the mapping.
|
||||
*/
|
||||
createAppSession(sessionId: string, provider: string, projectPath: string): string {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
|
||||
|
||||
projectsDb.createProjectPath(normalizedProjectPath);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
|
||||
).run(sessionId, provider, normalizedProjectPath);
|
||||
|
||||
return sessionId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Records the provider-native session id for one app-allocated session.
|
||||
*
|
||||
* If the filesystem watcher indexed the provider transcript before this
|
||||
* mapping was recorded (a duplicate row keyed by the provider id exists),
|
||||
* the duplicate is merged into the app row: its transcript path and name
|
||||
* are adopted and the duplicate row is removed. Runs in a transaction so
|
||||
* the sidebar can never observe both rows at once.
|
||||
*/
|
||||
assignProviderSessionId(sessionId: string, providerSessionId: string): void {
|
||||
const db = getConnection();
|
||||
|
||||
const merge = db.transaction(() => {
|
||||
const duplicate = db
|
||||
.prepare(
|
||||
`SELECT ${SESSION_ROW_COLUMNS} FROM sessions
|
||||
WHERE (session_id = ? OR provider_session_id = ?)
|
||||
AND session_id <> ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(providerSessionId, providerSessionId, sessionId) as SessionRow | undefined;
|
||||
|
||||
if (duplicate) {
|
||||
db.prepare('DELETE FROM sessions WHERE session_id = ?').run(duplicate.session_id);
|
||||
db.prepare(
|
||||
`UPDATE sessions SET
|
||||
provider_session_id = ?,
|
||||
jsonl_path = COALESCE(jsonl_path, ?),
|
||||
custom_name = COALESCE(custom_name, ?),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE session_id = ?`
|
||||
).run(providerSessionId, duplicate.jsonl_path, duplicate.custom_name, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE sessions SET
|
||||
provider_session_id = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE session_id = ?`
|
||||
).run(providerSessionId, sessionId);
|
||||
});
|
||||
|
||||
merge();
|
||||
},
|
||||
|
||||
updateSessionCustomName(sessionId: string, customName: string): void {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
@@ -85,17 +195,39 @@ export const sessionsDb = {
|
||||
).run(customName, sessionId);
|
||||
},
|
||||
|
||||
getSessionById(sessionId: string): SessionMetadataLookupRow | null {
|
||||
getSessionById(sessionId: string): SessionRow | null {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE session_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(sessionId) as SessionMetadataLookupRow | undefined;
|
||||
.get(sessionId) as SessionRow | undefined;
|
||||
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolves one session row through the provider-native id.
|
||||
*
|
||||
* The filesystem watcher only knows provider ids (they come from transcript
|
||||
* file names), so it uses this lookup to translate disk artifacts back to
|
||||
* the app-facing session row before broadcasting sidebar updates.
|
||||
*/
|
||||
getSessionByProviderSessionId(providerSessionId: string): SessionRow | null {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE provider_session_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(providerSessionId) as SessionRow | undefined;
|
||||
|
||||
return row ?? null;
|
||||
},
|
||||
@@ -104,7 +236,7 @@ export const sessionsDb = {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE isArchived = 0`
|
||||
)
|
||||
@@ -119,7 +251,7 @@ export const sessionsDb = {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE isArchived = 1
|
||||
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
|
||||
@@ -132,7 +264,7 @@ export const sessionsDb = {
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE project_path = ?
|
||||
AND isArchived = 0`
|
||||
@@ -149,7 +281,7 @@ export const sessionsDb = {
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE project_path = ?`
|
||||
)
|
||||
@@ -161,7 +293,7 @@ export const sessionsDb = {
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE project_path = ?
|
||||
AND isArchived = 0
|
||||
|
||||
@@ -83,6 +83,12 @@ export const SESSIONS_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'claude',
|
||||
-- The session id used by the provider CLI/SDK on disk (JSONL file name,
|
||||
-- store.db folder, sqlite row id, ...). \`session_id\` is the stable
|
||||
-- app-facing id that the frontend uses for the whole session lifetime;
|
||||
-- \`provider_session_id\` is filled in once the provider announces its own
|
||||
-- id mid-run, or equals \`session_id\` for sessions discovered on disk.
|
||||
provider_session_id TEXT,
|
||||
custom_name TEXT,
|
||||
project_path TEXT,
|
||||
jsonl_path TEXT,
|
||||
|
||||
108
server/modules/database/tests/sessions-provider-mapping.test.ts
Normal file
108
server/modules/database/tests/sessions-provider-mapping.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { closeConnection } from '@/modules/database/connection.js';
|
||||
import { initializeDatabase } from '@/modules/database/init-db.js';
|
||||
import { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
|
||||
|
||||
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'sessions-mapping-'));
|
||||
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||
|
||||
closeConnection();
|
||||
process.env.DATABASE_PATH = databasePath;
|
||||
await initializeDatabase();
|
||||
|
||||
try {
|
||||
await runTest();
|
||||
} finally {
|
||||
closeConnection();
|
||||
if (previousDatabasePath === undefined) {
|
||||
delete process.env.DATABASE_PATH;
|
||||
} else {
|
||||
process.env.DATABASE_PATH = previousDatabasePath;
|
||||
}
|
||||
await rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('disk-discovered sessions are keyed by the provider id for both columns', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createSession('provider-abc', 'claude', '/workspace/demo', 'From Disk');
|
||||
|
||||
const row = sessionsDb.getSessionById('provider-abc');
|
||||
assert.equal(row?.session_id, 'provider-abc');
|
||||
assert.equal(row?.provider_session_id, 'provider-abc');
|
||||
|
||||
const byProviderId = sessionsDb.getSessionByProviderSessionId('provider-abc');
|
||||
assert.equal(byProviderId?.session_id, 'provider-abc');
|
||||
});
|
||||
});
|
||||
|
||||
test('app sessions get the provider id assigned without creating a duplicate row', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-id-1', 'claude', '/workspace/demo');
|
||||
sessionsDb.assignProviderSessionId('app-id-1', 'provider-xyz');
|
||||
|
||||
// A later synchronizer pass that discovers the transcript on disk must
|
||||
// update the app row in place instead of inserting a provider-keyed row.
|
||||
const returnedId = sessionsDb.createSession(
|
||||
'provider-xyz',
|
||||
'claude',
|
||||
'/workspace/demo',
|
||||
'Synced Name',
|
||||
undefined,
|
||||
undefined,
|
||||
'/fake/path/provider-xyz.jsonl',
|
||||
);
|
||||
|
||||
assert.equal(returnedId, 'app-id-1');
|
||||
assert.equal(sessionsDb.getAllSessions().length, 1);
|
||||
|
||||
const row = sessionsDb.getSessionById('app-id-1');
|
||||
assert.equal(row?.provider_session_id, 'provider-xyz');
|
||||
assert.equal(row?.jsonl_path, '/fake/path/provider-xyz.jsonl');
|
||||
});
|
||||
});
|
||||
|
||||
test('assignProviderSessionId merges a watcher-created duplicate into the app row', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-id-2', 'codex', '/workspace/demo');
|
||||
|
||||
// Simulate the race: the filesystem watcher indexed the provider
|
||||
// transcript before the runtime announced its session id to the gateway.
|
||||
sessionsDb.createSession(
|
||||
'provider-race',
|
||||
'codex',
|
||||
'/workspace/demo',
|
||||
'Watcher Name',
|
||||
undefined,
|
||||
undefined,
|
||||
'/fake/provider-race.jsonl',
|
||||
);
|
||||
assert.equal(sessionsDb.getAllSessions().length, 2);
|
||||
|
||||
sessionsDb.assignProviderSessionId('app-id-2', 'provider-race');
|
||||
|
||||
const rows = sessionsDb.getAllSessions();
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0]?.session_id, 'app-id-2');
|
||||
assert.equal(rows[0]?.provider_session_id, 'provider-race');
|
||||
// Transcript path and name from the duplicate are adopted.
|
||||
assert.equal(rows[0]?.jsonl_path, '/fake/provider-race.jsonl');
|
||||
assert.equal(rows[0]?.custom_name, 'Watcher Name');
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy provider-keyed rows stay resolvable through both lookups', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createSession('legacy-1', 'gemini', '/workspace/demo');
|
||||
|
||||
assert.equal(sessionsDb.getSessionById('legacy-1')?.provider, 'gemini');
|
||||
assert.equal(sessionsDb.getSessionByProviderSessionId('legacy-1')?.session_id, 'legacy-1');
|
||||
});
|
||||
});
|
||||
@@ -67,8 +67,17 @@ function resolveRouteErrorMessage(error: unknown): string {
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const projects = await getProjectsWithSessions();
|
||||
asyncHandler(async (req, res) => {
|
||||
const skipSynchronization =
|
||||
readQueryStringValue(req.query.skipSynchronization).trim() === '1' ||
|
||||
readQueryStringValue(req.query.skipSync).trim() === '1';
|
||||
const sessionsLimit = readOptionalNumericQueryValue(req.query.sessionsLimit) ?? undefined;
|
||||
const sessionsOffset = readOptionalNumericQueryValue(req.query.sessionsOffset) ?? undefined;
|
||||
const projects = await getProjectsWithSessions({
|
||||
skipSynchronization,
|
||||
sessionsLimit,
|
||||
sessionsOffset,
|
||||
});
|
||||
res.json(projects);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -189,10 +189,11 @@ function readProjectSessionsPageByPath(
|
||||
};
|
||||
}
|
||||
|
||||
// Broadcast progress to all connected WebSocket clients
|
||||
// Broadcast progress to all connected WebSocket clients.
|
||||
// Uses the unified `kind` envelope like every other websocket frame.
|
||||
function broadcastProgress(progress: ProgressUpdate) {
|
||||
const message = JSON.stringify({
|
||||
type: 'loading_progress',
|
||||
kind: 'loading_progress',
|
||||
...progress,
|
||||
});
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
|
||||
- Update `server/modules/providers/provider.routes.ts`.
|
||||
- Update `server/routes/agent.js` if the provider is launchable from the agent runtime.
|
||||
- Update `server/index.js` if the provider needs runtime boot or shutdown wiring.
|
||||
- Update `public/modelConstants.js` if the provider appears in README or public API docs.
|
||||
- Update the `PROVIDER_ORDER` list in `public/api-docs.html` if the provider should appear in the public API docs.
|
||||
- Update `src/components/chat/hooks/useChatProviderState.ts` and
|
||||
`src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` if
|
||||
the provider should be selectable in chat.
|
||||
|
||||
@@ -18,18 +18,28 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Default (recommended)',
|
||||
description: 'Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok',
|
||||
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
|
||||
},
|
||||
{
|
||||
value: 'sonnet',
|
||||
label: 'Sonnet',
|
||||
description: 'Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok',
|
||||
value: 'fable',
|
||||
label: 'Fable',
|
||||
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
label: "Sonnet",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
|
||||
},
|
||||
{
|
||||
value: 'sonnet[1m]',
|
||||
label: 'Sonnet (1M context)',
|
||||
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
|
||||
},
|
||||
{
|
||||
value: 'opus[1m]',
|
||||
label: 'Opus 4.8 (1M context)',
|
||||
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
|
||||
},
|
||||
{
|
||||
value: 'haiku',
|
||||
label: 'Haiku',
|
||||
|
||||
@@ -111,7 +111,10 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
||||
// App-created sessions are keyed by an app id, so disk-discovered provider
|
||||
// ids must be resolved through the provider-id mapping first.
|
||||
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
|
||||
?? sessionsDb.getSessionById(parsed.sessionId);
|
||||
const existingSessionName = existingSession?.custom_name;
|
||||
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@ import readline from 'node:readline';
|
||||
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
|
||||
const PROVIDER = 'claude';
|
||||
@@ -103,10 +103,13 @@ async function parseAgentTools(filePath: string): Promise<AnyRecord[]> {
|
||||
|
||||
async function getSessionMessages(
|
||||
sessionId: string,
|
||||
providerSessionId: string,
|
||||
limit: number | null,
|
||||
offset: number,
|
||||
): Promise<ClaudeHistoryMessagesResult> {
|
||||
try {
|
||||
// The DB row is keyed by the app-facing session id, while the JSONL rows
|
||||
// on disk carry the provider-native id — both ids are needed here.
|
||||
const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||
|
||||
if (!jsonLPath) {
|
||||
@@ -133,7 +136,7 @@ async function getSessionMessages(
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(line) as AnyRecord;
|
||||
if (entry.sessionId === sessionId) {
|
||||
if (entry.sessionId === providerSessionId) {
|
||||
messages.push(entry);
|
||||
}
|
||||
} catch {
|
||||
@@ -553,12 +556,13 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { limit = null, offset = 0 } = options;
|
||||
const providerSessionId = options.providerSessionId ?? sessionId;
|
||||
|
||||
let result: ClaudeHistoryResult;
|
||||
try {
|
||||
// Load full history first so `total` reflects frontend-normalized messages,
|
||||
// not raw JSONL records.
|
||||
result = await getSessionMessages(sessionId, null, 0);
|
||||
result = await getSessionMessages(sessionId, providerSessionId, null, 0);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
|
||||
@@ -606,7 +610,6 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
}
|
||||
|
||||
const totalNormalized = normalized.length;
|
||||
let total = 0;
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind !== 'tool_result') {
|
||||
@@ -615,18 +618,10 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
const normalizedOffset = Math.max(0, offset);
|
||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||
const messages = normalizedLimit === null
|
||||
? normalized
|
||||
: normalized.slice(
|
||||
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
|
||||
Math.max(0, totalNormalized - normalizedOffset),
|
||||
);
|
||||
const hasMore = normalizedLimit === null
|
||||
? false
|
||||
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
|
||||
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
|
||||
|
||||
return {
|
||||
messages,
|
||||
messages: page,
|
||||
total,
|
||||
hasMore,
|
||||
offset: normalizedOffset,
|
||||
|
||||
@@ -43,11 +43,12 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
||||
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
|
||||
?? sessionsDb.getSessionById(parsed.sessionId);
|
||||
if (existingSession) {
|
||||
// If session name is untitled and we now have a name, update it
|
||||
if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') {
|
||||
sessionsDb.updateSessionCustomName(parsed.sessionId, parsed.sessionName);
|
||||
sessionsDb.updateSessionCustomName(existingSession.session_id, parsed.sessionName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +121,10 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
||||
// App-created sessions are keyed by an app id, so disk-discovered provider
|
||||
// ids must be resolved through the provider-id mapping first.
|
||||
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
|
||||
?? sessionsDb.getSessionById(parsed.sessionId);
|
||||
const existingSessionName = existingSession?.custom_name;
|
||||
if (existingSessionName && existingSessionName !== 'Untitled Codex Session') {
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import readline from 'node:readline';
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'codex';
|
||||
|
||||
@@ -552,7 +552,6 @@ export class CodexSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
}
|
||||
|
||||
const totalNormalized = normalized.length;
|
||||
let total = 0;
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind !== 'tool_result') {
|
||||
@@ -561,18 +560,10 @@ export class CodexSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
const normalizedOffset = Math.max(0, offset);
|
||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||
const messages = normalizedLimit === null
|
||||
? normalized
|
||||
: normalized.slice(
|
||||
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
|
||||
Math.max(0, totalNormalized - normalizedOffset),
|
||||
);
|
||||
const hasMore = normalizedLimit === null
|
||||
? false
|
||||
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
|
||||
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
|
||||
|
||||
return {
|
||||
messages,
|
||||
messages: page,
|
||||
total,
|
||||
hasMore,
|
||||
offset: normalizedOffset,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
generateMessageId,
|
||||
readObjectRecord,
|
||||
sanitizeLeafDirectoryName,
|
||||
sliceTailPage,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'cursor';
|
||||
@@ -363,42 +364,32 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
|
||||
/**
|
||||
* Fetches and paginates Cursor session history from its project-scoped store.db.
|
||||
*
|
||||
* Pagination follows the shared tail contract (`sliceTailPage`): offset 0 is
|
||||
* the most recent page, matching every other provider.
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { projectPath = '', limit = null, offset = 0 } = options;
|
||||
// The store.db folder on disk is named after the provider-native id, not
|
||||
// the app-facing session id this method is addressed with.
|
||||
const providerSessionId = options.providerSessionId ?? sessionId;
|
||||
|
||||
try {
|
||||
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
|
||||
const blobs = await this.loadCursorBlobs(providerSessionId, projectPath);
|
||||
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
|
||||
const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result');
|
||||
const total = renderableMessages.length;
|
||||
|
||||
if (limit !== null) {
|
||||
const start = offset;
|
||||
const page = limit === 0
|
||||
? []
|
||||
: renderableMessages.slice(start, start + limit);
|
||||
const hasMore = limit === 0
|
||||
? start < total
|
||||
: start + limit < total;
|
||||
return {
|
||||
messages: page,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
const { page, hasMore } = sliceTailPage(renderableMessages, limit, offset);
|
||||
|
||||
return {
|
||||
messages: renderableMessages,
|
||||
messages: page,
|
||||
total,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
limit: null,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -12,17 +12,14 @@ import {
|
||||
|
||||
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
|
||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
|
||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||
{ value: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite Preview' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
|
||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||||
{ value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
|
||||
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' },
|
||||
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
|
||||
{ value: 'gemma-4-31b-it', label: 'Gemma 4 31B IT' },
|
||||
{ value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 26B A4B IT' },
|
||||
],
|
||||
DEFAULT: 'gemini-3.1-pro-preview',
|
||||
DEFAULT: 'gemini-3-flash-preview',
|
||||
};
|
||||
|
||||
export class GeminiProviderModels implements IProviderModels {
|
||||
|
||||
@@ -5,7 +5,7 @@ import readline from 'node:readline';
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'gemini';
|
||||
|
||||
@@ -518,9 +518,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
||||
|
||||
const start = Math.max(0, offset);
|
||||
const pageLimit = limit === null ? null : Math.max(0, limit);
|
||||
const messages = pageLimit === null
|
||||
? normalized.slice(start)
|
||||
: normalized.slice(start, start + pageLimit);
|
||||
// Tail pagination via the shared contract: offset 0 returns the most
|
||||
// recent page, matching every other provider.
|
||||
const { page, hasMore } = sliceTailPage(normalized, pageLimit, start);
|
||||
let total = 0;
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind !== 'tool_result') {
|
||||
@@ -529,9 +529,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
messages: page,
|
||||
total,
|
||||
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
|
||||
hasMore,
|
||||
offset: start,
|
||||
limit: pageLimit,
|
||||
tokenUsage: result.tokenUsage,
|
||||
|
||||
@@ -112,7 +112,10 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
|
||||
}
|
||||
|
||||
const fallbackTitle = 'Untitled OpenCode Session';
|
||||
const existingSession = sessionsDb.getSessionById(sessionId);
|
||||
// App-created sessions are keyed by an app id, so disk-discovered provider
|
||||
// ids must be resolved through the provider-id mapping first.
|
||||
const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId)
|
||||
?? sessionsDb.getSessionById(sessionId);
|
||||
const existingName = existingSession?.custom_name;
|
||||
const nextName = existingName && existingName !== fallbackTitle
|
||||
? existingName
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
readObjectRecord,
|
||||
readJsonRecord,
|
||||
readOptionalString,
|
||||
sliceTailPage,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'opencode';
|
||||
@@ -325,6 +326,9 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { limit = null, offset = 0 } = options;
|
||||
// OpenCode's shared sqlite database keys messages by the provider-native
|
||||
// session id, not the app-facing id this method is addressed with.
|
||||
const providerSessionId = options.providerSessionId ?? sessionId;
|
||||
const db = openOpenCodeDatabase();
|
||||
if (!db) {
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
@@ -349,27 +353,20 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
|
||||
m.id,
|
||||
COALESCE(p.time_created, 0),
|
||||
p.id
|
||||
`).all(sessionId) as OpenCodeHistoryRow[];
|
||||
`).all(providerSessionId) as OpenCodeHistoryRow[];
|
||||
|
||||
const normalized = this.normalizeHistoryRows(rows, sessionId);
|
||||
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId);
|
||||
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, providerSessionId);
|
||||
|
||||
const normalizedOffset = Math.max(0, offset);
|
||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||
const total = normalized.length;
|
||||
const messages = normalizedLimit === null
|
||||
? normalized
|
||||
: normalized.slice(
|
||||
Math.max(0, total - normalizedOffset - normalizedLimit),
|
||||
Math.max(0, total - normalizedOffset),
|
||||
);
|
||||
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
|
||||
|
||||
return {
|
||||
messages,
|
||||
messages: page,
|
||||
total,
|
||||
hasMore: normalizedLimit === null
|
||||
? false
|
||||
: Math.max(0, total - normalizedOffset - normalizedLimit) > 0,
|
||||
hasMore,
|
||||
offset: normalizedOffset,
|
||||
limit: normalizedLimit,
|
||||
tokenUsage,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express, { type Request, type Response } from 'express';
|
||||
|
||||
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
||||
import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js';
|
||||
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
||||
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
|
||||
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
|
||||
@@ -382,7 +383,51 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/capabilities',
|
||||
asyncHandler(async (_req: Request, res: Response) => {
|
||||
res.json(createApiSuccessResponse({
|
||||
providers: providerCapabilitiesService.listAllProviderCapabilities(),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:provider/capabilities',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
res.json(createApiSuccessResponse(
|
||||
providerCapabilitiesService.getProviderCapabilities(provider),
|
||||
));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- Session routes -----------------
|
||||
/**
|
||||
* Session gateway entry point: allocates the stable app-facing session id for
|
||||
* a brand-new chat. The frontend must call this before the first `chat.send`
|
||||
* so the session id in the URL, the store, and the websocket all agree from
|
||||
* the very first message — there is no client-visible session-id handoff.
|
||||
*/
|
||||
router.post(
|
||||
'/sessions',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const provider = parseProvider(body.provider);
|
||||
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : '';
|
||||
const result = sessionsService.createAppSession(provider, projectPath);
|
||||
res.status(201).json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/sessions/running',
|
||||
asyncHandler(async (_req: Request, res: Response) => {
|
||||
const sessions = sessionsService.listRunningSessions();
|
||||
res.json(createApiSuccessResponse({ sessions }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/sessions/archived',
|
||||
asyncHandler(async (_req: Request, res: Response) => {
|
||||
@@ -459,7 +504,7 @@ router.get(
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
res.json(result);
|
||||
res.json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
|
||||
/**
|
||||
* Static, backend-owned description of what one provider integration supports.
|
||||
*
|
||||
* The frontend renders its composer UI (permission mode picker, image upload,
|
||||
* abort button, ...) purely from this shape, which is what keeps the frontend
|
||||
* free of per-provider conditionals. New provider features should be exposed
|
||||
* here instead of branching on the provider id in React components.
|
||||
*/
|
||||
type ProviderCapabilities = {
|
||||
provider: LLMProvider;
|
||||
/** Permission modes the provider runtime understands, in cycle order. */
|
||||
permissionModes: string[];
|
||||
defaultPermissionMode: string;
|
||||
/** Whether image attachments can be included in a chat.send. */
|
||||
supportsImages: boolean;
|
||||
/** Whether an in-flight run can be cancelled via chat.abort. */
|
||||
supportsAbort: boolean;
|
||||
/** Whether interactive tool permission prompts can reach the UI. */
|
||||
supportsPermissionRequests: boolean;
|
||||
/** Whether the token-usage endpoint has data for this provider. */
|
||||
supportsTokenUsage: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The capability matrix mirrors what each runtime actually implements today:
|
||||
* - permission modes match the option sets accepted by each CLI/SDK.
|
||||
* - only the Claude SDK integration surfaces interactive permission requests.
|
||||
* - Cursor has no token usage endpoint support (its store.db has no usage rows).
|
||||
*/
|
||||
const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
claude: {
|
||||
provider: 'claude',
|
||||
permissionModes: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: true,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: true,
|
||||
supportsTokenUsage: true,
|
||||
},
|
||||
cursor: {
|
||||
provider: 'cursor',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: false,
|
||||
},
|
||||
codex: {
|
||||
provider: 'codex',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
},
|
||||
gemini: {
|
||||
provider: 'gemini',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
},
|
||||
opencode: {
|
||||
provider: 'opencode',
|
||||
permissionModes: ['default'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Application service exposing the provider capability matrix.
|
||||
*/
|
||||
export const providerCapabilitiesService = {
|
||||
getProviderCapabilities(provider: LLMProvider): ProviderCapabilities {
|
||||
return PROVIDER_CAPABILITIES[provider];
|
||||
},
|
||||
|
||||
listAllProviderCapabilities(): ProviderCapabilities[] {
|
||||
return Object.values(PROVIDER_CAPABILITIES);
|
||||
},
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
|
||||
|
||||
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
const PROVIDER_MODELS_CACHE_VERSION = 1;
|
||||
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']);
|
||||
|
||||
type ProviderModelsServiceDependencies = {
|
||||
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
||||
@@ -232,10 +233,42 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
|
||||
return request;
|
||||
};
|
||||
|
||||
const loadDirectModels = (
|
||||
provider: LLMProvider,
|
||||
): Promise<ProviderModelsResult> => {
|
||||
const request = resolveProvider(provider).models.getSupportedModels()
|
||||
.then((models) => {
|
||||
const currentTime = now();
|
||||
return {
|
||||
models,
|
||||
cache: {
|
||||
updatedAt: new Date(currentTime).toISOString(),
|
||||
expiresAt: new Date(currentTime).toISOString(),
|
||||
source: 'fresh' as const,
|
||||
},
|
||||
};
|
||||
})
|
||||
.finally(() => {
|
||||
pendingRequests.delete(provider);
|
||||
});
|
||||
|
||||
pendingRequests.set(provider, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
const getProviderModels = async (
|
||||
provider: LLMProvider,
|
||||
options: ProviderModelsOptions = {},
|
||||
): Promise<ProviderModelsResult> => {
|
||||
if (UNCACHED_PROVIDERS.has(provider)) {
|
||||
const pendingRequest = pendingRequests.get(provider);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
return loadDirectModels(provider);
|
||||
}
|
||||
|
||||
if (options.bypassCache) {
|
||||
const pendingRequest = pendingRequests.get(provider);
|
||||
if (pendingRequest) {
|
||||
|
||||
@@ -4,10 +4,11 @@ import { promises as fsPromises } from 'node:fs';
|
||||
|
||||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
|
||||
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||
import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js';
|
||||
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
import { getProjectsWithSessions } from '@/modules/projects/index.js';
|
||||
import { generateDisplayName } from '@/modules/projects/index.js';
|
||||
|
||||
type WatcherEventType = 'add' | 'change';
|
||||
|
||||
@@ -58,6 +59,11 @@ const watchers: FSWatcher[] = [];
|
||||
type PendingWatcherUpdate = {
|
||||
providers: Set<LLMProvider>;
|
||||
changeTypes: Set<WatcherEventType>;
|
||||
/**
|
||||
* Provider-native session ids reported by the synchronizers. They are
|
||||
* translated back to app-facing session rows at flush time, because the
|
||||
* transcript file names on disk only ever contain provider ids.
|
||||
*/
|
||||
updatedSessionIds: Set<string>;
|
||||
};
|
||||
|
||||
@@ -131,6 +137,50 @@ function queuePendingWatcherUpdate(
|
||||
schedulePendingWatcherFlush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one `session_upserted` delta event for a provider-native session id.
|
||||
*
|
||||
* The event carries everything a sidebar needs to upsert the session in place
|
||||
* (session summary plus owning-project metadata), so clients never need a full
|
||||
* project-list refetch when a transcript file changes on disk. Returns `null`
|
||||
* when the id cannot be resolved to an indexed session row.
|
||||
*/
|
||||
async function buildSessionUpsertedEvent(updatedProviderSessionId: string): Promise<string | null> {
|
||||
const row = sessionsDb.getSessionByProviderSessionId(updatedProviderSessionId)
|
||||
?? sessionsDb.getSessionById(updatedProviderSessionId);
|
||||
if (!row || row.isArchived) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectPath = row.project_path;
|
||||
const project = projectPath ? projectsDb.getProjectPath(projectPath) : null;
|
||||
const displayName = project?.custom_project_name?.trim()
|
||||
? project.custom_project_name
|
||||
: await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath);
|
||||
|
||||
return JSON.stringify({
|
||||
kind: 'session_upserted',
|
||||
sessionId: row.session_id,
|
||||
provider: row.provider,
|
||||
session: {
|
||||
id: row.session_id,
|
||||
summary: row.custom_name || '',
|
||||
messageCount: 0,
|
||||
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
||||
},
|
||||
project: project
|
||||
? {
|
||||
projectId: project.project_id,
|
||||
path: project.project_path,
|
||||
fullPath: project.project_path,
|
||||
displayName,
|
||||
isStarred: Boolean(project.isStarred),
|
||||
}
|
||||
: null,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async function flushPendingWatcherUpdate(): Promise<void> {
|
||||
clearPendingWatcherFlushTimer();
|
||||
|
||||
@@ -149,33 +199,29 @@ async function flushPendingWatcherUpdate(): Promise<void> {
|
||||
watcherRefreshInFlight = true;
|
||||
|
||||
try {
|
||||
const updatedProjects = await getProjectsWithSessions({ skipSynchronization: true });
|
||||
const changeTypes = Array.from(queuedUpdate.changeTypes);
|
||||
const watchProviders = Array.from(queuedUpdate.providers);
|
||||
const updatedSessionIds = Array.from(queuedUpdate.updatedSessionIds);
|
||||
|
||||
// Backward-compatible fields stay populated with the first queued values.
|
||||
const updateMessage = JSON.stringify({
|
||||
type: 'projects_updated',
|
||||
projects: updatedProjects,
|
||||
timestamp: new Date().toISOString(),
|
||||
changeType: changeTypes[0] ?? 'change',
|
||||
updatedSessionId: updatedSessionIds[0] ?? undefined,
|
||||
watchProvider: watchProviders[0] ?? undefined,
|
||||
changeTypes,
|
||||
updatedSessionIds,
|
||||
watchProviders,
|
||||
batched: true,
|
||||
});
|
||||
|
||||
connectedClients.forEach(client => {
|
||||
if (client.readyState === WS_OPEN_STATE) {
|
||||
client.send(updateMessage);
|
||||
// Per-session deltas instead of full project snapshots: an upsert of one
|
||||
// session can never clobber unrelated client state, so the frontend needs
|
||||
// no "suppress updates while a run is active" protection logic.
|
||||
const events: string[] = [];
|
||||
for (const updatedSessionId of queuedUpdate.updatedSessionIds) {
|
||||
const event = await buildSessionUpsertedEvent(updatedSessionId);
|
||||
if (event) {
|
||||
events.push(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (events.length > 0) {
|
||||
connectedClients.forEach(client => {
|
||||
if (client.readyState === WS_OPEN_STATE) {
|
||||
for (const event of events) {
|
||||
client.send(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('Session watcher refresh failed while broadcasting projects_updated', { error: message });
|
||||
console.error('Session watcher refresh failed while broadcasting session_upserted', { error: message });
|
||||
} finally {
|
||||
watcherRefreshInFlight = false;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||
import { chatRunRegistry } from '@/modules/websocket/index.js';
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type {
|
||||
FetchHistoryOptions,
|
||||
@@ -11,6 +13,12 @@ import type {
|
||||
} from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
type CreateAppSessionResult = {
|
||||
sessionId: string;
|
||||
provider: LLMProvider;
|
||||
projectPath: string;
|
||||
};
|
||||
|
||||
type ArchivedSessionListItem = {
|
||||
sessionId: string;
|
||||
provider: LLMProvider;
|
||||
@@ -77,6 +85,21 @@ export const sessionsService = {
|
||||
return providerRegistry.listProviders().map((provider) => provider.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns app-facing ids for provider runs that are currently processing.
|
||||
*
|
||||
* This is intentionally status-only: callers that only need sidebar activity
|
||||
* indicators should not attach to chat streams or request replayed messages.
|
||||
*/
|
||||
listRunningSessions(): Array<{
|
||||
sessionId: string;
|
||||
provider: LLMProvider;
|
||||
startedAt: number;
|
||||
lastSeq: number;
|
||||
}> {
|
||||
return chatRunRegistry.listRunningRuns();
|
||||
},
|
||||
|
||||
/**
|
||||
* Normalizes one provider-native event into frontend session message events.
|
||||
*/
|
||||
@@ -89,12 +112,43 @@ export const sessionsService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches persisted history by session id.
|
||||
* Allocates a stable app-facing session id before any provider run happens.
|
||||
*
|
||||
* This is the entry point of the session gateway: the frontend calls this
|
||||
* (via `POST /api/providers/sessions`) when the user starts a brand-new
|
||||
* chat, navigates to the returned id immediately, and the id never changes
|
||||
* for the lifetime of the conversation. The provider-native id is mapped to
|
||||
* this row later, when the provider runtime announces it mid-run.
|
||||
*/
|
||||
createAppSession(provider: LLMProvider, projectPath: string): CreateAppSessionResult {
|
||||
const normalizedProjectPath = projectPath.trim();
|
||||
if (!normalizedProjectPath) {
|
||||
throw new AppError('projectPath is required.', {
|
||||
code: 'PROJECT_PATH_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
sessionsDb.createAppSession(sessionId, provider, normalizedProjectPath);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
provider,
|
||||
projectPath: normalizedProjectPath,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches persisted history by app session id.
|
||||
*
|
||||
* Provider and provider-specific lookup hints are resolved from the indexed
|
||||
* session metadata in the database.
|
||||
* session metadata in the database. The provider adapter receives the
|
||||
* provider-native session id (the one written into transcripts on disk),
|
||||
* and every returned message is remapped back to the app session id so
|
||||
* provider ids never reach the frontend.
|
||||
*/
|
||||
fetchHistory(
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
options: Pick<FetchHistoryOptions, 'limit' | 'offset'> = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
@@ -106,12 +160,33 @@ export const sessionsService = {
|
||||
});
|
||||
}
|
||||
|
||||
// App-created sessions that never produced a provider transcript yet
|
||||
// (e.g. first message still streaming) simply have no history.
|
||||
if (!session.provider_session_id) {
|
||||
return {
|
||||
messages: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
offset: options.offset ?? 0,
|
||||
limit: options.limit ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = session.provider as LLMProvider;
|
||||
return providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
|
||||
const result = await providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
|
||||
limit: options.limit ?? null,
|
||||
offset: options.offset ?? 0,
|
||||
projectPath: session.project_path ?? '',
|
||||
providerSessionId: session.provider_session_id,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
messages: result.messages.map((message) => ({
|
||||
...message,
|
||||
sessionId,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -130,6 +130,37 @@ test('provider models are cached for the three-day ttl', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('claude provider models are always loaded directly from the provider', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-claude-direct-'));
|
||||
let loadCount = 0;
|
||||
|
||||
try {
|
||||
const service = createProviderModelsService({
|
||||
cachePath: path.join(tempRoot, 'models-cache.json'),
|
||||
resolveProvider: (provider) => ({
|
||||
models: {
|
||||
getSupportedModels: async () => {
|
||||
loadCount += 1;
|
||||
return createModels(`${provider}-${loadCount}`);
|
||||
},
|
||||
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
||||
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const first = await service.getProviderModels('claude');
|
||||
const second = await service.getProviderModels('claude');
|
||||
|
||||
assert.equal(loadCount, 2);
|
||||
assert.equal(first.models.DEFAULT, 'claude-1');
|
||||
assert.equal(second.models.DEFAULT, 'claude-2');
|
||||
assert.equal(second.cache.source, 'fresh');
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('provider model cache is persisted across service instances', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
|
||||
const cachePath = path.join(tempRoot, 'models-cache.json');
|
||||
|
||||
@@ -33,10 +33,12 @@ Benefits:
|
||||
|---|---|
|
||||
| `services/websocket-server.service.ts` | Creates `WebSocketServer`, binds `verifyClient`, routes connection by pathname |
|
||||
| `services/websocket-auth.service.ts` | Authenticates upgrade requests and attaches `request.user` |
|
||||
| `services/chat-websocket.service.ts` | Handles `/ws` chat protocol and provider command/session control messages |
|
||||
| `services/chat-websocket.service.ts` | Handles the `/ws` chat protocol (`chat.send` / `chat.abort` / `chat.subscribe` / `chat.permission-response`) |
|
||||
| `services/chat-run-registry.service.ts` | Tracks live provider runs per app session id: seq numbering, event replay buffer, provider-id mapping, completion state |
|
||||
| `services/chat-session-writer.service.ts` | Gateway writer handed to provider runtimes: remaps provider session ids to app ids, swallows `session_created`, assigns `seq` |
|
||||
| `services/shell-websocket.service.ts` | Handles `/shell` PTY lifecycle, reconnect buffering, auth URL detection |
|
||||
| `services/plugin-websocket-proxy.service.ts` | Bridges client socket to plugin socket |
|
||||
| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) |
|
||||
| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) for non-chat writer consumers |
|
||||
| `services/websocket-state.service.ts` | Holds shared chat client set and open-state constant |
|
||||
|
||||
## High-Level Architecture
|
||||
@@ -52,12 +54,12 @@ flowchart LR
|
||||
D -->|other| H[close()]
|
||||
|
||||
E --> I[connectedClients Set]
|
||||
E --> J[WebSocketWriter]
|
||||
E --> J[chatRunRegistry + ChatSessionWriter]
|
||||
F --> K[ptySessionsMap]
|
||||
G --> L[Upstream Plugin ws://127.0.0.1:port/ws]
|
||||
|
||||
I --> M[projects.service broadcastProgress]
|
||||
I --> N[sessions-watcher.service projects_updated]
|
||||
I --> M[projects.service loading_progress]
|
||||
I --> N[sessions-watcher.service session_upserted]
|
||||
```
|
||||
|
||||
## Connection Handshake + Routing
|
||||
@@ -105,37 +107,41 @@ sequenceDiagram
|
||||
When a chat socket connects:
|
||||
|
||||
1. Add socket to `connectedClients`.
|
||||
2. Build `WebSocketWriter` (captures `userId` from authenticated request).
|
||||
3. Parse each incoming message with `parseIncomingJsonObject`.
|
||||
4. Dispatch by `data.type`.
|
||||
5. On close, remove socket from `connectedClients`.
|
||||
2. Parse each incoming message with `parseIncomingJsonObject`.
|
||||
3. Dispatch by `data.type` (four message types, none provider-specific).
|
||||
4. On close, remove socket from `connectedClients`.
|
||||
|
||||
### Session identity model
|
||||
|
||||
The frontend only ever knows the **app session id** (allocated by
|
||||
`POST /api/providers/sessions` or discovered via the session index). The
|
||||
provider-native id (JSONL file name, CLI resume id) stays inside the backend:
|
||||
|
||||
1. `chat.send` resolves the app id to `{ provider, provider_session_id, project_path }` from the sessions DB.
|
||||
2. The provider runtime receives the provider-native id for resume.
|
||||
3. The `ChatSessionWriter` remaps every outbound event back to the app id, and turns `session_created` announcements into a DB mapping update instead of forwarding them.
|
||||
|
||||
### Chat Message Dispatch
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Incoming WS message] --> B[parseIncomingJsonObject]
|
||||
B -->|invalid| C[send {type:error}]
|
||||
B -->|invalid| C[send kind:protocol_error]
|
||||
B -->|ok| D{data.type}
|
||||
|
||||
D -->|claude-command| E[queryClaudeSDK]
|
||||
D -->|cursor-command| F[spawnCursor]
|
||||
D -->|codex-command| G[queryCodex]
|
||||
D -->|gemini-command| H[spawnGemini]
|
||||
D -->|cursor-resume| I[spawnCursor resume]
|
||||
D -->|abort-session| J[abort by provider]
|
||||
D -->|claude-permission-response| K[resolveToolApproval]
|
||||
D -->|cursor-abort| L[abortCursorSession]
|
||||
D -->|check-session-status| M[is*SessionActive + optional reconnectSessionWriter]
|
||||
D -->|get-pending-permissions| N[getPendingApprovalsForSession]
|
||||
D -->|get-active-sessions| O[getActive*Sessions]
|
||||
D -->|chat.send| E[resolve session row -> startRun -> spawnFns provider]
|
||||
D -->|chat.abort| F[abortFns provider + synthetic complete]
|
||||
D -->|chat.subscribe| G[chat_subscribed ack + attach socket + replay events seq > lastSeq]
|
||||
D -->|chat.permission-response| H[resolveToolApproval]
|
||||
D -->|other| I[send kind:protocol_error]
|
||||
```
|
||||
|
||||
### Chat Notes
|
||||
|
||||
1. `abort-session` returns a normalized `complete` message with `aborted: true`.
|
||||
2. `check-session-status` returns `{ type: "session-status", isProcessing }`.
|
||||
3. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
|
||||
1. **Unified envelope**: every server-to-client frame carries a `kind` — either a provider `NormalizedMessage` kind or a gateway kind (`chat_subscribed`, `session_upserted`, `loading_progress`, `protocol_error`). There is no second `type`-based protocol.
|
||||
2. **Unified terminal lifecycle**: every provider run ends with exactly one `complete` message built by `createCompleteMessage()` (`server/shared/utils.ts`): `{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }`. The chat handler emits a synthetic `complete` for runs that crash or get aborted, and the run registry drops duplicate completes.
|
||||
3. **Per-run event log**: every live event gets a monotonically increasing `seq`. `chat.subscribe { sessions: [{ sessionId, lastSeq }] }` re-attaches the live stream to the requesting socket (any provider, not just Claude) and replays events with `seq > lastSeq`. If the buffer no longer covers `lastSeq`, the client refreshes over REST.
|
||||
4. `chat_subscribed` includes `isProcessing` (replaces `check-session-status`) and `pendingPermissions` (replaces `get-pending-permissions`).
|
||||
|
||||
## `/shell` Terminal Flow
|
||||
|
||||
@@ -223,9 +229,9 @@ Only chat sockets (`/ws`) are tracked in `connectedClients`.
|
||||
That shared set is consumed by:
|
||||
|
||||
1. `modules/projects/services/projects-with-sessions-fetch.service.ts`
|
||||
Broadcasts `loading_progress` while project snapshots are being built.
|
||||
Broadcasts `kind: loading_progress` while project snapshots are being built.
|
||||
2. `modules/providers/services/sessions-watcher.service.ts`
|
||||
Broadcasts `projects_updated` when provider session artifacts change.
|
||||
Broadcasts per-session `kind: session_upserted` deltas when provider session artifacts change (no full project snapshots).
|
||||
|
||||
This design centralizes cross-module realtime fanout without requiring route-local references to WebSocket internals.
|
||||
|
||||
@@ -252,7 +258,7 @@ Current explicit close codes in this module:
|
||||
|
||||
Other errors:
|
||||
|
||||
1. Chat handler catches and emits `{ type: "error", error }`.
|
||||
1. Chat handler catches and emits `{ kind: "protocol_error", code, error }`.
|
||||
2. Shell handler catches and writes terminal-visible error output.
|
||||
3. Unknown websocket paths are closed immediately.
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { WS_OPEN_STATE, connectedClients } from './services/websocket-state.service.js';
|
||||
export { createWebSocketServer } from './services/websocket-server.service.js';
|
||||
export { chatRunRegistry } from './services/chat-run-registry.service.js';
|
||||
|
||||
273
server/modules/websocket/services/chat-run-registry.service.ts
Normal file
273
server/modules/websocket/services/chat-run-registry.service.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import { ChatSessionWriter } from '@/modules/websocket/services/chat-session-writer.service.js';
|
||||
import type {
|
||||
LLMProvider,
|
||||
NormalizedMessage,
|
||||
RealtimeClientConnection,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
type ChatRunStatus = 'running' | 'completed';
|
||||
|
||||
/**
|
||||
* One live (or recently finished) provider run for a single app session.
|
||||
*
|
||||
* State notes — why each mutable field is essential:
|
||||
* - `providerSessionId`: the provider-native id captured mid-run. The abort
|
||||
* handler needs it to address the provider runtime, and the DB mapping is
|
||||
* written from it so history/resume work after the run.
|
||||
* - `status`: drives `chat_subscribed.isProcessing`, prevents double sends
|
||||
* into the same session, and guards the synthetic-complete fallback in the
|
||||
* chat handler (only emitted when a runtime died without completing).
|
||||
* - `lastSeq` / `events`: the per-run event log. Every live event gets a
|
||||
* monotonically increasing `seq` and is buffered so a reconnecting client
|
||||
* can replay exactly the events it missed via `chat.subscribe`.
|
||||
*/
|
||||
type ChatRun = {
|
||||
appSessionId: string;
|
||||
provider: LLMProvider;
|
||||
providerSessionId: string | null;
|
||||
status: ChatRunStatus;
|
||||
lastSeq: number;
|
||||
events: NormalizedMessage[];
|
||||
writer: ChatSessionWriter;
|
||||
startedAt: number;
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* How long a completed run stays available for replay. Covers the window
|
||||
* between a run finishing and the client refreshing history over REST (for
|
||||
* example when the browser tab was asleep while the run completed).
|
||||
*/
|
||||
const COMPLETED_RUN_RETENTION_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Upper bound on buffered events per run so a very long tool-heavy run cannot
|
||||
* grow memory unbounded. When exceeded, the oldest events are dropped —
|
||||
* a reconnecting client whose `lastSeq` predates the buffer falls back to a
|
||||
* REST history refresh, which is always the authoritative source.
|
||||
*/
|
||||
const MAX_BUFFERED_EVENTS_PER_RUN = 5000;
|
||||
|
||||
/**
|
||||
* Active and recently-completed runs keyed by app session id.
|
||||
*
|
||||
* This map is the single in-memory source of truth for "is something running
|
||||
* for this session" — the chat websocket handler, abort path, and subscribe
|
||||
* path all consult it instead of asking each provider runtime individually.
|
||||
*/
|
||||
const runs = new Map<string, ChatRun>();
|
||||
|
||||
function evictRunLater(appSessionId: string): void {
|
||||
const timer = setTimeout(() => {
|
||||
const run = runs.get(appSessionId);
|
||||
if (run && run.status === 'completed') {
|
||||
runs.delete(appSessionId);
|
||||
}
|
||||
}, COMPLETED_RUN_RETENTION_MS);
|
||||
|
||||
// Never keep the process alive just to evict a buffered run.
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorates one outbound live event for a run and records it in the event log.
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Remap `sessionId` (and `actualSessionId` on `complete`) to the stable
|
||||
* app session id — provider-native ids never leave the backend.
|
||||
* 2. Assign the next `seq` so clients can detect/replay gaps.
|
||||
* 3. Buffer the event for `chat.subscribe` replay.
|
||||
* 4. Flip the run to `completed` when the terminal `complete` event passes by.
|
||||
*/
|
||||
function decorateAndRecordEvent(run: ChatRun, message: NormalizedMessage): NormalizedMessage | null {
|
||||
// Exactly-one-complete contract: when a run is aborted the chat handler
|
||||
// emits the terminal `complete` immediately, but the killed runtime may
|
||||
// still emit its own `complete` from its exit handler moments later.
|
||||
// Whichever arrives first wins; the duplicate is dropped here.
|
||||
if (message.kind === 'complete' && run.status === 'completed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
run.lastSeq += 1;
|
||||
|
||||
const outbound: NormalizedMessage = {
|
||||
...message,
|
||||
sessionId: run.appSessionId,
|
||||
seq: run.lastSeq,
|
||||
};
|
||||
|
||||
if (message.kind === 'complete') {
|
||||
// The provider may report its own id here; the frontend only ever knows
|
||||
// the app id, so the "actual" id is by definition the app id as well.
|
||||
outbound.actualSessionId = run.appSessionId;
|
||||
run.status = 'completed';
|
||||
run.completedAt = Date.now();
|
||||
evictRunLater(run.appSessionId);
|
||||
}
|
||||
|
||||
run.events.push(outbound);
|
||||
if (run.events.length > MAX_BUFFERED_EVENTS_PER_RUN) {
|
||||
run.events.splice(0, run.events.length - MAX_BUFFERED_EVENTS_PER_RUN);
|
||||
}
|
||||
|
||||
return outbound;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the provider-native session id for a run and persists the
|
||||
* app-id-to-provider-id mapping so history fetches and future resumes can
|
||||
* address the provider transcript.
|
||||
*
|
||||
* Called from the gateway writer when the runtime either calls
|
||||
* `setSessionId(...)` or emits its `session_created` event — whichever
|
||||
* happens first wins; later calls with the same id are no-ops.
|
||||
*/
|
||||
function recordProviderSessionId(run: ChatRun, providerSessionId: string): void {
|
||||
if (!providerSessionId || run.providerSessionId === providerSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
run.providerSessionId = providerSessionId;
|
||||
|
||||
try {
|
||||
sessionsDb.assignProviderSessionId(run.appSessionId, providerSessionId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[ChatRunRegistry] Failed to persist provider session id mapping', {
|
||||
appSessionId: run.appSessionId,
|
||||
providerSessionId,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of live provider runs keyed by the stable app session id.
|
||||
*
|
||||
* The registry is what makes the websocket protocol provider-independent:
|
||||
* every run gets a `ChatSessionWriter` that remaps provider-native session
|
||||
* ids to the app id, assigns `seq` numbers, and buffers events for replay —
|
||||
* regardless of which provider runtime produced them.
|
||||
*/
|
||||
export const chatRunRegistry = {
|
||||
/**
|
||||
* Starts tracking a run and returns it, or `null` when a run is already in
|
||||
* progress for the session (callers must reject the duplicate send).
|
||||
*/
|
||||
startRun(input: {
|
||||
appSessionId: string;
|
||||
provider: LLMProvider;
|
||||
providerSessionId: string | null;
|
||||
connection: RealtimeClientConnection;
|
||||
userId: string | number | null;
|
||||
}): ChatRun | null {
|
||||
const existing = runs.get(input.appSessionId);
|
||||
if (existing && existing.status === 'running') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const run: ChatRun = {
|
||||
appSessionId: input.appSessionId,
|
||||
provider: input.provider,
|
||||
providerSessionId: input.providerSessionId,
|
||||
status: 'running',
|
||||
lastSeq: 0,
|
||||
events: [],
|
||||
writer: null as unknown as ChatSessionWriter,
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
};
|
||||
|
||||
run.writer = new ChatSessionWriter({
|
||||
connection: input.connection,
|
||||
userId: input.userId,
|
||||
provider: input.provider,
|
||||
providerSessionId: input.providerSessionId,
|
||||
onProviderSessionId: (providerSessionId) => {
|
||||
recordProviderSessionId(run, providerSessionId);
|
||||
},
|
||||
decorateOutboundEvent: (message) => decorateAndRecordEvent(run, message),
|
||||
});
|
||||
|
||||
runs.set(input.appSessionId, run);
|
||||
return run;
|
||||
},
|
||||
|
||||
getRun(appSessionId: string): ChatRun | undefined {
|
||||
return runs.get(appSessionId);
|
||||
},
|
||||
|
||||
isProcessing(appSessionId: string): boolean {
|
||||
return runs.get(appSessionId)?.status === 'running';
|
||||
},
|
||||
|
||||
listRunningRuns(): Array<{
|
||||
sessionId: string;
|
||||
provider: LLMProvider;
|
||||
startedAt: number;
|
||||
lastSeq: number;
|
||||
}> {
|
||||
return Array.from(runs.values())
|
||||
.filter((run) => run.status === 'running')
|
||||
.map((run) => ({
|
||||
sessionId: run.appSessionId,
|
||||
provider: run.provider,
|
||||
startedAt: run.startedAt,
|
||||
lastSeq: run.lastSeq,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-attaches a run's outbound stream to a (new) websocket connection.
|
||||
*
|
||||
* This is the generic replacement for the Claude-only writer reconnect:
|
||||
* after a page refresh the new socket subscribes and immediately starts
|
||||
* receiving the still-running stream, for every provider.
|
||||
*/
|
||||
attachConnection(appSessionId: string, connection: RealtimeClientConnection): boolean {
|
||||
const run = runs.get(appSessionId);
|
||||
if (!run) {
|
||||
return false;
|
||||
}
|
||||
|
||||
run.writer.updateWebSocket(connection);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns buffered events with `seq` greater than `afterSeq` for replay.
|
||||
*
|
||||
* An empty array with `run.lastSeq > afterSeq` not covered by the buffer
|
||||
* means the buffer was truncated; the client should refresh over REST.
|
||||
*/
|
||||
replayEvents(appSessionId: string, afterSeq: number): NormalizedMessage[] {
|
||||
const run = runs.get(appSessionId);
|
||||
if (!run) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return run.events.filter((event) => typeof event.seq === 'number' && event.seq > afterSeq);
|
||||
},
|
||||
|
||||
/**
|
||||
* Emits a synthetic terminal `complete` if (and only if) the run is still
|
||||
* marked running. Used when a provider runtime throws or resolves without
|
||||
* having produced its own terminal event, and by the abort path.
|
||||
*/
|
||||
completeRun(appSessionId: string, opts: { exitCode: number; aborted?: boolean }): void {
|
||||
const run = runs.get(appSessionId);
|
||||
if (!run || run.status !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
run.writer.sendComplete(opts);
|
||||
},
|
||||
|
||||
/**
|
||||
* Test-only escape hatch: clears every tracked run.
|
||||
*/
|
||||
clearAll(): void {
|
||||
runs.clear();
|
||||
},
|
||||
};
|
||||
145
server/modules/websocket/services/chat-session-writer.service.ts
Normal file
145
server/modules/websocket/services/chat-session-writer.service.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
|
||||
import type {
|
||||
LLMProvider,
|
||||
NormalizedMessage,
|
||||
RealtimeClientConnection,
|
||||
} from '@/shared/types.js';
|
||||
import { createCompleteMessage, readObjectRecord } from '@/shared/utils.js';
|
||||
|
||||
type ChatSessionWriterOptions = {
|
||||
connection: RealtimeClientConnection;
|
||||
userId: string | number | null;
|
||||
provider: LLMProvider;
|
||||
/** Provider-native id when resuming an existing session, otherwise null. */
|
||||
providerSessionId: string | null;
|
||||
/**
|
||||
* Invoked the moment the provider runtime reveals its native session id
|
||||
* (either via `setSessionId` or a `session_created` event). The registry
|
||||
* persists the app-id-to-provider-id mapping from this callback.
|
||||
*/
|
||||
onProviderSessionId: (providerSessionId: string) => void;
|
||||
/**
|
||||
* Remaps/sequences/buffers one outbound live event. Implemented by the chat
|
||||
* run registry; the writer never forwards a provider event untouched.
|
||||
* Returns `null` when the event must be dropped (duplicate terminal
|
||||
* `complete` after an abort already completed the run).
|
||||
*/
|
||||
decorateOutboundEvent: (message: NormalizedMessage) => NormalizedMessage | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gateway writer handed to provider runtimes instead of a raw websocket writer.
|
||||
*
|
||||
* It exposes the exact same surface as `WebSocketWriter` (`send`,
|
||||
* `setSessionId`, `getSessionId`, `updateWebSocket`, `userId`,
|
||||
* `isWebSocketWriter`) so the provider runtimes (`claude-sdk.js`,
|
||||
* `cursor-cli.js`, ...) need zero changes — but everything that flows through
|
||||
* it is translated from the provider's world into the app's protocol:
|
||||
*
|
||||
* - `session_created` events are swallowed and turned into a provider-id
|
||||
* mapping; the frontend never learns provider-native ids.
|
||||
* - every other event gets `sessionId` remapped to the app session id and a
|
||||
* per-run `seq` assigned before being forwarded.
|
||||
* - `setSessionId(...)` calls (used by runtimes to label captured ids) are
|
||||
* intercepted and recorded as the provider-id mapping as well.
|
||||
*/
|
||||
export class ChatSessionWriter {
|
||||
ws: RealtimeClientConnection;
|
||||
userId: string | number | null;
|
||||
/**
|
||||
* Some runtimes feature-detect their writer with this flag; keep it so the
|
||||
* gateway writer is a drop-in replacement for `WebSocketWriter`.
|
||||
*/
|
||||
isWebSocketWriter = true;
|
||||
|
||||
private readonly options: ChatSessionWriterOptions;
|
||||
/**
|
||||
* The provider-native session id as the runtime knows it. Kept locally
|
||||
* (besides the registry) because runtimes read it back via `getSessionId()`
|
||||
* to label their own outgoing events — those labels are remapped on send
|
||||
* anyway, but the runtime-visible value must stay provider-native.
|
||||
*/
|
||||
private providerSessionId: string | null;
|
||||
|
||||
constructor(options: ChatSessionWriterOptions) {
|
||||
this.options = options;
|
||||
this.ws = options.connection;
|
||||
this.userId = options.userId;
|
||||
this.providerSessionId = options.providerSessionId;
|
||||
}
|
||||
|
||||
send(data: unknown): void {
|
||||
const record = readObjectRecord(data);
|
||||
if (!record || typeof record.kind !== 'string') {
|
||||
// Provider runtimes only emit kind-based normalized messages. Anything
|
||||
// else indicates a programming error; drop it rather than leaking an
|
||||
// un-remapped payload to the client.
|
||||
console.error('[ChatSessionWriter] Dropping non-normalized outbound payload', data);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = record as NormalizedMessage;
|
||||
|
||||
if (message.kind === 'session_created') {
|
||||
const announcedId =
|
||||
typeof message.newSessionId === 'string' && message.newSessionId
|
||||
? message.newSessionId
|
||||
: message.sessionId;
|
||||
if (announcedId) {
|
||||
this.captureProviderSessionId(announcedId);
|
||||
}
|
||||
// Swallowed on purpose: the frontend already has the stable app session
|
||||
// id, so there is no client-side handoff to perform anymore.
|
||||
return;
|
||||
}
|
||||
|
||||
const outbound = this.options.decorateOutboundEvent(message);
|
||||
if (outbound) {
|
||||
this.forward(outbound);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the synthetic terminal `complete` for runs that ended without one
|
||||
* (runtime crash before completing, or user abort).
|
||||
*/
|
||||
sendComplete(opts: { exitCode: number; aborted?: boolean }): void {
|
||||
const message = createCompleteMessage({
|
||||
provider: this.options.provider,
|
||||
sessionId: this.providerSessionId,
|
||||
exitCode: opts.exitCode,
|
||||
aborted: opts.aborted,
|
||||
});
|
||||
const outbound = this.options.decorateOutboundEvent(message);
|
||||
if (outbound) {
|
||||
this.forward(outbound);
|
||||
}
|
||||
}
|
||||
|
||||
updateWebSocket(newConnection: RealtimeClientConnection): void {
|
||||
this.ws = newConnection;
|
||||
}
|
||||
|
||||
setSessionId(sessionId: string): void {
|
||||
this.captureProviderSessionId(sessionId);
|
||||
}
|
||||
|
||||
getSessionId(): string | null {
|
||||
return this.providerSessionId;
|
||||
}
|
||||
|
||||
private captureProviderSessionId(providerSessionId: string): void {
|
||||
if (!providerSessionId || this.providerSessionId === providerSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.providerSessionId = providerSessionId;
|
||||
this.options.onProviderSessionId(providerSessionId);
|
||||
}
|
||||
|
||||
private forward(message: NormalizedMessage): void {
|
||||
if (this.ws.readyState === WS_OPEN_STATE) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,35 @@
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js';
|
||||
import { WebSocketWriter } from '@/modules/websocket/services/websocket-writer.service.js';
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
|
||||
import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
|
||||
import type {
|
||||
AnyRecord,
|
||||
AuthenticatedWebSocketRequest,
|
||||
LLMProvider,
|
||||
} from '@/shared/types.js';
|
||||
import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js';
|
||||
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
||||
|
||||
type ChatIncomingMessage = AnyRecord & {
|
||||
type?: string;
|
||||
command?: string;
|
||||
options?: AnyRecord;
|
||||
provider?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
allow?: unknown;
|
||||
updatedInput?: unknown;
|
||||
message?: unknown;
|
||||
rememberEntry?: unknown;
|
||||
};
|
||||
|
||||
const DEFAULT_PROVIDER: LLMProvider = 'claude';
|
||||
/**
|
||||
* One provider runtime entry point. All five runtimes share this signature,
|
||||
* which lets the chat handler dispatch through a provider-keyed map instead
|
||||
* of provider-specific branches.
|
||||
*/
|
||||
type ProviderSpawnFn = (
|
||||
command: string,
|
||||
options: AnyRecord,
|
||||
writer: unknown
|
||||
) => Promise<unknown>;
|
||||
|
||||
type ChatWebSocketDependencies = {
|
||||
queryClaudeSDK: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
spawnOpenCode: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
abortClaudeSDKSession: (sessionId: string) => Promise<boolean>;
|
||||
abortCursorSession: (sessionId: string) => boolean;
|
||||
abortCodexSession: (sessionId: string) => boolean;
|
||||
abortGeminiSession: (sessionId: string) => boolean;
|
||||
abortOpenCodeSession: (sessionId: string) => boolean;
|
||||
/** Provider runtimes keyed by provider id. */
|
||||
spawnFns: Record<LLMProvider, ProviderSpawnFn>;
|
||||
/**
|
||||
* Abort functions keyed by provider id. They are addressed with the
|
||||
* provider-native session id (that is how runtimes key their process maps).
|
||||
* The Claude abort is async; the rest are sync — both shapes are accepted.
|
||||
*/
|
||||
abortFns: Record<LLMProvider, (providerSessionId: string) => boolean | Promise<boolean>>;
|
||||
resolveToolApproval: (
|
||||
requestId: string,
|
||||
payload: {
|
||||
@@ -44,31 +39,10 @@ type ChatWebSocketDependencies = {
|
||||
rememberEntry?: unknown;
|
||||
}
|
||||
) => void;
|
||||
isClaudeSDKSessionActive: (sessionId: string) => boolean;
|
||||
isCursorSessionActive: (sessionId: string) => boolean;
|
||||
isCodexSessionActive: (sessionId: string) => boolean;
|
||||
isGeminiSessionActive: (sessionId: string) => boolean;
|
||||
isOpenCodeSessionActive: (sessionId: string) => boolean;
|
||||
reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean;
|
||||
getPendingApprovalsForSession: (sessionId: string) => unknown[];
|
||||
getActiveClaudeSDKSessions: () => unknown;
|
||||
getActiveCursorSessions: () => unknown;
|
||||
getActiveCodexSessions: () => unknown;
|
||||
getActiveGeminiSessions: () => unknown;
|
||||
getActiveOpenCodeSessions: () => unknown;
|
||||
/** Claude-only today: pending tool approvals included in `chat_subscribed`. */
|
||||
getPendingApprovalsForSession: (providerSessionId: string) => unknown[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes potentially invalid provider names coming from websocket payloads.
|
||||
*/
|
||||
function readProvider(value: unknown): LLMProvider {
|
||||
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini' || value === 'opencode') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return DEFAULT_PROVIDER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the authenticated request user id in the formats currently produced
|
||||
* by platform and OSS auth code paths.
|
||||
@@ -92,8 +66,258 @@ function readRequestUserId(
|
||||
return null;
|
||||
}
|
||||
|
||||
function sendJson(ws: WebSocket, payload: unknown): void {
|
||||
if (ws.readyState === WS_OPEN_STATE) {
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a protocol-level failure to the requesting client.
|
||||
*
|
||||
* Protocol errors deliberately use their own `kind` (instead of the provider
|
||||
* `error` message kind) so the frontend can distinguish "your request was
|
||||
* invalid" from "the model run produced an error" without inspecting text.
|
||||
*/
|
||||
function sendProtocolError(
|
||||
ws: WebSocket,
|
||||
code: string,
|
||||
error: string,
|
||||
sessionId?: string
|
||||
): void {
|
||||
sendJson(ws, {
|
||||
kind: 'protocol_error',
|
||||
code,
|
||||
error,
|
||||
sessionId: sessionId ?? null,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function readRequiredSessionId(data: AnyRecord): string | null {
|
||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId.trim() : '';
|
||||
return sessionId.length > 0 ? sessionId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `chat.send`: resolves the session row (provider, project path, and
|
||||
* provider-native id all come from the database — never from the client),
|
||||
* registers the run, and dispatches to the provider runtime.
|
||||
*/
|
||||
async function handleChatSend(
|
||||
ws: WebSocket,
|
||||
userId: string | number | null,
|
||||
data: AnyRecord,
|
||||
dependencies: ChatWebSocketDependencies
|
||||
): Promise<void> {
|
||||
const sessionId = readRequiredSessionId(data);
|
||||
if (!sessionId) {
|
||||
sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.send requires a sessionId.');
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessionsDb.getSessionById(sessionId);
|
||||
if (!session) {
|
||||
sendProtocolError(
|
||||
ws,
|
||||
'SESSION_NOT_FOUND',
|
||||
`Session "${sessionId}" was not found. Create it via POST /api/providers/sessions first.`,
|
||||
sessionId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = session.provider as LLMProvider;
|
||||
const spawnFn = dependencies.spawnFns[provider];
|
||||
if (!spawnFn) {
|
||||
sendProtocolError(ws, 'UNSUPPORTED_PROVIDER', `Provider "${provider}" is not available.`, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const run = chatRunRegistry.startRun({
|
||||
appSessionId: sessionId,
|
||||
provider,
|
||||
providerSessionId: session.provider_session_id,
|
||||
connection: ws,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
sendProtocolError(
|
||||
ws,
|
||||
'RUN_IN_PROGRESS',
|
||||
`Session "${sessionId}" already has a run in progress.`,
|
||||
sessionId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const clientOptions = (data.options ?? {}) as AnyRecord;
|
||||
const command = typeof data.content === 'string' ? data.content : '';
|
||||
|
||||
// The provider runtimes receive the provider-native session id (that is the
|
||||
// id their CLI/SDK understands for resume). Brand-new sessions have no
|
||||
// provider id yet, so the runtime starts fresh and announces one, which the
|
||||
// gateway writer captures and maps back to the app session id.
|
||||
const runtimeOptions: AnyRecord = {
|
||||
...clientOptions,
|
||||
sessionId: session.provider_session_id ?? undefined,
|
||||
resume: Boolean(session.provider_session_id),
|
||||
cwd: clientOptions.cwd ?? session.project_path ?? undefined,
|
||||
projectPath: session.project_path ?? clientOptions.projectPath,
|
||||
};
|
||||
|
||||
try {
|
||||
await spawnFn(command, runtimeOptions, run.writer);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Chat] Provider runtime "${provider}" failed`, { sessionId, error: message });
|
||||
} finally {
|
||||
// Safety net: a runtime that crashed (or resolved) without emitting its
|
||||
// terminal `complete` would otherwise leave the session stuck in
|
||||
// "processing" forever on every connected client.
|
||||
chatRunRegistry.completeRun(sessionId, { exitCode: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `chat.abort`: cancels the run for one app session and emits the
|
||||
* terminal `complete` on its behalf (runtimes skip their own complete for
|
||||
* aborted runs, and the registry drops any duplicate).
|
||||
*/
|
||||
async function handleChatAbort(
|
||||
ws: WebSocket,
|
||||
data: AnyRecord,
|
||||
dependencies: ChatWebSocketDependencies
|
||||
): Promise<void> {
|
||||
const sessionId = readRequiredSessionId(data);
|
||||
if (!sessionId) {
|
||||
sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.abort requires a sessionId.');
|
||||
return;
|
||||
}
|
||||
|
||||
const run = chatRunRegistry.getRun(sessionId);
|
||||
if (!run || run.status !== 'running') {
|
||||
sendProtocolError(ws, 'NO_ACTIVE_RUN', `Session "${sessionId}" has no active run.`, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const abortFn = dependencies.abortFns[run.provider];
|
||||
let success = false;
|
||||
if (abortFn && run.providerSessionId) {
|
||||
success = Boolean(await abortFn(run.providerSessionId));
|
||||
}
|
||||
|
||||
chatRunRegistry.completeRun(sessionId, {
|
||||
exitCode: success ? 0 : 1,
|
||||
aborted: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `chat.subscribe`: for each requested session, reports whether a run
|
||||
* is processing, re-attaches the live stream to this socket, replays missed
|
||||
* events (seq > lastSeq), and includes pending permission requests.
|
||||
*
|
||||
* This single message replaces the old `check-session-status`,
|
||||
* `get-pending-permissions`, and Claude-only writer reconnect flows.
|
||||
*/
|
||||
function handleChatSubscribe(
|
||||
ws: WebSocket,
|
||||
data: AnyRecord,
|
||||
dependencies: ChatWebSocketDependencies
|
||||
): void {
|
||||
const targets = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
|
||||
for (const target of targets) {
|
||||
if (!target || typeof target !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionId = typeof (target as AnyRecord).sessionId === 'string'
|
||||
? ((target as AnyRecord).sessionId as string).trim()
|
||||
: '';
|
||||
if (!sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastSeqRaw = (target as AnyRecord).lastSeq;
|
||||
const lastSeq = typeof lastSeqRaw === 'number' && Number.isFinite(lastSeqRaw)
|
||||
? Math.max(0, Math.floor(lastSeqRaw))
|
||||
: 0;
|
||||
|
||||
const run = chatRunRegistry.getRun(sessionId);
|
||||
const isProcessing = chatRunRegistry.isProcessing(sessionId);
|
||||
|
||||
// Future live events for this run should land on the socket that asked —
|
||||
// this is what makes mid-stream page refreshes work for all providers.
|
||||
if (isProcessing) {
|
||||
chatRunRegistry.attachConnection(sessionId, ws);
|
||||
}
|
||||
|
||||
// Pending approvals are tracked under the provider-native id inside the
|
||||
// Claude runtime; remap their sessionId so the client only sees app ids.
|
||||
const pendingPermissions = (run?.providerSessionId
|
||||
? dependencies.getPendingApprovalsForSession(run.providerSessionId)
|
||||
: []
|
||||
).map((approval) =>
|
||||
approval && typeof approval === 'object'
|
||||
? { ...(approval as AnyRecord), sessionId }
|
||||
: approval,
|
||||
);
|
||||
|
||||
sendJson(ws, {
|
||||
kind: 'chat_subscribed',
|
||||
sessionId,
|
||||
isProcessing,
|
||||
lastSeq: run?.lastSeq ?? 0,
|
||||
pendingPermissions,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Replay only for RUNNING runs, strictly after the ack. Completed runs
|
||||
// are fully persisted to the provider transcript and served over REST —
|
||||
// replaying them (e.g. after a page reload where the client's lastSeq is
|
||||
// 0) would duplicate messages the history fetch already returned.
|
||||
if (isProcessing) {
|
||||
for (const event of chatRunRegistry.replayEvents(sessionId, lastSeq)) {
|
||||
sendJson(ws, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `chat.permission-response`: forwards a tool-approval decision to the
|
||||
* pending approval resolver (Claude is the only provider with interactive
|
||||
* approvals today, but the message is intentionally provider-neutral).
|
||||
*/
|
||||
function handlePermissionResponse(data: AnyRecord, dependencies: ChatWebSocketDependencies): void {
|
||||
if (typeof data.requestId !== 'string' || data.requestId.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dependencies.resolveToolApproval(data.requestId, {
|
||||
allow: Boolean(data.allow),
|
||||
updatedInput: data.updatedInput,
|
||||
message: typeof data.message === 'string' ? data.message : undefined,
|
||||
rememberEntry: data.rememberEntry,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles authenticated chat websocket messages used by the main chat panel.
|
||||
*
|
||||
* Inbound protocol (client to server):
|
||||
* - `chat.send` { sessionId, content, options? }
|
||||
* - `chat.abort` { sessionId }
|
||||
* - `chat.subscribe` { sessions: [{ sessionId, lastSeq? }] }
|
||||
* - `chat.permission-response` { requestId, allow, updatedInput?, message?, rememberEntry? }
|
||||
*
|
||||
* Outbound protocol (server to client): every frame is `kind`-based — either
|
||||
* a provider `NormalizedMessage` (with `seq`) or a gateway event
|
||||
* (`chat_subscribed`, `session_upserted`, `loading_progress`,
|
||||
* `protocol_error`).
|
||||
*/
|
||||
export function handleChatConnection(
|
||||
ws: WebSocket,
|
||||
@@ -103,7 +327,7 @@ export function handleChatConnection(
|
||||
console.log('[INFO] Chat WebSocket connected');
|
||||
connectedClients.add(ws);
|
||||
|
||||
const writer = new WebSocketWriter(ws, readRequestUserId(request));
|
||||
const userId = readRequestUserId(request);
|
||||
|
||||
ws.on('message', async (rawMessage) => {
|
||||
try {
|
||||
@@ -112,169 +336,30 @@ export function handleChatConnection(
|
||||
throw new Error('Invalid websocket payload');
|
||||
}
|
||||
|
||||
const data = parsed as ChatIncomingMessage;
|
||||
const messageType = data.type;
|
||||
if (!messageType) {
|
||||
throw new Error('Message type is required');
|
||||
}
|
||||
const data = parsed as AnyRecord;
|
||||
const messageType = typeof data.type === 'string' ? data.type : '';
|
||||
|
||||
if (messageType === 'claude-command') {
|
||||
await dependencies.queryClaudeSDK(data.command ?? '', data.options, writer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'cursor-command') {
|
||||
await dependencies.spawnCursor(data.command ?? '', data.options, writer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'codex-command') {
|
||||
await dependencies.queryCodex(data.command ?? '', data.options, writer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'gemini-command') {
|
||||
await dependencies.spawnGemini(data.command ?? '', data.options, writer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'opencode-command') {
|
||||
await dependencies.spawnOpenCode(data.command ?? '', data.options, writer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'cursor-resume') {
|
||||
await dependencies.spawnCursor(
|
||||
'',
|
||||
{
|
||||
sessionId: data.sessionId,
|
||||
resume: true,
|
||||
cwd: data.options?.cwd,
|
||||
},
|
||||
writer
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'abort-session') {
|
||||
const provider = readProvider(data.provider);
|
||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
||||
let success = false;
|
||||
|
||||
if (provider === 'cursor') {
|
||||
success = dependencies.abortCursorSession(sessionId);
|
||||
} else if (provider === 'codex') {
|
||||
success = dependencies.abortCodexSession(sessionId);
|
||||
} else if (provider === 'gemini') {
|
||||
success = dependencies.abortGeminiSession(sessionId);
|
||||
} else if (provider === 'opencode') {
|
||||
success = dependencies.abortOpenCodeSession(sessionId);
|
||||
} else {
|
||||
success = await dependencies.abortClaudeSDKSession(sessionId);
|
||||
}
|
||||
|
||||
writer.send(
|
||||
createNormalizedMessage({
|
||||
kind: 'complete',
|
||||
exitCode: success ? 0 : 1,
|
||||
aborted: true,
|
||||
success,
|
||||
sessionId,
|
||||
provider,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'claude-permission-response') {
|
||||
if (typeof data.requestId === 'string' && data.requestId.length > 0) {
|
||||
dependencies.resolveToolApproval(data.requestId, {
|
||||
allow: Boolean(data.allow),
|
||||
updatedInput: data.updatedInput,
|
||||
message: typeof data.message === 'string' ? data.message : undefined,
|
||||
rememberEntry: data.rememberEntry,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'cursor-abort') {
|
||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
||||
const success = dependencies.abortCursorSession(sessionId);
|
||||
writer.send(
|
||||
createNormalizedMessage({
|
||||
kind: 'complete',
|
||||
exitCode: success ? 0 : 1,
|
||||
aborted: true,
|
||||
success,
|
||||
sessionId,
|
||||
provider: 'cursor',
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'check-session-status') {
|
||||
const provider = readProvider(data.provider);
|
||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
||||
let isActive = false;
|
||||
|
||||
if (provider === 'cursor') {
|
||||
isActive = dependencies.isCursorSessionActive(sessionId);
|
||||
} else if (provider === 'codex') {
|
||||
isActive = dependencies.isCodexSessionActive(sessionId);
|
||||
} else if (provider === 'gemini') {
|
||||
isActive = dependencies.isGeminiSessionActive(sessionId);
|
||||
} else if (provider === 'opencode') {
|
||||
isActive = dependencies.isOpenCodeSessionActive(sessionId);
|
||||
} else {
|
||||
isActive = dependencies.isClaudeSDKSessionActive(sessionId);
|
||||
if (isActive) {
|
||||
dependencies.reconnectSessionWriter(sessionId, ws);
|
||||
}
|
||||
}
|
||||
|
||||
writer.send({
|
||||
type: 'session-status',
|
||||
sessionId,
|
||||
provider,
|
||||
isProcessing: isActive,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'get-pending-permissions') {
|
||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
||||
if (sessionId && dependencies.isClaudeSDKSessionActive(sessionId)) {
|
||||
const pending = dependencies.getPendingApprovalsForSession(sessionId);
|
||||
writer.send({
|
||||
type: 'pending-permissions-response',
|
||||
sessionId,
|
||||
data: pending,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'get-active-sessions') {
|
||||
writer.send({
|
||||
type: 'active-sessions',
|
||||
sessions: {
|
||||
claude: dependencies.getActiveClaudeSDKSessions(),
|
||||
cursor: dependencies.getActiveCursorSessions(),
|
||||
codex: dependencies.getActiveCodexSessions(),
|
||||
gemini: dependencies.getActiveGeminiSessions(),
|
||||
opencode: dependencies.getActiveOpenCodeSessions(),
|
||||
},
|
||||
});
|
||||
switch (messageType) {
|
||||
case 'chat.send':
|
||||
await handleChatSend(ws, userId, data, dependencies);
|
||||
return;
|
||||
case 'chat.abort':
|
||||
await handleChatAbort(ws, data, dependencies);
|
||||
return;
|
||||
case 'chat.subscribe':
|
||||
handleChatSubscribe(ws, data, dependencies);
|
||||
return;
|
||||
case 'chat.permission-response':
|
||||
handlePermissionResponse(data, dependencies);
|
||||
return;
|
||||
default:
|
||||
sendProtocolError(ws, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type "${messageType}".`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[ERROR] Chat WebSocket error:', message);
|
||||
writer.send({
|
||||
type: 'error',
|
||||
error: message,
|
||||
});
|
||||
sendProtocolError(ws, 'INTERNAL_ERROR', message);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
||||
import pty, { type IPty } from 'node-pty';
|
||||
import { WebSocket, type RawData } from 'ws';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
||||
|
||||
type ShellIncomingMessage = {
|
||||
@@ -18,6 +19,7 @@ type ShellIncomingMessage = {
|
||||
provider?: string;
|
||||
initialCommand?: string;
|
||||
isPlainShell?: boolean;
|
||||
forceRestart?: boolean;
|
||||
};
|
||||
|
||||
type PtySessionEntry = {
|
||||
@@ -75,6 +77,40 @@ function parseShellMessage(rawMessage: RawData): ShellIncomingMessage | null {
|
||||
return payload as ShellIncomingMessage;
|
||||
}
|
||||
|
||||
const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9_.\-:]+$/;
|
||||
|
||||
/**
|
||||
* Maps the app-facing session id to the provider-native id used by CLIs.
|
||||
*
|
||||
* Chat history and provider artifacts on disk are keyed by the provider id,
|
||||
* while the shell UI sends the stable app id from the session gateway.
|
||||
*/
|
||||
function resolveResumeSessionId(
|
||||
appSessionId: string,
|
||||
provider: string,
|
||||
dependencies: ShellWebSocketDependencies
|
||||
): string | null {
|
||||
try {
|
||||
const sessionRow = sessionsDb.getSessionById(appSessionId);
|
||||
const providerSessionId = sessionRow?.provider_session_id;
|
||||
if (providerSessionId && SAFE_SESSION_ID_PATTERN.test(providerSessionId)) {
|
||||
return providerSessionId;
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
const geminiSession = dependencies.getSessionById(appSessionId);
|
||||
const cliSessionId = geminiSession?.cliSessionId;
|
||||
if (cliSessionId && SAFE_SESSION_ID_PATTERN.test(cliSessionId)) {
|
||||
return cliSessionId;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to resolve resume session id for ${provider}:`, error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves provider command line for plain shell and agent-backed shell modes.
|
||||
*/
|
||||
@@ -86,7 +122,6 @@ function buildShellCommand(
|
||||
const sessionId = readString(message.sessionId);
|
||||
const initialCommand = readString(message.initialCommand);
|
||||
const provider = readString(message.provider, 'claude');
|
||||
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
|
||||
const isPlainShell =
|
||||
readBoolean(message.isPlainShell) ||
|
||||
(!!initialCommand && !hasSession) ||
|
||||
@@ -96,59 +131,47 @@ function buildShellCommand(
|
||||
return initialCommand;
|
||||
}
|
||||
|
||||
const resumeId =
|
||||
hasSession && sessionId ? resolveResumeSessionId(sessionId, provider, dependencies) : null;
|
||||
|
||||
if (provider === 'cursor') {
|
||||
if (hasSession && sessionId) {
|
||||
return `cursor-agent --resume="${sessionId}"`;
|
||||
if (resumeId) {
|
||||
return `cursor-agent --resume="${resumeId}"`;
|
||||
}
|
||||
return 'cursor-agent';
|
||||
}
|
||||
|
||||
if (provider === 'codex') {
|
||||
if (hasSession && sessionId) {
|
||||
if (resumeId) {
|
||||
if (os.platform() === 'win32') {
|
||||
return `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
||||
return `codex resume "${resumeId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
||||
}
|
||||
return `codex resume "${sessionId}" || codex`;
|
||||
return `codex resume "${resumeId}" || codex`;
|
||||
}
|
||||
return 'codex';
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
const command = initialCommand || 'gemini';
|
||||
let resumeId = sessionId;
|
||||
if (hasSession && sessionId) {
|
||||
try {
|
||||
const existingSession = dependencies.getSessionById(sessionId);
|
||||
if (existingSession && existingSession.cliSessionId) {
|
||||
resumeId = existingSession.cliSessionId;
|
||||
if (!safeSessionIdPattern.test(resumeId)) {
|
||||
resumeId = '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get Gemini CLI session ID:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSession && resumeId) {
|
||||
if (resumeId) {
|
||||
return `${command} --resume "${resumeId}"`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
if (hasSession && sessionId) {
|
||||
return `opencode --session "${sessionId}"`;
|
||||
if (resumeId) {
|
||||
return `opencode --session "${resumeId}"`;
|
||||
}
|
||||
return initialCommand || 'opencode';
|
||||
}
|
||||
|
||||
const command = initialCommand || 'claude';
|
||||
if (hasSession && sessionId) {
|
||||
if (resumeId) {
|
||||
if (os.platform() === 'win32') {
|
||||
return `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
||||
return `claude --resume "${resumeId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
||||
}
|
||||
return `claude --resume "${sessionId}" || claude`;
|
||||
return `claude --resume "${resumeId}" || claude`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
@@ -180,6 +203,7 @@ export function handleShellConnection(
|
||||
const hasSession = readBoolean(data.hasSession);
|
||||
const provider = readString(data.provider, 'claude');
|
||||
const initialCommand = readString(data.initialCommand);
|
||||
const forceRestart = readBoolean(data.forceRestart);
|
||||
const isPlainShell =
|
||||
readBoolean(data.isPlainShell) ||
|
||||
(!!initialCommand && !hasSession) ||
|
||||
@@ -200,7 +224,7 @@ export function handleShellConnection(
|
||||
: '';
|
||||
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
|
||||
|
||||
if (isLoginCommand) {
|
||||
if (isLoginCommand || forceRestart) {
|
||||
const oldSession = ptySessionsMap.get(ptySessionKey);
|
||||
if (oldSession) {
|
||||
if (oldSession.timeoutId) {
|
||||
@@ -211,7 +235,8 @@ export function handleShellConnection(
|
||||
}
|
||||
}
|
||||
|
||||
const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
|
||||
const existingSession =
|
||||
isLoginCommand || forceRestart ? null : ptySessionsMap.get(ptySessionKey);
|
||||
if (existingSession) {
|
||||
shellProcess = existingSession.pty;
|
||||
if (existingSession.timeoutId) {
|
||||
@@ -251,8 +276,7 @@ export function handleShellConnection(
|
||||
return;
|
||||
}
|
||||
|
||||
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
|
||||
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
|
||||
if (sessionId && !SAFE_SESSION_ID_PATTERN.test(sessionId)) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
|
||||
return;
|
||||
}
|
||||
@@ -368,6 +392,10 @@ export function handleShellConnection(
|
||||
}
|
||||
|
||||
const session = ptySessionsMap.get(ptySessionKey);
|
||||
if (session && session.pty !== shellProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
@@ -451,6 +479,10 @@ export function handleShellConnection(
|
||||
|
||||
session.ws = null;
|
||||
session.timeoutId = setTimeout(() => {
|
||||
if (ptySessionsMap.get(ptySessionKey as string) !== session) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.pty.kill();
|
||||
ptySessionsMap.delete(ptySessionKey as string);
|
||||
}, PTY_SESSION_TIMEOUT);
|
||||
|
||||
@@ -31,6 +31,24 @@ export function createWebSocketServer(
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, request) => {
|
||||
// Keep WebSocket alive across reverse-proxy idle timeouts (Cloudflare ~100s,
|
||||
// AWS ALB 60s, nginx 60s, etc.). Without app-level pings these connections
|
||||
// are silently torn down even when the UI is active, causing repeated
|
||||
// reconnect cycles. ws library heartbeat is opt-in.
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
const heartbeat = setInterval(() => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
try {
|
||||
ws.ping();
|
||||
} catch {
|
||||
// socket may have been closed concurrently — interval will be cleared below
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
const stopHeartbeat = () => clearInterval(heartbeat);
|
||||
ws.on('close', stopHeartbeat);
|
||||
ws.on('error', stopHeartbeat);
|
||||
|
||||
const incomingRequest = request as AuthenticatedWebSocketRequest;
|
||||
const url = incomingRequest.url ?? '/';
|
||||
const pathname = new URL(url, 'http://localhost').pathname;
|
||||
|
||||
239
server/modules/websocket/tests/chat-run-registry.test.ts
Normal file
239
server/modules/websocket/tests/chat-run-registry.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
|
||||
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
|
||||
import type { NormalizedMessage } from '@/shared/types.js';
|
||||
|
||||
/**
|
||||
* Minimal stand-in for a websocket connection: collects every JSON frame the
|
||||
* gateway writer forwards so assertions can inspect the outbound protocol.
|
||||
*/
|
||||
class FakeConnection {
|
||||
readyState = 1; // WS_OPEN_STATE
|
||||
frames: NormalizedMessage[] = [];
|
||||
|
||||
send(data: string): void {
|
||||
this.frames.push(JSON.parse(data) as NormalizedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'chat-run-registry-'));
|
||||
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||
|
||||
closeConnection();
|
||||
process.env.DATABASE_PATH = databasePath;
|
||||
await initializeDatabase();
|
||||
|
||||
try {
|
||||
await runTest();
|
||||
} finally {
|
||||
chatRunRegistry.clearAll();
|
||||
closeConnection();
|
||||
if (previousDatabasePath === undefined) {
|
||||
delete process.env.DATABASE_PATH;
|
||||
} else {
|
||||
process.env.DATABASE_PATH = previousDatabasePath;
|
||||
}
|
||||
await rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('live events are remapped to the app session id and sequenced', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-1', 'claude', '/workspace/demo');
|
||||
const connection = new FakeConnection();
|
||||
const run = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-1',
|
||||
provider: 'claude',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: 'user-1',
|
||||
});
|
||||
assert.ok(run);
|
||||
|
||||
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'provider-id-9', content: 'hello' });
|
||||
run.writer.send({ kind: 'text', provider: 'claude', sessionId: 'provider-id-9', content: 'hello world' });
|
||||
|
||||
assert.equal(connection.frames.length, 2);
|
||||
assert.equal(connection.frames[0]?.sessionId, 'app-run-1');
|
||||
assert.equal(connection.frames[0]?.seq, 1);
|
||||
assert.equal(connection.frames[1]?.sessionId, 'app-run-1');
|
||||
assert.equal(connection.frames[1]?.seq, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('session_created is swallowed and persisted as the provider-id mapping', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo');
|
||||
const connection = new FakeConnection();
|
||||
const run = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-2',
|
||||
provider: 'cursor',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(run);
|
||||
|
||||
run.writer.send({
|
||||
kind: 'session_created',
|
||||
provider: 'cursor',
|
||||
sessionId: 'cursor-native-7',
|
||||
newSessionId: 'cursor-native-7',
|
||||
});
|
||||
|
||||
// Never forwarded to the client...
|
||||
assert.equal(connection.frames.length, 0);
|
||||
// ...but recorded in the registry and persisted in the database.
|
||||
assert.equal(run.providerSessionId, 'cursor-native-7');
|
||||
assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7');
|
||||
});
|
||||
});
|
||||
|
||||
test('complete marks the run finished and duplicate completes are dropped', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-3', 'codex', '/workspace/demo');
|
||||
const connection = new FakeConnection();
|
||||
const run = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-3',
|
||||
provider: 'codex',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(run);
|
||||
|
||||
run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 0 });
|
||||
// Late duplicate from a killed runtime's exit handler.
|
||||
run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 1 });
|
||||
|
||||
const completes = connection.frames.filter((frame) => frame.kind === 'complete');
|
||||
assert.equal(completes.length, 1);
|
||||
assert.equal(completes[0]?.actualSessionId, 'app-run-3');
|
||||
assert.equal(chatRunRegistry.isProcessing('app-run-3'), false);
|
||||
|
||||
// completeRun is also a no-op once the run already completed.
|
||||
chatRunRegistry.completeRun('app-run-3', { exitCode: 1 });
|
||||
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('listRunningRuns returns only currently running app sessions', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo');
|
||||
sessionsDb.createAppSession('app-run-8', 'codex', '/workspace/demo');
|
||||
const connection = new FakeConnection();
|
||||
|
||||
const completedRun = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-7',
|
||||
provider: 'claude',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(completedRun);
|
||||
|
||||
const runningRun = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-8',
|
||||
provider: 'codex',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(runningRun);
|
||||
|
||||
chatRunRegistry.completeRun('app-run-7', { exitCode: 0 });
|
||||
|
||||
const runningSessions = chatRunRegistry.listRunningRuns();
|
||||
assert.deepEqual(runningSessions.map((session) => session.sessionId), ['app-run-8']);
|
||||
assert.equal(runningSessions[0]?.provider, 'codex');
|
||||
});
|
||||
});
|
||||
|
||||
test('replayEvents returns only events after the requested seq', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-4', 'claude', '/workspace/demo');
|
||||
const connection = new FakeConnection();
|
||||
const run = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-4',
|
||||
provider: 'claude',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(run);
|
||||
|
||||
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'a' });
|
||||
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'b' });
|
||||
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'c' });
|
||||
|
||||
const replayed = chatRunRegistry.replayEvents('app-run-4', 1);
|
||||
assert.deepEqual(replayed.map((event) => event.content), ['b', 'c']);
|
||||
assert.deepEqual(replayed.map((event) => event.seq), [2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
test('attachConnection reroutes the live stream to a new socket', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-5', 'gemini', '/workspace/demo');
|
||||
const firstConnection = new FakeConnection();
|
||||
const run = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-5',
|
||||
provider: 'gemini',
|
||||
providerSessionId: null,
|
||||
connection: firstConnection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(run);
|
||||
|
||||
run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'before' });
|
||||
|
||||
const secondConnection = new FakeConnection();
|
||||
assert.equal(chatRunRegistry.attachConnection('app-run-5', secondConnection), true);
|
||||
run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'after' });
|
||||
|
||||
assert.deepEqual(firstConnection.frames.map((frame) => frame.content), ['before']);
|
||||
assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']);
|
||||
});
|
||||
});
|
||||
|
||||
test('startRun rejects a second concurrent run for the same session', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-6', 'opencode', '/workspace/demo');
|
||||
const connection = new FakeConnection();
|
||||
const first = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-6',
|
||||
provider: 'opencode',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(first);
|
||||
|
||||
const second = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-6',
|
||||
provider: 'opencode',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: null,
|
||||
});
|
||||
assert.equal(second, null);
|
||||
|
||||
// After the run finishes a new one is allowed again.
|
||||
chatRunRegistry.completeRun('app-run-6', { exitCode: 0 });
|
||||
const third = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-6',
|
||||
provider: 'opencode',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(third);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Track active sessions
|
||||
const activeCodexSessions = new Map();
|
||||
@@ -352,21 +352,26 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
}
|
||||
}
|
||||
|
||||
// Send completion event
|
||||
if (!terminalFailure) {
|
||||
sendMessage(ws, createNormalizedMessage({
|
||||
kind: 'complete',
|
||||
actualSessionId: capturedSessionId || thread.id || sessionId || null,
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
provider: 'codex'
|
||||
}));
|
||||
notifyRunStopped({
|
||||
userId: ws?.userId || null,
|
||||
// Send the terminal completion event — skipped for aborted runs, whose
|
||||
// terminal `complete` (aborted: true) was already sent by abort-session.
|
||||
const runSession = capturedSessionId ? activeCodexSessions.get(capturedSessionId) : null;
|
||||
const runAborted = runSession?.status === 'aborted' || abortController.signal.aborted;
|
||||
if (!runAborted) {
|
||||
sendMessage(ws, createCompleteMessage({
|
||||
provider: 'codex',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
sessionName: sessionSummary,
|
||||
stopReason: 'completed'
|
||||
});
|
||||
actualSessionId: capturedSessionId || thread.id || sessionId || null,
|
||||
exitCode: terminalFailure ? 1 : 0,
|
||||
}));
|
||||
if (!terminalFailure) {
|
||||
notifyRunStopped({
|
||||
userId: ws?.userId || null,
|
||||
provider: 'codex',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
sessionName: sessionSummary,
|
||||
stopReason: 'completed'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
@@ -386,6 +391,11 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
: error.message;
|
||||
|
||||
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
|
||||
sendMessage(ws, createCompleteMessage({
|
||||
provider: 'codex',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
exitCode: 1,
|
||||
}));
|
||||
if (!terminalFailure) {
|
||||
notifyRunFailed({
|
||||
userId: ws?.userId || null,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { sessionsService } from './modules/providers/services/sessions.service.j
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
|
||||
import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
|
||||
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
|
||||
@@ -92,6 +92,9 @@ async function spawnOpenCode(command, options = {}, ws) {
|
||||
let stdoutLineBuffer = '';
|
||||
let terminalNotificationSent = false;
|
||||
let opencodeProcess = null;
|
||||
// Unified lifecycle contract: exactly one terminal `complete` per run
|
||||
// (close and error handlers can both fire for spawn failures).
|
||||
let completeSent = false;
|
||||
|
||||
const notifyTerminalState = ({ code = null, error = null } = {}) => {
|
||||
if (terminalNotificationSent) {
|
||||
@@ -256,13 +259,12 @@ async function spawnOpenCode(command, options = {}, ws) {
|
||||
}));
|
||||
}
|
||||
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'complete',
|
||||
exitCode: code,
|
||||
isNewSession: !sessionId && !!command,
|
||||
sessionId: finalSessionId,
|
||||
provider: 'opencode',
|
||||
}));
|
||||
// Terminal complete — skipped for aborted runs (abort-session
|
||||
// already sent the aborted complete on this run's behalf).
|
||||
if (!completeSent && !opencodeProcess.aborted) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: code }));
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
notifyTerminalState({ code });
|
||||
@@ -302,6 +304,10 @@ async function spawnOpenCode(command, options = {}, ws) {
|
||||
sessionId: finalSessionId,
|
||||
provider: 'opencode',
|
||||
}));
|
||||
if (!completeSent && !opencodeProcess.aborted) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: 1 }));
|
||||
}
|
||||
notifyTerminalState({ error });
|
||||
reject(error);
|
||||
});
|
||||
@@ -315,6 +321,9 @@ function abortOpenCodeSession(sessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The abort handler sends the terminal complete (aborted: true); flag the
|
||||
// process so its close handler does not emit a second one.
|
||||
process.aborted = true;
|
||||
process.kill('SIGTERM');
|
||||
activeOpenCodeProcesses.delete(sessionId);
|
||||
return true;
|
||||
|
||||
@@ -592,12 +592,14 @@ class ResponseCollector {
|
||||
}
|
||||
}
|
||||
|
||||
const inputTokens = totalInput + totalCacheRead + totalCacheCreation;
|
||||
|
||||
return {
|
||||
inputTokens: totalInput,
|
||||
inputTokens,
|
||||
outputTokens: totalOutput,
|
||||
cacheReadTokens: totalCacheRead,
|
||||
cacheCreationTokens: totalCacheCreation,
|
||||
totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
|
||||
totalTokens: inputTokens + totalOutput
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -644,7 +646,7 @@ class ResponseCollector {
|
||||
*
|
||||
* @param {string} model - (Optional) Model identifier for providers.
|
||||
*
|
||||
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
|
||||
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable'
|
||||
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
|
||||
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
|
||||
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
|
||||
|
||||
@@ -268,16 +268,35 @@ Custom commands can be created in:
|
||||
tokenUsage.contextWindow ??
|
||||
0,
|
||||
) || 0;
|
||||
const inputTokensRaw =
|
||||
const normalizedInputValue =
|
||||
tokenUsage.inputTokens ??
|
||||
tokenUsage.input ??
|
||||
tokenUsage.cumulativeInputTokens ??
|
||||
tokenUsage.breakdown?.input ??
|
||||
tokenUsage.promptTokens;
|
||||
const directInputTokens =
|
||||
Number(
|
||||
tokenUsage.inputTokens ??
|
||||
tokenUsage.input ??
|
||||
normalizedInputValue ??
|
||||
tokenUsage.input_tokens ??
|
||||
tokenUsage.cumulativeInputTokens ??
|
||||
tokenUsage.breakdown?.input ??
|
||||
tokenUsage.promptTokens ??
|
||||
0
|
||||
) || 0;
|
||||
const cacheReadTokens =
|
||||
Number(
|
||||
tokenUsage.cacheReadTokens ??
|
||||
tokenUsage.cache_read_input_tokens ??
|
||||
tokenUsage.cacheReadInputTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
const cacheCreationTokens =
|
||||
Number(
|
||||
tokenUsage.cacheCreationTokens ??
|
||||
tokenUsage.cache_creation_input_tokens ??
|
||||
tokenUsage.cacheCreationInputTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
const inputTokens = normalizedInputValue == null
|
||||
? directInputTokens + cacheReadTokens + cacheCreationTokens
|
||||
: directInputTokens;
|
||||
const outputTokens =
|
||||
Number(
|
||||
tokenUsage.outputTokens ??
|
||||
@@ -288,8 +307,9 @@ Custom commands can be created in:
|
||||
tokenUsage.completionTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
const hasTokenBreakdown = inputTokensRaw > 0 || outputTokens > 0;
|
||||
const used = reportedUsed || inputTokensRaw + outputTokens;
|
||||
const computedUsed = inputTokens + outputTokens;
|
||||
const hasTokenBreakdown = computedUsed > 0;
|
||||
const used = Math.max(reportedUsed, computedUsed);
|
||||
|
||||
return {
|
||||
type: "builtin",
|
||||
@@ -302,7 +322,7 @@ Custom commands can be created in:
|
||||
...(hasTokenBreakdown
|
||||
? {
|
||||
tokenBreakdown: {
|
||||
input: inputTokensRaw,
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -98,6 +98,44 @@ function normalizeSessionName(sessionName) {
|
||||
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
|
||||
}
|
||||
|
||||
function rowMatchesProvider(row, provider) {
|
||||
return row && (!provider || row.provider === provider);
|
||||
}
|
||||
|
||||
function resolveSessionRow(sessionId, provider) {
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appSessionRow = sessionsDb.getSessionById(sessionId);
|
||||
if (rowMatchesProvider(appSessionRow, provider)) {
|
||||
return appSessionRow;
|
||||
}
|
||||
|
||||
const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId);
|
||||
if (rowMatchesProvider(providerSessionRow, provider)) {
|
||||
return providerSessionRow;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeNotificationSession(event) {
|
||||
if (!event?.sessionId || !event.provider || event.provider === 'system') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const row = resolveSessionRow(event.sessionId, event.provider);
|
||||
if (!row || row.session_id === event.sessionId) {
|
||||
return event;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
sessionId: row.session_id
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionName(event) {
|
||||
const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
|
||||
if (explicitSessionName) {
|
||||
@@ -112,28 +150,29 @@ function resolveSessionName(event) {
|
||||
}
|
||||
|
||||
function buildPushBody(event) {
|
||||
const normalizedEvent = normalizeNotificationSession(event);
|
||||
const CODE_MAP = {
|
||||
'permission.required': event.meta?.toolName
|
||||
? `Action Required: Tool "${event.meta.toolName}" needs approval`
|
||||
'permission.required': normalizedEvent.meta?.toolName
|
||||
? `Action Required: Tool "${normalizedEvent.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',
|
||||
'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped',
|
||||
'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error',
|
||||
'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.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';
|
||||
const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant';
|
||||
const sessionName = resolveSessionName(normalizedEvent);
|
||||
const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification';
|
||||
|
||||
return {
|
||||
title: sessionName || 'CloudCLI',
|
||||
body: `${providerLabel}: ${message}`,
|
||||
data: {
|
||||
sessionId: event.sessionId || null,
|
||||
code: event.code,
|
||||
provider: event.provider || null,
|
||||
sessionId: normalizedEvent.sessionId || null,
|
||||
code: normalizedEvent.code,
|
||||
provider: normalizedEvent.provider || null,
|
||||
sessionName,
|
||||
tag: `${event.provider || 'assistant'}:${event.sessionId || 'none'}:${event.code}`
|
||||
tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}`
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -175,15 +214,16 @@ function notifyUserIfEnabled({ userId, event }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedEvent = normalizeNotificationSession(event);
|
||||
const preferences = notificationPreferencesDb.getPreferences(userId);
|
||||
if (!shouldSendPush(preferences, event)) {
|
||||
if (!shouldSendPush(preferences, normalizedEvent)) {
|
||||
return;
|
||||
}
|
||||
if (isDuplicate(event)) {
|
||||
if (isDuplicate(normalizedEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendWebPush(userId, event).catch((err) => {
|
||||
sendWebPush(userId, normalizedEvent).catch((err) => {
|
||||
console.error('Web push send error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
80
server/services/notification-orchestrator.test.js
Normal file
80
server/services/notification-orchestrator.test.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import webPush from 'web-push';
|
||||
|
||||
import {
|
||||
closeConnection,
|
||||
initializeDatabase,
|
||||
notificationPreferencesDb,
|
||||
pushSubscriptionsDb,
|
||||
sessionsDb,
|
||||
userDb,
|
||||
} from '../modules/database/index.js';
|
||||
|
||||
import { notifyRunStopped } from './notification-orchestrator.js';
|
||||
|
||||
async function withIsolatedDatabase(runTest) {
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'notification-orchestrator-'));
|
||||
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||
|
||||
closeConnection();
|
||||
process.env.DATABASE_PATH = databasePath;
|
||||
await initializeDatabase();
|
||||
|
||||
try {
|
||||
await runTest();
|
||||
} finally {
|
||||
closeConnection();
|
||||
if (previousDatabasePath === undefined) {
|
||||
delete process.env.DATABASE_PATH;
|
||||
} else {
|
||||
process.env.DATABASE_PATH = previousDatabasePath;
|
||||
}
|
||||
await rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('push payload uses the app session id when notified with a provider session id', async () => {
|
||||
const originalSendNotification = webPush.sendNotification;
|
||||
const sentPayloads = [];
|
||||
|
||||
webPush.sendNotification = async (_subscription, payload) => {
|
||||
sentPayloads.push(JSON.parse(payload));
|
||||
return {};
|
||||
};
|
||||
|
||||
try {
|
||||
await withIsolatedDatabase(async () => {
|
||||
const user = userDb.createUser('notify-user', 'hash');
|
||||
const userId = Number(user.id);
|
||||
|
||||
notificationPreferencesDb.updatePreferences(userId, {
|
||||
channels: { webPush: true },
|
||||
events: { actionRequired: true, stop: true, error: true },
|
||||
});
|
||||
pushSubscriptionsDb.saveSubscription(userId, 'https://example.test/push', 'p256dh', 'auth');
|
||||
sessionsDb.createAppSession('app-session-1', 'claude', '/workspace/demo');
|
||||
sessionsDb.assignProviderSessionId('app-session-1', 'claude-native-1');
|
||||
|
||||
notifyRunStopped({
|
||||
userId,
|
||||
provider: 'claude',
|
||||
sessionId: 'claude-native-1',
|
||||
stopReason: 'completed',
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(sentPayloads.length, 1);
|
||||
assert.equal(sentPayloads[0]?.data?.sessionId, 'app-session-1');
|
||||
assert.match(sentPayloads[0]?.data?.tag, /app-session-1/);
|
||||
});
|
||||
} finally {
|
||||
webPush.sendNotification = originalSendNotification;
|
||||
}
|
||||
});
|
||||
42
server/shared/tests/slice-tail-page.test.ts
Normal file
42
server/shared/tests/slice-tail-page.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { sliceTailPage } from '@/shared/utils.js';
|
||||
|
||||
const ITEMS = ['a', 'b', 'c', 'd', 'e'];
|
||||
|
||||
test('offset 0 returns the most recent page', () => {
|
||||
const { page, hasMore } = sliceTailPage(ITEMS, 2, 0);
|
||||
assert.deepEqual(page, ['d', 'e']);
|
||||
assert.equal(hasMore, true);
|
||||
});
|
||||
|
||||
test('increasing offsets walk backwards in time', () => {
|
||||
const { page, hasMore } = sliceTailPage(ITEMS, 2, 2);
|
||||
assert.deepEqual(page, ['b', 'c']);
|
||||
assert.equal(hasMore, true);
|
||||
});
|
||||
|
||||
test('the oldest page reports hasMore false', () => {
|
||||
const { page, hasMore } = sliceTailPage(ITEMS, 2, 4);
|
||||
assert.deepEqual(page, ['a']);
|
||||
assert.equal(hasMore, false);
|
||||
});
|
||||
|
||||
test('null limit returns everything', () => {
|
||||
const { page, hasMore } = sliceTailPage(ITEMS, null, 0);
|
||||
assert.deepEqual(page, ITEMS);
|
||||
assert.equal(hasMore, false);
|
||||
});
|
||||
|
||||
test('offsets past the start return an empty page', () => {
|
||||
const { page, hasMore } = sliceTailPage(ITEMS, 3, 10);
|
||||
assert.deepEqual(page, []);
|
||||
assert.equal(hasMore, false);
|
||||
});
|
||||
|
||||
test('zero limit returns an empty page but keeps hasMore accurate', () => {
|
||||
const { page, hasMore } = sliceTailPage(ITEMS, 0, 0);
|
||||
assert.deepEqual(page, []);
|
||||
assert.equal(hasMore, true);
|
||||
});
|
||||
@@ -68,7 +68,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
|
||||
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
|
||||
|
||||
/**
|
||||
* One selectable model row (matches the documentation `public/modelConstants.js` option shape).
|
||||
* One selectable model row in a provider model catalog.
|
||||
*/
|
||||
export type ProviderModelOption = {
|
||||
value: string;
|
||||
@@ -175,6 +175,30 @@ export type MessageKind =
|
||||
| 'interactive_prompt'
|
||||
| 'task_notification';
|
||||
|
||||
/**
|
||||
* Event kinds added by the chat gateway layer on top of provider message kinds.
|
||||
*
|
||||
* These are app-level realtime events (subscription acks, sidebar deltas,
|
||||
* project loading progress, protocol failures) that are not produced by any
|
||||
* provider adapter. Together with `MessageKind` they form the complete set of
|
||||
* `kind` values a websocket client can receive, so the frontend only ever
|
||||
* needs one kind-based switch.
|
||||
*/
|
||||
export type GatewayEventKind =
|
||||
| 'chat_subscribed'
|
||||
| 'session_upserted'
|
||||
| 'loading_progress'
|
||||
| 'protocol_error';
|
||||
|
||||
/**
|
||||
* Complete set of `kind` values emitted to websocket clients.
|
||||
*
|
||||
* Every server-to-client websocket frame carries a `kind` from this union.
|
||||
* Provider runtimes emit `MessageKind` values; gateway services emit
|
||||
* `GatewayEventKind` values.
|
||||
*/
|
||||
export type ServerEventKind = MessageKind | GatewayEventKind;
|
||||
|
||||
/**
|
||||
* Provider-neutral message envelope used in REST responses and realtime channels.
|
||||
*
|
||||
@@ -187,6 +211,13 @@ export type NormalizedMessage = {
|
||||
timestamp: string;
|
||||
provider: LLMProvider;
|
||||
kind: MessageKind;
|
||||
/**
|
||||
* Monotonic per-run sequence number assigned by the chat run registry when a
|
||||
* live event is forwarded to the websocket. History messages loaded over
|
||||
* REST do not carry it. Clients use it with `chat.subscribe` to replay only
|
||||
* the live events they missed across websocket reconnects.
|
||||
*/
|
||||
seq?: number;
|
||||
role?: 'user' | 'assistant';
|
||||
content?: string;
|
||||
/**
|
||||
@@ -237,11 +268,18 @@ export type NormalizedMessage = {
|
||||
*
|
||||
* Consumers should pass provider-specific lookup hints (`projectPath`) only
|
||||
* when the selected provider requires them.
|
||||
*
|
||||
* `providerSessionId` is the provider-native session id from the sessions
|
||||
* index (transcript file name / provider database key). Provider adapters
|
||||
* must use it — never the app-facing session id they were called with — when
|
||||
* matching transcript rows on disk, because app-created sessions use an
|
||||
* app-allocated id that the provider has never seen.
|
||||
*/
|
||||
export type FetchHistoryOptions = {
|
||||
projectPath?: string;
|
||||
limit?: number | null;
|
||||
offset?: number;
|
||||
providerSessionId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -346,6 +346,84 @@ export function createNormalizedMessage(fields: NormalizedMessageInput): Normali
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the unified terminal `complete` lifecycle message.
|
||||
*
|
||||
* Contract: every provider run ends with exactly one `complete` (the
|
||||
* abort-session handler emits it on behalf of cancelled runs, so aborted runs
|
||||
* must NOT emit their own). The frontend treats `complete` as the only
|
||||
* terminal signal and never needs provider-specific handling:
|
||||
*
|
||||
* - `sessionId` — the id the client knows this run by ('' if never discovered)
|
||||
* - `actualSessionId` — canonical id after the run; equals `sessionId` unless
|
||||
* the provider rewrote it mid-run
|
||||
* - `exitCode` — 0 on success; a missing/null code (e.g. killed process)
|
||||
* is reported as failure
|
||||
* - `success` — exitCode === 0 and not aborted
|
||||
* - `aborted` — run was cancelled by the user
|
||||
*/
|
||||
export function createCompleteMessage(opts: {
|
||||
provider: NormalizedMessage['provider'];
|
||||
sessionId?: string | null;
|
||||
actualSessionId?: string | null;
|
||||
exitCode?: number | null;
|
||||
aborted?: boolean;
|
||||
}): NormalizedMessage {
|
||||
const exitCode = typeof opts.exitCode === 'number' ? opts.exitCode : 1;
|
||||
const aborted = Boolean(opts.aborted);
|
||||
|
||||
return createNormalizedMessage({
|
||||
kind: 'complete',
|
||||
provider: opts.provider,
|
||||
sessionId: opts.sessionId || null,
|
||||
actualSessionId: opts.actualSessionId || opts.sessionId || null,
|
||||
exitCode,
|
||||
success: exitCode === 0 && !aborted,
|
||||
aborted,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- CONVERSATION HISTORY PAGINATION UTILITIES ------------
|
||||
/**
|
||||
* Slices one page from the END of a chronologically ordered message list.
|
||||
*
|
||||
* This is the single pagination contract for conversation history across all
|
||||
* providers: `offset = 0` returns the most recent `limit` items, increasing
|
||||
* offsets walk backwards in time (for "scroll up to load older" UIs), and a
|
||||
* `null` limit returns everything. Items must already be sorted oldest-first;
|
||||
* the returned page preserves that order.
|
||||
*
|
||||
* Every provider history reader must use this helper instead of slicing
|
||||
* manually so `offset`/`limit` query params behave identically regardless of
|
||||
* which provider produced the session.
|
||||
*/
|
||||
export function sliceTailPage<T>(
|
||||
items: T[],
|
||||
limit: number | null,
|
||||
offset: number,
|
||||
): { page: T[]; hasMore: boolean } {
|
||||
const total = items.length;
|
||||
const normalizedOffset = Math.max(0, offset);
|
||||
|
||||
if (limit === null) {
|
||||
// A null limit returns the full list; offset still trims newest entries
|
||||
// so "everything before the page I already have" stays expressible.
|
||||
const end = Math.max(0, total - normalizedOffset);
|
||||
return {
|
||||
page: items.slice(0, end),
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
const end = Math.max(0, total - normalizedOffset);
|
||||
const start = Math.max(0, end - Math.max(0, limit));
|
||||
return {
|
||||
page: items.slice(start, end),
|
||||
hasMore: start > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- MCP CONFIG PARSING UTILITIES ------------
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
import { spawn } from 'cross-spawn';
|
||||
|
||||
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
|
||||
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
||||
|
||||
@@ -7,6 +7,41 @@ const runningPlugins = new Map();
|
||||
// Map<pluginName, Promise<port>> — in-flight start operations
|
||||
const startingPlugins = new Map();
|
||||
|
||||
/**
|
||||
* Build the environment handed to a plugin server subprocess.
|
||||
*
|
||||
* Intentionally minimal: only non-secret essentials, never the host's full
|
||||
* environment. On Windows a handful of system variables are required for any
|
||||
* child to bootstrap (Node itself, and any Python or CLI a plugin shells out
|
||||
* to). Without APPDATA a `pip install --user` tool cannot locate its
|
||||
* site-packages and fails to import; SystemRoot, PATHEXT and TEMP are needed to
|
||||
* resolve system DLLs, executable extensions and a temp directory. None of
|
||||
* these carry secrets, so the ones that are set get passed straight through.
|
||||
*/
|
||||
function buildPluginEnv(name) {
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
NODE_ENV: process.env.NODE_ENV || 'production',
|
||||
PLUGIN_NAME: name,
|
||||
};
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const WINDOWS_ESSENTIALS = [
|
||||
'SystemRoot', 'windir', 'SystemDrive',
|
||||
'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
|
||||
'TEMP', 'TMP', 'PATHEXT',
|
||||
];
|
||||
for (const key of WINDOWS_ESSENTIALS) {
|
||||
if (process.env[key] !== undefined) {
|
||||
env[key] = process.env[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a plugin's server subprocess.
|
||||
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
|
||||
@@ -26,15 +61,9 @@ export function startPluginServer(name, pluginDir, serverEntry) {
|
||||
|
||||
const serverPath = path.join(pluginDir, serverEntry);
|
||||
|
||||
// Restricted env — only essentials, no host secrets
|
||||
const pluginProcess = spawn('node', [serverPath], {
|
||||
cwd: pluginDir,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
NODE_ENV: process.env.NODE_ENV || 'production',
|
||||
PLUGIN_NAME: name,
|
||||
},
|
||||
env: buildPluginEnv(name),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -10,6 +10,33 @@ import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/Palett
|
||||
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
||||
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
||||
import { useProjectsState } from '../../hooks/useProjectsState';
|
||||
import { api } from '../../utils/api';
|
||||
|
||||
type RunningSessionApiItem = {
|
||||
sessionId?: unknown;
|
||||
startedAt?: unknown;
|
||||
statusText?: unknown;
|
||||
canInterrupt?: unknown;
|
||||
};
|
||||
|
||||
type RunningSessionsApiPayload = {
|
||||
data?: {
|
||||
sessions?: RunningSessionApiItem[];
|
||||
};
|
||||
};
|
||||
|
||||
const parseStartedAt = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
export default function AppContent() {
|
||||
return (
|
||||
@@ -24,16 +51,13 @@ function AppContentInner() {
|
||||
const { sessionId } = useParams<{ sessionId?: string }>();
|
||||
const { t } = useTranslation('common');
|
||||
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
||||
const { ws, sendMessage, latestMessage, isConnected } = useWebSocket();
|
||||
const wasConnectedRef = useRef(false);
|
||||
const { ws, sendMessage, subscribe } = useWebSocket();
|
||||
|
||||
const {
|
||||
activeSessions,
|
||||
processingSessions,
|
||||
markSessionAsActive,
|
||||
markSessionAsInactive,
|
||||
markSessionAsProcessing,
|
||||
markSessionAsNotProcessing,
|
||||
markSessionProcessing,
|
||||
markSessionIdle,
|
||||
syncProcessingSessions,
|
||||
} = useSessionProtection();
|
||||
|
||||
const {
|
||||
@@ -50,16 +74,64 @@ function AppContentInner() {
|
||||
setShowSettings,
|
||||
openSettings,
|
||||
refreshProjectsSilently,
|
||||
registerOptimisticSession,
|
||||
sidebarSharedProps,
|
||||
handleNewSession,
|
||||
} = useProjectsState({
|
||||
sessionId,
|
||||
navigate,
|
||||
latestMessage,
|
||||
subscribe,
|
||||
isMobile,
|
||||
activeSessions,
|
||||
activeSessions: processingSessions,
|
||||
});
|
||||
|
||||
const refreshRunningSessions = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.runningSessions();
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as RunningSessionsApiPayload;
|
||||
const sessions = Array.isArray(payload.data?.sessions) ? payload.data.sessions : [];
|
||||
|
||||
syncProcessingSessions(
|
||||
sessions
|
||||
.map((session) => {
|
||||
if (typeof session.sessionId !== 'string' || !session.sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
startedAt: parseStartedAt(session.startedAt),
|
||||
statusText: typeof session.statusText === 'string' ? session.statusText : undefined,
|
||||
canInterrupt: typeof session.canInterrupt === 'boolean' ? session.canInterrupt : undefined,
|
||||
};
|
||||
})
|
||||
.filter((session): session is NonNullable<typeof session> => Boolean(session)),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[AppContent] Failed to sync running sessions:', error);
|
||||
}
|
||||
}, [syncProcessingSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshRunningSessions();
|
||||
}, [refreshRunningSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (processingSessions.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void refreshRunningSessions();
|
||||
}, 5000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [processingSessions.size, refreshRunningSessions]);
|
||||
|
||||
usePaletteOpsRegister({
|
||||
openSettings,
|
||||
refreshProjects: refreshProjectsSilently,
|
||||
@@ -99,23 +171,9 @@ function AppContentInner() {
|
||||
};
|
||||
}, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]);
|
||||
|
||||
// Permission recovery: query pending permissions on WebSocket reconnect or session change
|
||||
useEffect(() => {
|
||||
const isReconnect = isConnected && !wasConnectedRef.current;
|
||||
|
||||
if (isReconnect) {
|
||||
wasConnectedRef.current = true;
|
||||
} else if (!isConnected) {
|
||||
wasConnectedRef.current = false;
|
||||
}
|
||||
|
||||
if (isConnected && selectedSession?.id) {
|
||||
sendMessage({
|
||||
type: 'get-pending-permissions',
|
||||
sessionId: selectedSession.id
|
||||
});
|
||||
}
|
||||
}, [isConnected, selectedSession?.id, sendMessage]);
|
||||
// Pending tool permissions are recovered through the `chat.subscribe` flow:
|
||||
// the `chat_subscribed` ack carries them on session open and on reconnect,
|
||||
// so no separate permission-recovery message is needed here.
|
||||
|
||||
// Adjust the app container to stay above the virtual keyboard on iOS Safari.
|
||||
// On Chrome for Android the layout viewport already shrinks when the keyboard opens,
|
||||
@@ -180,19 +238,19 @@ function AppContentInner() {
|
||||
setActiveTab={setActiveTab}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
latestMessage={latestMessage}
|
||||
isMobile={isMobile}
|
||||
onMenuClick={() => setSidebarOpen(true)}
|
||||
isLoading={isLoadingProjects}
|
||||
onInputFocusChange={setIsInputFocused}
|
||||
onSessionActive={markSessionAsActive}
|
||||
onSessionInactive={markSessionAsInactive}
|
||||
onSessionProcessing={markSessionAsProcessing}
|
||||
onSessionNotProcessing={markSessionAsNotProcessing}
|
||||
onSessionProcessing={markSessionProcessing}
|
||||
onSessionIdle={markSessionIdle}
|
||||
processingSessions={processingSessions}
|
||||
onNavigateToSession={(targetSessionId: string, options) =>
|
||||
navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) })
|
||||
}
|
||||
onSessionEstablished={(targetSessionId, context) =>
|
||||
registerOptimisticSession({ sessionId: targetSessionId, ...context })
|
||||
}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
externalMessageUpdate={externalMessageUpdate}
|
||||
newSessionTrigger={newSessionTrigger}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Brain, Zap, Sparkles, Atom } from 'lucide-react';
|
||||
|
||||
export const thinkingModes = [
|
||||
{
|
||||
id: 'none',
|
||||
name: 'Standard',
|
||||
description: 'Regular Claude response',
|
||||
icon: null,
|
||||
prefix: '',
|
||||
color: 'text-gray-600'
|
||||
},
|
||||
{
|
||||
id: 'think',
|
||||
name: 'Think',
|
||||
description: 'Basic extended thinking',
|
||||
icon: Brain,
|
||||
prefix: 'think',
|
||||
color: 'text-blue-600'
|
||||
},
|
||||
{
|
||||
id: 'think-hard',
|
||||
name: 'Think Hard',
|
||||
description: 'More thorough evaluation',
|
||||
icon: Zap,
|
||||
prefix: 'think hard',
|
||||
color: 'text-purple-600'
|
||||
},
|
||||
{
|
||||
id: 'think-harder',
|
||||
name: 'Think Harder',
|
||||
description: 'Deep analysis with alternatives',
|
||||
icon: Sparkles,
|
||||
prefix: 'think harder',
|
||||
color: 'text-indigo-600'
|
||||
},
|
||||
{
|
||||
id: 'ultrathink',
|
||||
name: 'Ultrathink',
|
||||
description: 'Maximum thinking budget',
|
||||
icon: Atom,
|
||||
prefix: 'ultrathink',
|
||||
color: 'text-red-600'
|
||||
}
|
||||
];
|
||||
@@ -12,13 +12,14 @@ import type {
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { thinkingModes } from '../constants/thinkingModes';
|
||||
import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
||||
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
||||
import { safeLocalStorage } from '../utils/chatStorage';
|
||||
import type {
|
||||
ChatMessage,
|
||||
PendingPermissionRequest,
|
||||
PermissionMode,
|
||||
SessionEstablishedContext,
|
||||
} from '../types/types';
|
||||
import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } from '../../../types/app';
|
||||
import { escapeRegExp } from '../utils/chatFormatting';
|
||||
@@ -26,10 +27,6 @@ import { escapeRegExp } from '../utils/chatFormatting';
|
||||
import { useFileMentions } from './useFileMentions';
|
||||
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
|
||||
|
||||
type PendingViewSession = {
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
interface UseChatComposerStateArgs {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
@@ -47,17 +44,20 @@ interface UseChatComposerStateArgs {
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
sendByCtrlEnter?: boolean;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: MarkSessionProcessing;
|
||||
/**
|
||||
* Invoked with the freshly allocated session id when the user sends the
|
||||
* first message of a brand-new conversation. The backend allocates the id
|
||||
* via POST /api/providers/sessions BEFORE the websocket send, so the id is
|
||||
* stable for the conversation's whole lifetime — the consumer navigates to
|
||||
* /session/:id and records it as the current session.
|
||||
*/
|
||||
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||
onInputFocusChange?: (focused: boolean) => void;
|
||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||
onShowSettings?: () => void;
|
||||
pendingViewSessionRef: { current: PendingViewSession | null };
|
||||
scrollToBottom: () => void;
|
||||
addMessage: (msg: ChatMessage) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setCanAbortSession: (canAbort: boolean) => void;
|
||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
||||
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
|
||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||
}
|
||||
@@ -178,17 +178,13 @@ export function useChatComposerState({
|
||||
tokenBudget,
|
||||
sendMessage,
|
||||
sendByCtrlEnter,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionEstablished,
|
||||
onInputFocusChange,
|
||||
onFileOpen,
|
||||
onShowSettings,
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
addMessage,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setIsUserScrolledUp,
|
||||
setPendingPermissionRequests,
|
||||
}: UseChatComposerStateArgs) {
|
||||
@@ -204,7 +200,6 @@ export function useChatComposerState({
|
||||
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
|
||||
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
|
||||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||
const [thinkingMode, setThinkingMode] = useState('none');
|
||||
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -313,7 +308,7 @@ export function useChatComposerState({
|
||||
}, [addMessage]);
|
||||
|
||||
const executeCommand = useCallback(
|
||||
async (command: SlashCommand, rawInput?: string) => {
|
||||
async (command: SlashCommand, rawInput?: string, options?: { preserveInput?: boolean }) => {
|
||||
if (!command || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
@@ -370,8 +365,10 @@ export function useChatComposerState({
|
||||
const result = (await response.json()) as CommandExecutionResult;
|
||||
if (result.type === 'builtin') {
|
||||
handleBuiltInCommand(result);
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
if (!options?.preserveInput) {
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
}
|
||||
} else if (result.type === 'custom') {
|
||||
await handleCustomCommand(result);
|
||||
}
|
||||
@@ -402,6 +399,19 @@ export function useChatComposerState({
|
||||
],
|
||||
);
|
||||
|
||||
const showCostModal = useCallback(() => {
|
||||
executeCommand(
|
||||
{
|
||||
name: '/cost',
|
||||
description: 'Display token usage information',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' },
|
||||
} as SlashCommand,
|
||||
'/cost',
|
||||
{ preserveInput: true },
|
||||
);
|
||||
}, [executeCommand]);
|
||||
|
||||
const {
|
||||
slashCommands,
|
||||
slashCommandsCount,
|
||||
@@ -562,11 +572,7 @@ export function useChatComposerState({
|
||||
}
|
||||
}
|
||||
|
||||
let messageContent = currentInput;
|
||||
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
|
||||
if (selectedThinkingMode && selectedThinkingMode.prefix) {
|
||||
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
|
||||
}
|
||||
const messageContent = currentInput;
|
||||
|
||||
let uploadedImages: unknown[] = [];
|
||||
if (attachedImages.length > 0) {
|
||||
@@ -600,8 +606,54 @@ export function useChatComposerState({
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveSessionId =
|
||||
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
|
||||
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
||||
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
|
||||
|
||||
// The conversation always has a stable backend-allocated session id
|
||||
// BEFORE the first websocket send: brand-new chats allocate one here
|
||||
// via the session gateway. There is no client-visible session-id
|
||||
// handoff later — this id stays valid for the conversation's lifetime.
|
||||
let targetSessionId = selectedSession?.id || currentSessionId || null;
|
||||
if (!targetSessionId) {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/providers/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
projectPath: resolvedProjectPath,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create session (${response.status})`);
|
||||
}
|
||||
const body = await response.json();
|
||||
targetSessionId = body?.data?.sessionId || null;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Session creation failed:', error);
|
||||
addMessage({
|
||||
type: 'error',
|
||||
content: `Failed to start a new session: ${message}`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetSessionId) {
|
||||
addMessage({
|
||||
type: 'error',
|
||||
content: 'Failed to start a new session: no session id returned.',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onSessionEstablished?.(targetSessionId, {
|
||||
provider,
|
||||
project: selectedProject,
|
||||
summary: sessionSummary,
|
||||
});
|
||||
}
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
type: 'user',
|
||||
@@ -611,27 +663,17 @@ export function useChatComposerState({
|
||||
};
|
||||
|
||||
addMessage(userMessage);
|
||||
setIsLoading(true); // Processing banner starts
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({
|
||||
text: 'Processing',
|
||||
tokens: 0,
|
||||
can_interrupt: true,
|
||||
// Mark this request as processing in the per-session activity map (the
|
||||
// single source of truth the indicator derives from). The id is always
|
||||
// concrete at this point — no pending placeholder exists anymore.
|
||||
onSessionProcessing?.(targetSessionId, {
|
||||
statusText: null,
|
||||
canInterrupt: true,
|
||||
});
|
||||
|
||||
setIsUserScrolledUp(false);
|
||||
setTimeout(() => scrollToBottom(), 100);
|
||||
|
||||
if (!effectiveSessionId && !selectedSession?.id) {
|
||||
// This tracks only that a request is in flight before the provider has
|
||||
// emitted its real session id; routing still waits for session_created.
|
||||
pendingViewSessionRef.current = { startedAt: Date.now() };
|
||||
}
|
||||
if (effectiveSessionId) {
|
||||
onSessionActive?.(effectiveSessionId);
|
||||
onSessionProcessing?.(effectiveSessionId);
|
||||
}
|
||||
|
||||
const getToolsSettings = () => {
|
||||
try {
|
||||
const settingsKey =
|
||||
@@ -660,87 +702,35 @@ export function useChatComposerState({
|
||||
};
|
||||
|
||||
const toolsSettings = getToolsSettings();
|
||||
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
||||
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
|
||||
const model =
|
||||
provider === 'cursor'
|
||||
? cursorModel
|
||||
: provider === 'codex'
|
||||
? codexModel
|
||||
: provider === 'gemini'
|
||||
? geminiModel
|
||||
: provider === 'opencode'
|
||||
? opencodeModel
|
||||
: claudeModel;
|
||||
|
||||
if (provider === 'cursor') {
|
||||
sendMessage({
|
||||
type: 'cursor-command',
|
||||
command: messageContent,
|
||||
sessionId: effectiveSessionId,
|
||||
options: {
|
||||
cwd: resolvedProjectPath,
|
||||
projectPath: resolvedProjectPath,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: Boolean(effectiveSessionId),
|
||||
model: cursorModel,
|
||||
skipPermissions: toolsSettings?.skipPermissions || false,
|
||||
sessionSummary,
|
||||
toolsSettings,
|
||||
},
|
||||
});
|
||||
} else if (provider === 'codex') {
|
||||
sendMessage({
|
||||
type: 'codex-command',
|
||||
command: messageContent,
|
||||
sessionId: effectiveSessionId,
|
||||
options: {
|
||||
cwd: resolvedProjectPath,
|
||||
projectPath: resolvedProjectPath,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: Boolean(effectiveSessionId),
|
||||
model: codexModel,
|
||||
sessionSummary,
|
||||
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
|
||||
},
|
||||
});
|
||||
} else if (provider === 'gemini') {
|
||||
sendMessage({
|
||||
type: 'gemini-command',
|
||||
command: messageContent,
|
||||
sessionId: effectiveSessionId,
|
||||
options: {
|
||||
cwd: resolvedProjectPath,
|
||||
projectPath: resolvedProjectPath,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: Boolean(effectiveSessionId),
|
||||
model: geminiModel,
|
||||
sessionSummary,
|
||||
permissionMode,
|
||||
toolsSettings,
|
||||
},
|
||||
});
|
||||
} else if (provider === 'opencode') {
|
||||
sendMessage({
|
||||
type: 'opencode-command',
|
||||
command: messageContent,
|
||||
sessionId: effectiveSessionId,
|
||||
options: {
|
||||
cwd: resolvedProjectPath,
|
||||
projectPath: resolvedProjectPath,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: Boolean(effectiveSessionId),
|
||||
model: opencodeModel,
|
||||
sessionSummary,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sendMessage({
|
||||
type: 'claude-command',
|
||||
command: messageContent,
|
||||
options: {
|
||||
projectPath: resolvedProjectPath,
|
||||
cwd: resolvedProjectPath,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: Boolean(effectiveSessionId),
|
||||
toolsSettings,
|
||||
permissionMode,
|
||||
model: claudeModel,
|
||||
sessionSummary,
|
||||
images: uploadedImages,
|
||||
},
|
||||
});
|
||||
}
|
||||
// One message shape for every provider. The backend resolves the
|
||||
// provider, project path, and provider-native resume id from the
|
||||
// session row; `options` only carries composer-level preferences.
|
||||
sendMessage({
|
||||
type: 'chat.send',
|
||||
sessionId: targetSessionId,
|
||||
content: messageContent,
|
||||
options: {
|
||||
model,
|
||||
// Codex has no plan mode; downgrade rather than sending an
|
||||
// unsupported value to its runtime.
|
||||
permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode,
|
||||
toolsSettings,
|
||||
skipPermissions: toolsSettings?.skipPermissions || false,
|
||||
sessionSummary,
|
||||
images: uploadedImages,
|
||||
},
|
||||
});
|
||||
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
@@ -749,7 +739,6 @@ export function useChatComposerState({
|
||||
setUploadingImages(new Map());
|
||||
setImageErrors(new Map());
|
||||
setIsTextareaExpanded(false);
|
||||
setThinkingMode('none');
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
@@ -768,22 +757,17 @@ export function useChatComposerState({
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
pendingViewSessionRef,
|
||||
onSessionEstablished,
|
||||
permissionMode,
|
||||
provider,
|
||||
resetCommandMenuState,
|
||||
scrollToBottom,
|
||||
selectedProject,
|
||||
sendMessage,
|
||||
setCanAbortSession,
|
||||
addMessage,
|
||||
setClaudeStatus,
|
||||
setIsLoading,
|
||||
setIsUserScrolledUp,
|
||||
slashCommands,
|
||||
thinkingMode,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -937,29 +921,19 @@ export function useChatComposerState({
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorSessionId =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;
|
||||
|
||||
const candidateSessionIds = [
|
||||
currentSessionId,
|
||||
provider === 'cursor' ? cursorSessionId : null,
|
||||
selectedSession?.id || null,
|
||||
];
|
||||
|
||||
const targetSessionId =
|
||||
candidateSessionIds.find((sessionId) => Boolean(sessionId)) || null;
|
||||
|
||||
const targetSessionId = selectedSession?.id || currentSessionId || null;
|
||||
if (!targetSessionId) {
|
||||
console.warn('Abort requested but no concrete session ID is available yet.');
|
||||
console.warn('Abort requested but no session ID is available.');
|
||||
return;
|
||||
}
|
||||
|
||||
// The backend resolves the provider from the session row, so no provider
|
||||
// field is needed here.
|
||||
sendMessage({
|
||||
type: 'abort-session',
|
||||
type: 'chat.abort',
|
||||
sessionId: targetSessionId,
|
||||
provider,
|
||||
});
|
||||
}, [canAbortSession, currentSessionId, provider, selectedSession?.id, sendMessage]);
|
||||
}, [canAbortSession, currentSessionId, selectedSession?.id, sendMessage]);
|
||||
|
||||
const handleGrantToolPermission = useCallback(
|
||||
(suggestion: { entry: string; toolName: string }) => {
|
||||
@@ -984,7 +958,7 @@ export function useChatComposerState({
|
||||
|
||||
validIds.forEach((requestId) => {
|
||||
sendMessage({
|
||||
type: 'claude-permission-response',
|
||||
type: 'chat.permission-response',
|
||||
requestId,
|
||||
allow: Boolean(decision?.allow),
|
||||
updatedInput: decision?.updatedInput,
|
||||
@@ -993,15 +967,11 @@ export function useChatComposerState({
|
||||
});
|
||||
});
|
||||
|
||||
setPendingPermissionRequests((previous) => {
|
||||
const next = previous.filter((request) => !validIds.includes(request.requestId));
|
||||
if (next.length === 0) {
|
||||
setClaudeStatus(null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setPendingPermissionRequests((previous) =>
|
||||
previous.filter((request) => !validIds.includes(request.requestId)),
|
||||
);
|
||||
},
|
||||
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
|
||||
[sendMessage, setPendingPermissionRequests],
|
||||
);
|
||||
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
@@ -1020,8 +990,6 @@ export function useChatComposerState({
|
||||
textareaRef,
|
||||
inputHighlightRef,
|
||||
isTextareaExpanded,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
slashCommandsCount,
|
||||
filteredCommands,
|
||||
frequentCommands,
|
||||
@@ -1059,5 +1027,6 @@ export function useChatComposerState({
|
||||
isInputFocused,
|
||||
commandModalPayload,
|
||||
closeCommandModal,
|
||||
showCostModal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ import type { NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
import type { ChatMessage, SubagentChildTool } from '../types/types';
|
||||
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
|
||||
|
||||
function formatToolResultContent(content: unknown): string {
|
||||
const text = typeof content === 'string' ? content : JSON.stringify(content);
|
||||
const toolUseErrorMatch = /^<tool_use_error>([\s\S]*)<\/tool_use_error>$/.exec(text.trim());
|
||||
return toolUseErrorMatch ? toolUseErrorMatch[1] : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert NormalizedMessage[] from the session store into ChatMessage[]
|
||||
* that the existing UI components expect.
|
||||
@@ -20,7 +26,12 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
|
||||
// First pass: collect tool results for attachment
|
||||
const toolResultMap = new Map<string, NormalizedMessage>();
|
||||
const toolUseIds = new Set<string>();
|
||||
for (const msg of messages) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId) {
|
||||
toolUseIds.add(msg.toolId);
|
||||
}
|
||||
|
||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||
toolResultMap.set(msg.toolId, msg);
|
||||
}
|
||||
@@ -97,7 +108,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
|
||||
const toolResult = tr
|
||||
? {
|
||||
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
|
||||
content: formatToolResultContent(tr.content),
|
||||
isError: Boolean(tr.isError),
|
||||
toolUseResult: (tr as any).toolUseResult,
|
||||
}
|
||||
@@ -191,8 +202,25 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
break;
|
||||
|
||||
// tool_result is handled via attachment to tool_use above
|
||||
case 'tool_result':
|
||||
case 'tool_result': {
|
||||
if (msg.toolId && toolUseIds.has(msg.toolId)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const content = formatToolResultContent(msg.content || '');
|
||||
if (!content.trim()) {
|
||||
break;
|
||||
}
|
||||
|
||||
converted.push({
|
||||
type: msg.isError ? 'error' : 'assistant',
|
||||
content,
|
||||
timestamp: msg.timestamp,
|
||||
toolId: msg.toolId,
|
||||
...sharedMetadata,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -17,17 +17,35 @@ const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
|
||||
opencode: 'anthropic/claude-sonnet-4-5',
|
||||
};
|
||||
|
||||
const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
|
||||
if (provider === 'codex') {
|
||||
return ['default', 'acceptEdits', 'bypassPermissions'];
|
||||
}
|
||||
if (provider === 'claude') {
|
||||
return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
}
|
||||
if (provider === 'opencode') {
|
||||
return ['default'];
|
||||
}
|
||||
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
/**
|
||||
* Fallback permission-mode matrix used only until the backend capability
|
||||
* matrix (`GET /api/providers/capabilities`) has loaded. The backend is the
|
||||
* source of truth; this mirror exists so the composer renders sensibly on
|
||||
* first paint and when the capabilities request fails.
|
||||
*/
|
||||
const FALLBACK_PERMISSION_MODES: Record<LLMProvider, PermissionMode[]> = {
|
||||
claude: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
codex: ['default', 'acceptEdits', 'bypassPermissions'],
|
||||
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
opencode: ['default'],
|
||||
};
|
||||
|
||||
type ProviderCapabilities = {
|
||||
provider: LLMProvider;
|
||||
permissionModes: string[];
|
||||
defaultPermissionMode: string;
|
||||
supportsImages: boolean;
|
||||
supportsAbort: boolean;
|
||||
supportsPermissionRequests: boolean;
|
||||
supportsTokenUsage: boolean;
|
||||
};
|
||||
|
||||
type ProviderCapabilitiesApiResponse = {
|
||||
success?: boolean;
|
||||
data?: {
|
||||
providers?: ProviderCapabilities[];
|
||||
};
|
||||
};
|
||||
|
||||
interface UseChatProviderStateArgs {
|
||||
@@ -76,6 +94,17 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
||||
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
|
||||
});
|
||||
|
||||
/**
|
||||
* Backend-owned capability matrix keyed by provider. Drives the permission
|
||||
* mode picker (and is the extension point for future per-provider UI
|
||||
* differences) so the frontend stays free of hardcoded provider branching.
|
||||
* Null until `/api/providers/capabilities` resolves; the static fallback
|
||||
* map covers that window.
|
||||
*/
|
||||
const [providerCapabilities, setProviderCapabilities] = useState<
|
||||
Partial<Record<LLMProvider, ProviderCapabilities>> | null
|
||||
>(null);
|
||||
|
||||
const [providerModelCatalog, setProviderModelCatalog] = useState<
|
||||
Partial<Record<LLMProvider, ProviderModelsDefinition>>
|
||||
>({});
|
||||
@@ -181,6 +210,41 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
||||
void loadProviderModels();
|
||||
}, [loadProviderModels]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadCapabilities = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/providers/capabilities');
|
||||
const body = (await response.json()) as ProviderCapabilitiesApiResponse;
|
||||
if (cancelled || !body.success || !Array.isArray(body.data?.providers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const byProvider: Partial<Record<LLMProvider, ProviderCapabilities>> = {};
|
||||
for (const capabilities of body.data.providers) {
|
||||
byProvider[capabilities.provider] = capabilities;
|
||||
}
|
||||
setProviderCapabilities(byProvider);
|
||||
} catch (error) {
|
||||
console.error('Error loading provider capabilities:', error);
|
||||
}
|
||||
};
|
||||
|
||||
void loadCapabilities();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getPermissionModesForProvider = useCallback((targetProvider: LLMProvider): PermissionMode[] => {
|
||||
const capabilityModes = providerCapabilities?.[targetProvider]?.permissionModes;
|
||||
if (capabilityModes && capabilityModes.length > 0) {
|
||||
return capabilityModes as PermissionMode[];
|
||||
}
|
||||
return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default'];
|
||||
}, [providerCapabilities]);
|
||||
|
||||
const pickStoredOrCurrent = (
|
||||
storageKey: string,
|
||||
current: string,
|
||||
@@ -269,7 +333,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
||||
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
|
||||
const validModes = getPermissionModesForProvider(provider);
|
||||
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
|
||||
}, [selectedSession?.id, provider]);
|
||||
}, [selectedSession?.id, provider, getPermissionModesForProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
|
||||
@@ -327,7 +391,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
||||
if (selectedSession?.id) {
|
||||
localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
|
||||
}
|
||||
}, [permissionMode, provider, selectedSession?.id]);
|
||||
}, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]);
|
||||
|
||||
const selectProviderModel = useCallback(async (
|
||||
targetProvider: LLMProvider,
|
||||
|
||||
@@ -1,71 +1,34 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
|
||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
|
||||
import type { ServerEvent } from '../../../contexts/WebSocketContext';
|
||||
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
|
||||
import { playChatCompletionSound } from '../../../utils/notificationSound';
|
||||
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
||||
import type { PendingPermissionRequest } from '../types/types';
|
||||
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
|
||||
type PendingViewSession = {
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
type LatestChatMessage = {
|
||||
type?: string;
|
||||
kind?: string;
|
||||
data?: any;
|
||||
message?: any;
|
||||
delta?: string;
|
||||
sessionId?: string;
|
||||
session_id?: string;
|
||||
requestId?: string;
|
||||
toolName?: string;
|
||||
input?: unknown;
|
||||
context?: unknown;
|
||||
error?: string;
|
||||
tool?: any;
|
||||
toolId?: string;
|
||||
result?: any;
|
||||
exitCode?: number;
|
||||
isProcessing?: boolean;
|
||||
actualSessionId?: string;
|
||||
event?: string;
|
||||
status?: any;
|
||||
isNewSession?: boolean;
|
||||
resultText?: string;
|
||||
isError?: boolean;
|
||||
success?: boolean;
|
||||
reason?: string;
|
||||
provider?: string;
|
||||
content?: string;
|
||||
text?: string;
|
||||
tokens?: number;
|
||||
canInterrupt?: boolean;
|
||||
tokenBudget?: unknown;
|
||||
newSessionId?: string;
|
||||
aborted?: boolean;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
interface UseChatRealtimeHandlersArgs {
|
||||
latestMessage: LatestChatMessage | null;
|
||||
subscribe: (listener: (event: ServerEvent) => void) => () => void;
|
||||
provider: LLMProvider;
|
||||
selectedSession: ProjectSession | null;
|
||||
currentSessionId: string | null;
|
||||
setCurrentSessionId: (sessionId: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setCanAbortSession: (canAbort: boolean) => void;
|
||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
||||
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
||||
streamTimerRef: MutableRefObject<number | null>;
|
||||
accumulatedStreamRef: MutableRefObject<string>;
|
||||
onSessionInactive?: (sessionId?: string | null) => void;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
|
||||
/**
|
||||
* Highest live `seq` observed per session. Essential for reconnect catch-up:
|
||||
* `chat.subscribe` sends this value as `lastSeq` so the server replays only
|
||||
* the events this client actually missed. Written here on every sequenced
|
||||
* frame; read wherever a `chat.subscribe` is sent (session open, reconnect).
|
||||
*/
|
||||
lastSeqRef: MutableRefObject<Map<string, number>>;
|
||||
/** When each session's `chat.subscribe` was last sent; guards stale idle acks. */
|
||||
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
|
||||
onSessionProcessing?: MarkSessionProcessing;
|
||||
onSessionIdle?: MarkSessionIdle;
|
||||
onWebSocketReconnect?: () => void;
|
||||
sessionStore: SessionStore;
|
||||
}
|
||||
@@ -74,321 +37,259 @@ interface UseChatRealtimeHandlersArgs {
|
||||
/* Hook */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Routes server events into the session store and processing-state map.
|
||||
*
|
||||
* This is intentionally a thin reducer over the unified `kind`-based
|
||||
* protocol: every frame is keyed by the stable app session id, so there is
|
||||
* no session-id handoff, no provider branching, and no navigation here.
|
||||
* Sidebar events (`session_upserted`, `loading_progress`) are handled by
|
||||
* `useProjectsState`, not in this hook.
|
||||
*/
|
||||
export function useChatRealtimeHandlers({
|
||||
latestMessage,
|
||||
subscribe,
|
||||
provider,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setTokenBudget,
|
||||
setPendingPermissionRequests,
|
||||
pendingViewSessionRef,
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
lastSeqRef,
|
||||
statusCheckSentAtRef,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
onSessionIdle,
|
||||
onWebSocketReconnect,
|
||||
sessionStore,
|
||||
}: UseChatRealtimeHandlersArgs) {
|
||||
const paletteOps = usePaletteOps();
|
||||
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestMessage) return;
|
||||
if (lastProcessedMessageRef.current === latestMessage) return;
|
||||
lastProcessedMessageRef.current = latestMessage;
|
||||
const handleEvent = (msg: ServerEvent) => {
|
||||
if (!msg.kind) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeViewSessionId =
|
||||
selectedSession?.id || currentSessionId || null;
|
||||
const activeViewSessionId = selectedSession?.id || currentSessionId || null;
|
||||
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Legacy messages (no `kind` field) — handle and return */
|
||||
/* ---------------------------------------------------------------- */
|
||||
// Record replay progress for every sequenced live event.
|
||||
if (sid && typeof msg.seq === 'number') {
|
||||
const known = lastSeqRef.current.get(sid) ?? 0;
|
||||
if (msg.seq > known) {
|
||||
lastSeqRef.current.set(sid, msg.seq);
|
||||
}
|
||||
}
|
||||
|
||||
const msg = latestMessage as any;
|
||||
|
||||
if (!msg.kind) {
|
||||
const messageType = String(msg.type || '');
|
||||
|
||||
switch (messageType) {
|
||||
case 'websocket-reconnected':
|
||||
switch (msg.kind) {
|
||||
case 'websocket_reconnected':
|
||||
onWebSocketReconnect?.();
|
||||
return;
|
||||
|
||||
case 'pending-permissions-response': {
|
||||
const permSessionId = msg.sessionId;
|
||||
const isCurrentPermSession =
|
||||
permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id);
|
||||
if (permSessionId && !isCurrentPermSession) return;
|
||||
setPendingPermissionRequests(msg.data || []);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'session-status': {
|
||||
const statusSessionId = msg.sessionId;
|
||||
if (!statusSessionId) return;
|
||||
|
||||
const status = msg.status;
|
||||
if (status) {
|
||||
const statusInfo = {
|
||||
text: status.text || 'Working...',
|
||||
tokens: status.tokens || 0,
|
||||
can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
|
||||
};
|
||||
setClaudeStatus(statusInfo);
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(statusInfo.can_interrupt);
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy isProcessing format from check-session-status
|
||||
const isCurrentSession =
|
||||
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
|
||||
case 'chat_subscribed': {
|
||||
// Ack for chat.subscribe: authoritative processing state plus any
|
||||
// pending tool-permission prompts for the run.
|
||||
if (!sid) return;
|
||||
|
||||
if (msg.isProcessing) {
|
||||
onSessionActive?.(statusSessionId);
|
||||
onSessionProcessing?.(statusSessionId);
|
||||
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
|
||||
return;
|
||||
onSessionProcessing?.(sid);
|
||||
} else {
|
||||
// Idle ack: ignore it if a newer request started after the
|
||||
// subscribe was sent — the ack describes the older state.
|
||||
onSessionIdle?.(sid, {
|
||||
ifStartedBefore: statusCheckSentAtRef.current.get(sid),
|
||||
});
|
||||
}
|
||||
|
||||
onSessionInactive?.(statusSessionId);
|
||||
onSessionNotProcessing?.(statusSessionId);
|
||||
if (isCurrentSession) {
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
const isViewedSession = sid === activeViewSessionId;
|
||||
if (isViewedSession && Array.isArray(msg.pendingPermissions)) {
|
||||
setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
case 'protocol_error': {
|
||||
console.error('[Chat] Protocol error:', msg.code, msg.error);
|
||||
if (sid) {
|
||||
// Surface the failure in the conversation and stop the spinner —
|
||||
// the run never started (or was rejected), so no `complete` follows.
|
||||
onSessionIdle?.(sid);
|
||||
sessionStore.appendRealtime(sid, {
|
||||
id: `protocol_error_${Date.now()}`,
|
||||
sessionId: sid,
|
||||
timestamp: new Date().toISOString(),
|
||||
provider,
|
||||
kind: 'error',
|
||||
content: String(msg.error || 'Request failed'),
|
||||
} as NormalizedMessage);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Sidebar/global events — owned by useProjectsState.
|
||||
case 'session_upserted':
|
||||
case 'loading_progress':
|
||||
return;
|
||||
|
||||
default:
|
||||
// Unknown legacy message type — ignore
|
||||
return;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* NormalizedMessage handling (has `kind` field) */
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* -------------------------------------------------------------- */
|
||||
/* Provider NormalizedMessage handling */
|
||||
/* -------------------------------------------------------------- */
|
||||
|
||||
const sid = msg.sessionId || activeViewSessionId;
|
||||
|
||||
// --- Streaming: buffer for performance ---
|
||||
if (msg.kind === 'stream_delta') {
|
||||
const text = msg.content || '';
|
||||
if (!text) return;
|
||||
accumulatedStreamRef.current += text;
|
||||
if (!streamTimerRef.current) {
|
||||
streamTimerRef.current = window.setTimeout(() => {
|
||||
streamTimerRef.current = null;
|
||||
if (sid) {
|
||||
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
// Also route to store for non-active sessions
|
||||
if (sid && sid !== activeViewSessionId) {
|
||||
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.kind === 'stream_end') {
|
||||
if (streamTimerRef.current) {
|
||||
clearTimeout(streamTimerRef.current);
|
||||
streamTimerRef.current = null;
|
||||
}
|
||||
if (sid) {
|
||||
if (accumulatedStreamRef.current) {
|
||||
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
||||
// --- Streaming: buffer for performance ---
|
||||
if (msg.kind === 'stream_delta') {
|
||||
const text = (msg.content as string) || '';
|
||||
if (!text) return;
|
||||
accumulatedStreamRef.current += text;
|
||||
if (!streamTimerRef.current) {
|
||||
streamTimerRef.current = window.setTimeout(() => {
|
||||
streamTimerRef.current = null;
|
||||
if (sid) {
|
||||
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
sessionStore.finalizeStreaming(sid);
|
||||
}
|
||||
accumulatedStreamRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// --- All other messages: route to store ---
|
||||
const shouldPersist =
|
||||
msg.kind !== 'session_created'
|
||||
&& msg.kind !== 'complete'
|
||||
&& msg.kind !== 'status'
|
||||
&& msg.kind !== 'permission_request'
|
||||
&& msg.kind !== 'permission_cancelled';
|
||||
|
||||
if (sid && shouldPersist) {
|
||||
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
|
||||
}
|
||||
|
||||
// --- UI side effects for specific kinds ---
|
||||
switch (msg.kind) {
|
||||
case 'session_created': {
|
||||
const newSessionId = msg.newSessionId;
|
||||
if (!newSessionId) break;
|
||||
|
||||
// We no longer synthesize client-side placeholder IDs. Until the provider
|
||||
// announces `session_created`, the active id is expected to be null.
|
||||
if (!currentSessionId) {
|
||||
console.log('Session created with ID:', newSessionId);
|
||||
console.log('Existing session ID:', currentSessionId);
|
||||
setCurrentSessionId(newSessionId);
|
||||
setPendingPermissionRequests((prev) =>
|
||||
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
|
||||
);
|
||||
// Also route to store for non-active sessions
|
||||
if (sid && sid !== activeViewSessionId) {
|
||||
sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage);
|
||||
}
|
||||
pendingViewSessionRef.current = null;
|
||||
onSessionActive?.(newSessionId);
|
||||
onSessionProcessing?.(newSessionId);
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({
|
||||
text: 'Processing',
|
||||
tokens: 0,
|
||||
can_interrupt: true,
|
||||
});
|
||||
onNavigateToSession?.(newSessionId);
|
||||
break;
|
||||
return;
|
||||
}
|
||||
|
||||
case 'complete': {
|
||||
// Flush any remaining streaming state
|
||||
if (msg.kind === 'stream_end') {
|
||||
if (streamTimerRef.current) {
|
||||
clearTimeout(streamTimerRef.current);
|
||||
streamTimerRef.current = null;
|
||||
}
|
||||
if (sid && accumulatedStreamRef.current) {
|
||||
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
||||
if (sid) {
|
||||
if (accumulatedStreamRef.current) {
|
||||
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
||||
}
|
||||
sessionStore.finalizeStreaming(sid);
|
||||
}
|
||||
accumulatedStreamRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
setPendingPermissionRequests([]);
|
||||
onSessionInactive?.(sid);
|
||||
onSessionNotProcessing?.(sid);
|
||||
pendingViewSessionRef.current = null;
|
||||
// --- All other messages: route to store ---
|
||||
const shouldPersist =
|
||||
msg.kind !== 'complete'
|
||||
&& msg.kind !== 'status'
|
||||
&& msg.kind !== 'permission_request'
|
||||
&& msg.kind !== 'permission_cancelled';
|
||||
|
||||
if (sid && shouldPersist) {
|
||||
sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage);
|
||||
}
|
||||
|
||||
// --- UI side effects for specific kinds ---
|
||||
switch (msg.kind) {
|
||||
case 'complete': {
|
||||
// Flush any remaining streaming state
|
||||
if (streamTimerRef.current) {
|
||||
clearTimeout(streamTimerRef.current);
|
||||
streamTimerRef.current = null;
|
||||
}
|
||||
if (sid && accumulatedStreamRef.current) {
|
||||
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
||||
sessionStore.finalizeStreaming(sid);
|
||||
}
|
||||
accumulatedStreamRef.current = '';
|
||||
|
||||
// `complete` is the unified terminal event — every provider run ends
|
||||
// with exactly one, regardless of success, failure, or abort. The
|
||||
// indicator derives from the processing map, so deleting the entry
|
||||
// hides it immediately and atomically.
|
||||
onSessionIdle?.(sid);
|
||||
setPendingPermissionRequests([]);
|
||||
|
||||
if (msg.aborted) {
|
||||
// Abort was requested — the complete event confirms it. No
|
||||
// further UI action is needed beyond clearing the entry above.
|
||||
break;
|
||||
}
|
||||
|
||||
// Celebrate only successful runs (failed runs end with success: false).
|
||||
if (msg.success !== false) {
|
||||
showCompletionTitleIndicator();
|
||||
void playChatCompletionSound();
|
||||
}
|
||||
|
||||
// The session id is stable for the whole conversation (allocated
|
||||
// before the first send), so the only follow-up is syncing the
|
||||
// viewed conversation with the now-persisted transcript.
|
||||
if (sid && sid === activeViewSessionId) {
|
||||
void sessionStore.refreshFromServer(sid);
|
||||
}
|
||||
|
||||
// Handle aborted case
|
||||
if (msg.aborted) {
|
||||
// Abort was requested — the complete event confirms it
|
||||
// No special UI action needed beyond clearing loading state above
|
||||
// The backend already sent any abort-related messages
|
||||
break;
|
||||
}
|
||||
|
||||
const actualSessionId =
|
||||
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
||||
? msg.actualSessionId
|
||||
: null;
|
||||
const isVisibleSession =
|
||||
Boolean(
|
||||
sid
|
||||
&& sid === activeViewSessionId,
|
||||
);
|
||||
// 'error' is an informational message row, not a terminal event —
|
||||
// providers emit it for mid-run stderr output too. Run teardown is
|
||||
// always signalled by the unified 'complete' that follows.
|
||||
|
||||
if (actualSessionId && sid && actualSessionId !== sid) {
|
||||
sessionStore.replaceSessionId(sid, actualSessionId);
|
||||
|
||||
if (isVisibleSession) {
|
||||
setCurrentSessionId(actualSessionId);
|
||||
}
|
||||
|
||||
if (isVisibleSession) {
|
||||
onNavigateToSession?.(actualSessionId, { replace: true });
|
||||
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
onSessionInactive?.(sid);
|
||||
onSessionNotProcessing?.(sid);
|
||||
pendingViewSessionRef.current = null;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'permission_request': {
|
||||
if (!msg.requestId) break;
|
||||
setPendingPermissionRequests((prev) => {
|
||||
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
|
||||
return [...prev, {
|
||||
requestId: msg.requestId,
|
||||
toolName: msg.toolName || 'UnknownTool',
|
||||
input: msg.input,
|
||||
context: msg.context,
|
||||
sessionId: sid || null,
|
||||
receivedAt: new Date(),
|
||||
}];
|
||||
});
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'permission_cancelled': {
|
||||
if (msg.requestId) {
|
||||
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
if (msg.text === 'token_budget' && msg.tokenBudget) {
|
||||
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
|
||||
} else if (msg.text) {
|
||||
setClaudeStatus({
|
||||
text: msg.text,
|
||||
tokens: msg.tokens || 0,
|
||||
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
|
||||
case 'permission_request': {
|
||||
if (!msg.requestId) break;
|
||||
setPendingPermissionRequests((prev) => {
|
||||
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
|
||||
return [...prev, {
|
||||
requestId: msg.requestId as string,
|
||||
toolName: (msg.toolName as string) || 'UnknownTool',
|
||||
input: msg.input,
|
||||
context: msg.context,
|
||||
sessionId: sid || null,
|
||||
receivedAt: new Date(),
|
||||
}];
|
||||
});
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(msg.canInterrupt !== false);
|
||||
if (sid) {
|
||||
onSessionProcessing?.(sid);
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// text, tool_use, tool_result, thinking, interactive_prompt, task_notification
|
||||
// → already routed to store above, no UI side effects needed
|
||||
default:
|
||||
break;
|
||||
}
|
||||
case 'permission_cancelled': {
|
||||
if (msg.requestId) {
|
||||
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
if (msg.text === 'token_budget' && msg.tokenBudget) {
|
||||
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
|
||||
} else if (msg.text && sid) {
|
||||
onSessionProcessing?.(sid, {
|
||||
statusText: msg.text as string,
|
||||
canInterrupt: msg.canInterrupt !== false,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// text, tool_use, tool_result, thinking, interactive_prompt, task_notification
|
||||
// → already routed to store above, no UI side effects needed
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return subscribe(handleEvent);
|
||||
}, [
|
||||
latestMessage,
|
||||
subscribe,
|
||||
provider,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setTokenBudget,
|
||||
setPendingPermissionRequests,
|
||||
pendingViewSessionRef,
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
lastSeqRef,
|
||||
statusCheckSentAtRef,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
onSessionIdle,
|
||||
onWebSocketReconnect,
|
||||
sessionStore,
|
||||
paletteOps,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
import type { ChatMessage, Provider } from '../types/types';
|
||||
@@ -12,10 +13,6 @@ import { normalizedToChatMessages } from './useChatMessages';
|
||||
const MESSAGES_PER_PAGE = 20;
|
||||
const INITIAL_VISIBLE_MESSAGES = 100;
|
||||
|
||||
type PendingViewSession = {
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
interface UseChatSessionStateArgs {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
@@ -24,9 +21,13 @@ interface UseChatSessionStateArgs {
|
||||
autoScrollToBottom?: boolean;
|
||||
externalMessageUpdate?: number;
|
||||
newSessionTrigger?: number;
|
||||
processingSessions?: Set<string>;
|
||||
processingSessions?: SessionActivityMap;
|
||||
onSessionIdle?: MarkSessionIdle;
|
||||
resetStreamingState: () => void;
|
||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
||||
/** When each session's `chat.subscribe` was last sent; guards stale idle acks. */
|
||||
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
|
||||
/** Highest live seq observed per session; sent as `lastSeq` on subscribe. */
|
||||
lastSeqRef: MutableRefObject<Map<string, number>>;
|
||||
sessionStore: SessionStore;
|
||||
}
|
||||
|
||||
@@ -99,21 +100,20 @@ export function useChatSessionState({
|
||||
externalMessageUpdate,
|
||||
newSessionTrigger,
|
||||
processingSessions,
|
||||
onSessionIdle,
|
||||
resetStreamingState,
|
||||
pendingViewSessionRef,
|
||||
statusCheckSentAtRef,
|
||||
lastSeqRef,
|
||||
sessionStore,
|
||||
}: UseChatSessionStateArgs) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
|
||||
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
||||
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
|
||||
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
||||
const [totalMessages, setTotalMessages] = useState(0);
|
||||
const [canAbortSession, setCanAbortSession] = useState(false);
|
||||
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
||||
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
|
||||
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
|
||||
const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
|
||||
const [allMessagesLoaded, setAllMessagesLoaded] = useState(false);
|
||||
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
|
||||
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
|
||||
@@ -170,13 +170,8 @@ export function useChatSessionState({
|
||||
* - No coupling to unrelated external update signals.
|
||||
*/
|
||||
resetStreamingState();
|
||||
pendingViewSessionRef.current = null;
|
||||
setClaudeStatus(null);
|
||||
setCanAbortSession(false);
|
||||
setIsLoading(false);
|
||||
setCurrentSessionId(null);
|
||||
setPendingUserMessage(null);
|
||||
sessionStorage.removeItem('cursorSessionId');
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
@@ -204,13 +199,30 @@ export function useChatSessionState({
|
||||
clearTimeout(loadAllFinishedTimerRef.current);
|
||||
loadAllFinishedTimerRef.current = null;
|
||||
}
|
||||
}, [newSessionTrigger, pendingViewSessionRef, resetStreamingState]);
|
||||
}, [newSessionTrigger, onSessionIdle, resetStreamingState]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Derive processing state for the viewed session */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const activeSessionId = selectedSession?.id || currentSessionId || null;
|
||||
|
||||
// The activity indicator always reflects the latest status of the session
|
||||
// being viewed — never stale local UI state from the last time it was
|
||||
// open. Session ids are concrete before any send, so no pending
|
||||
// placeholder entry exists anymore.
|
||||
const sessionActivity = (activeSessionId && processingSessions?.get(activeSessionId)) || null;
|
||||
const isProcessing = sessionActivity !== null;
|
||||
const canAbortSession = isProcessing && sessionActivity.canInterrupt;
|
||||
|
||||
// Ref mirror so effects can read the latest map without re-running on
|
||||
// every activity transition.
|
||||
const processingSessionsRef = useRef(processingSessions);
|
||||
processingSessionsRef.current = processingSessions;
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Derive chatMessages from the store */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const activeSessionId = selectedSession?.id || currentSessionId || null;
|
||||
const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);
|
||||
const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null);
|
||||
|
||||
@@ -383,30 +395,61 @@ export function useChatSessionState({
|
||||
setIsUserScrolledUp(false);
|
||||
}, [selectedProject?.projectId, selectedSession?.id]);
|
||||
|
||||
// Initial scroll to bottom
|
||||
// Initial scroll to bottom — robust to lazy content reflow.
|
||||
// The previous implementation fired one scrollToBottom() at +200ms and
|
||||
// cleared the pending flag. When markdown blocks, code highlighting, or
|
||||
// images finished rendering after that window, scrollHeight grew but
|
||||
// nothing re-anchored the viewport, leaving the chat tab visually
|
||||
// "scrolled way up" with the latest assistant message off-screen.
|
||||
//
|
||||
// This version re-scrolls every animation frame while scrollHeight is
|
||||
// still growing, capped at ~1s (60 frames) or 3 consecutive stable
|
||||
// frames. Cancels cleanly on session change via the pending flag.
|
||||
useEffect(() => {
|
||||
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
|
||||
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
|
||||
pendingInitialScrollRef.current = false;
|
||||
if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200);
|
||||
if (searchScrollActiveRef.current) { pendingInitialScrollRef.current = false; return; }
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
let frame = 0;
|
||||
let lastHeight = 0;
|
||||
let stableCount = 0;
|
||||
let rafId = 0;
|
||||
|
||||
const tick = () => {
|
||||
if (!pendingInitialScrollRef.current || !scrollContainerRef.current) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
if (container.scrollHeight === lastHeight) {
|
||||
stableCount++;
|
||||
} else {
|
||||
stableCount = 0;
|
||||
lastHeight = container.scrollHeight;
|
||||
}
|
||||
frame++;
|
||||
if (stableCount < 3 && frame < 60) {
|
||||
rafId = requestAnimationFrame(tick);
|
||||
} else {
|
||||
pendingInitialScrollRef.current = false;
|
||||
}
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
||||
|
||||
// Main session loading effect — store-based
|
||||
useEffect(() => {
|
||||
if (!selectedSession || !selectedProject) {
|
||||
// A new provider run can be in flight before the router has a canonical
|
||||
// selectedSession. Keep the processing banner alive until complete/error.
|
||||
if (pendingViewSessionRef.current) {
|
||||
// A freshly created session can be mid-run before the router has a
|
||||
// canonical selectedSession (the URL effect synthesizes one on the
|
||||
// next render). Keep the active view intact instead of wiping it.
|
||||
if (currentSessionId && processingSessionsRef.current?.has(currentSessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetStreamingState();
|
||||
pendingViewSessionRef.current = null;
|
||||
setClaudeStatus(null);
|
||||
setCanAbortSession(false);
|
||||
setIsLoading(false);
|
||||
setCurrentSessionId(null);
|
||||
sessionStorage.removeItem('cursorSessionId');
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
@@ -426,9 +469,6 @@ export function useChatSessionState({
|
||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
||||
if (sessionChanged) {
|
||||
resetStreamingState();
|
||||
pendingViewSessionRef.current = null;
|
||||
setClaudeStatus(null);
|
||||
setCanAbortSession(false);
|
||||
}
|
||||
|
||||
// Reset pagination/scroll state
|
||||
@@ -447,17 +487,24 @@ export function useChatSessionState({
|
||||
|
||||
if (sessionChanged) {
|
||||
setTokenBudget(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setCurrentSessionId(selectedSession.id);
|
||||
if (provider === 'cursor') {
|
||||
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
||||
}
|
||||
|
||||
// Check session status
|
||||
// Subscribe to the session's live run (if any): the ack reconciles the
|
||||
// processing indicator, re-attaches a mid-flight stream to this socket,
|
||||
// and replays any live events missed since `lastSeq`. Recording the send
|
||||
// time lets the ack handler discard idle acks that a newer request has
|
||||
// since outdated.
|
||||
if (ws) {
|
||||
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });
|
||||
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
|
||||
sendMessage({
|
||||
type: 'chat.subscribe',
|
||||
sessions: [{
|
||||
sessionId: selectedSession.id,
|
||||
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
lastLoadedSessionKeyRef.current = sessionKey;
|
||||
@@ -481,11 +528,12 @@ export function useChatSessionState({
|
||||
setIsLoadingSessionMessages(false);
|
||||
});
|
||||
}, [
|
||||
pendingViewSessionRef,
|
||||
resetStreamingState,
|
||||
selectedProject,
|
||||
selectedSession?.id,
|
||||
sendMessage,
|
||||
statusCheckSentAtRef,
|
||||
lastSeqRef,
|
||||
ws,
|
||||
sessionStore,
|
||||
]);
|
||||
@@ -499,7 +547,7 @@ export function useChatSessionState({
|
||||
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||
|
||||
// Skip store refresh during active streaming
|
||||
if (!isLoading) {
|
||||
if (!isProcessing) {
|
||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||
provider: (selectedSession.__provider || provider) as LLMProvider,
|
||||
projectId: selectedProject.projectId,
|
||||
@@ -524,7 +572,7 @@ export function useChatSessionState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
sessionStore,
|
||||
isLoading,
|
||||
isProcessing,
|
||||
]);
|
||||
|
||||
// Search navigation target
|
||||
@@ -691,16 +739,6 @@ export function useChatSessionState({
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeViewSessionId = selectedSession?.id || currentSessionId;
|
||||
if (!activeViewSessionId || !processingSessions) return;
|
||||
const shouldBeProcessing = processingSessions.has(activeViewSessionId);
|
||||
if (shouldBeProcessing && !isLoading) {
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
}
|
||||
}, [currentSessionId, isLoading, processingSessions, selectedSession?.id]);
|
||||
|
||||
// "Load all" overlay
|
||||
const prevLoadingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -782,16 +820,15 @@ export function useChatSessionState({
|
||||
addMessage,
|
||||
clearMessages,
|
||||
rewindMessages,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
sessionActivity,
|
||||
isProcessing,
|
||||
canAbortSession,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
isLoadingSessionMessages,
|
||||
isLoadingMoreMessages,
|
||||
hasMoreMessages,
|
||||
totalMessages,
|
||||
canAbortSession,
|
||||
setCanAbortSession,
|
||||
isUserScrolledUp,
|
||||
setIsUserScrolledUp,
|
||||
tokenBudget,
|
||||
@@ -804,8 +841,6 @@ export function useChatSessionState({
|
||||
isLoadingAllMessages,
|
||||
loadAllJustFinished,
|
||||
showLoadAllOverlay,
|
||||
claudeStatus,
|
||||
setClaudeStatus,
|
||||
createDiff,
|
||||
scrollContainerRef,
|
||||
scrollToBottom,
|
||||
|
||||
@@ -393,7 +393,8 @@ export function useSlashCommands({
|
||||
return;
|
||||
}
|
||||
|
||||
const slashPattern = /^\/(\S*)$/;
|
||||
// Match / at start of input OR after whitespace, capturing the /word up to cursor.
|
||||
const slashPattern = /(?:^|\s)(\/\S*)$/;
|
||||
const match = textBeforeCursor.match(slashPattern);
|
||||
|
||||
if (!match) {
|
||||
@@ -401,8 +402,9 @@ export function useSlashCommands({
|
||||
return;
|
||||
}
|
||||
|
||||
const slashPos = 0;
|
||||
const query = match[1];
|
||||
// Compute actual position of / in the full input string.
|
||||
const slashPos = match.index! + (match[0].length - match[1].length);
|
||||
const query = match[1].slice(1); // strip leading /
|
||||
|
||||
setSlashPosition(slashPos);
|
||||
setShowCommandMenu(true);
|
||||
|
||||
@@ -564,11 +564,15 @@ export function shouldHideToolResult(toolName: string, toolResult: any): boolean
|
||||
|
||||
if (!config.result) return false;
|
||||
|
||||
// Hidden/success-only configs suppress noisy successful output, but errors
|
||||
// still need to be visible so failed tool calls are diagnosable.
|
||||
if (toolResult?.isError) return false;
|
||||
|
||||
// Always hidden
|
||||
if (config.result.hidden) return true;
|
||||
|
||||
// Hide on success only
|
||||
if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
|
||||
if (config.result.hideOnSuccess && toolResult) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type {
|
||||
MarkSessionIdle,
|
||||
MarkSessionProcessing,
|
||||
SessionActivityMap,
|
||||
} from '../../../hooks/useSessionProtection';
|
||||
|
||||
export type Provider = LLMProvider;
|
||||
|
||||
@@ -102,20 +107,24 @@ export type SessionNavigationOptions = {
|
||||
replace?: boolean;
|
||||
};
|
||||
|
||||
export type SessionEstablishedContext = {
|
||||
provider: LLMProvider;
|
||||
project: Project;
|
||||
summary?: string | null;
|
||||
};
|
||||
|
||||
export interface ChatInterfaceProps {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
ws: WebSocket | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
latestMessage: any;
|
||||
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
||||
onInputFocusChange?: (focused: boolean) => void;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
onSessionInactive?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||
processingSessions?: Set<string>;
|
||||
onSessionProcessing?: MarkSessionProcessing;
|
||||
onSessionIdle?: MarkSessionIdle;
|
||||
processingSessions?: SessionActivityMap;
|
||||
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||
onShowSettings?: () => void;
|
||||
autoExpandTools?: boolean;
|
||||
showRawParameters?: boolean;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||
import { useWebSocket } from '../../../contexts/WebSocketContext';
|
||||
import PermissionContext from '../../../contexts/PermissionContext';
|
||||
import { QuickSettingsPanel } from '../../quick-settings-panel';
|
||||
import type { ChatInterfaceProps, Provider } from '../types/types';
|
||||
@@ -17,24 +18,18 @@ import ChatComposer from './subcomponents/ChatComposer';
|
||||
import CommandResultModal from './subcomponents/CommandResultModal';
|
||||
|
||||
|
||||
type PendingViewSession = {
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
function ChatInterface({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
ws,
|
||||
sendMessage,
|
||||
latestMessage,
|
||||
onFileOpen,
|
||||
onInputFocusChange,
|
||||
onSessionActive,
|
||||
onSessionInactive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onSessionIdle,
|
||||
processingSessions,
|
||||
onNavigateToSession,
|
||||
onSessionEstablished,
|
||||
onShowSettings,
|
||||
autoExpandTools,
|
||||
showRawParameters,
|
||||
@@ -46,12 +41,19 @@ function ChatInterface({
|
||||
onShowAllTasks,
|
||||
}: ChatInterfaceProps) {
|
||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
||||
const { subscribe } = useWebSocket();
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const sessionStore = useSessionStore();
|
||||
const streamTimerRef = useRef<number | null>(null);
|
||||
const accumulatedStreamRef = useRef('');
|
||||
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
|
||||
// When each session's `chat.subscribe` was last sent; idle acks older than
|
||||
// a later local request are discarded as stale.
|
||||
const statusCheckSentAtRef = useRef(new Map<string, number>());
|
||||
// Highest live `seq` observed per session. Written by the realtime handler
|
||||
// on every sequenced frame, read whenever a `chat.subscribe` is sent so the
|
||||
// server replays only the events this client actually missed.
|
||||
const lastSeqRef = useRef(new Map<string, number>());
|
||||
|
||||
const resetStreamingState = useCallback(() => {
|
||||
if (streamTimerRef.current) {
|
||||
@@ -92,16 +94,15 @@ function ChatInterface({
|
||||
const {
|
||||
chatMessages,
|
||||
addMessage,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
sessionActivity,
|
||||
isProcessing,
|
||||
canAbortSession,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
isLoadingSessionMessages,
|
||||
isLoadingMoreMessages,
|
||||
hasMoreMessages,
|
||||
totalMessages,
|
||||
canAbortSession,
|
||||
setCanAbortSession,
|
||||
isUserScrolledUp,
|
||||
setIsUserScrolledUp,
|
||||
tokenBudget,
|
||||
@@ -114,8 +115,6 @@ function ChatInterface({
|
||||
isLoadingAllMessages,
|
||||
loadAllJustFinished,
|
||||
showLoadAllOverlay,
|
||||
claudeStatus,
|
||||
setClaudeStatus,
|
||||
createDiff,
|
||||
scrollContainerRef,
|
||||
scrollToBottom,
|
||||
@@ -130,19 +129,28 @@ function ChatInterface({
|
||||
externalMessageUpdate,
|
||||
newSessionTrigger,
|
||||
processingSessions,
|
||||
onSessionIdle,
|
||||
resetStreamingState,
|
||||
pendingViewSessionRef,
|
||||
statusCheckSentAtRef,
|
||||
lastSeqRef,
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
// Brand-new conversation: the composer allocated a stable session id via
|
||||
// the session gateway before the first send. Record it locally and put it
|
||||
// in the URL — this id never changes again, so there is no later handoff.
|
||||
const handleSessionEstablished = useCallback<NonNullable<ChatInterfaceProps['onSessionEstablished']>>((sessionId, context) => {
|
||||
setCurrentSessionId(sessionId);
|
||||
onSessionEstablished?.(sessionId, context);
|
||||
onNavigateToSession?.(sessionId);
|
||||
}, [setCurrentSessionId, onSessionEstablished, onNavigateToSession]);
|
||||
|
||||
const {
|
||||
input,
|
||||
setInput,
|
||||
textareaRef,
|
||||
inputHighlightRef,
|
||||
isTextareaExpanded,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
slashCommandsCount,
|
||||
filteredCommands,
|
||||
frequentCommands,
|
||||
@@ -180,6 +188,7 @@ function ChatInterface({
|
||||
isInputFocused: _isInputFocused,
|
||||
commandModalPayload,
|
||||
closeCommandModal,
|
||||
showCostModal,
|
||||
} = useChatComposerState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
@@ -192,66 +201,67 @@ function ChatInterface({
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
isLoading: isProcessing,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
sendMessage,
|
||||
sendByCtrlEnter,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionEstablished: handleSessionEstablished,
|
||||
onInputFocusChange,
|
||||
onFileOpen,
|
||||
onShowSettings,
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
addMessage,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setIsUserScrolledUp,
|
||||
setPendingPermissionRequests,
|
||||
});
|
||||
|
||||
// On WebSocket reconnect, re-fetch the current session's messages from the server
|
||||
// so missed streaming events are shown. Also reset isLoading.
|
||||
// On WebSocket reconnect, re-fetch the current session's messages from the
|
||||
// server so missed streaming events are shown, then re-subscribe — the
|
||||
// `chat_subscribed` ack restores or clears the activity indicator, replays
|
||||
// missed live events, and re-attaches a still-running stream to this socket.
|
||||
const handleWebSocketReconnect = useCallback(async () => {
|
||||
if (!selectedProject || !selectedSession) return;
|
||||
const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
|
||||
const providerVal =
|
||||
selectedSession.__provider
|
||||
|| (localStorage.getItem('selected-provider') as LLMProvider)
|
||||
|| 'claude';
|
||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||
provider: (selectedSession.__provider || providerVal) as LLMProvider,
|
||||
provider: providerVal as LLMProvider,
|
||||
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
});
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
}, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]);
|
||||
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
|
||||
sendMessage({
|
||||
type: 'chat.subscribe',
|
||||
sessions: [{
|
||||
sessionId: selectedSession.id,
|
||||
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
|
||||
}],
|
||||
});
|
||||
}, [selectedProject, selectedSession, sendMessage, sessionStore]);
|
||||
|
||||
useChatRealtimeHandlers({
|
||||
latestMessage,
|
||||
subscribe,
|
||||
provider,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setTokenBudget,
|
||||
setPendingPermissionRequests,
|
||||
pendingViewSessionRef,
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
lastSeqRef,
|
||||
statusCheckSentAtRef,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
onSessionIdle,
|
||||
onWebSocketReconnect: handleWebSocketReconnect,
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading || !canAbortSession) {
|
||||
if (!canAbortSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -268,7 +278,7 @@ function ChatInterface({
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
|
||||
};
|
||||
}, [canAbortSession, handleAbortSession, isLoading]);
|
||||
}, [canAbortSession, handleAbortSession]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -363,15 +373,13 @@ function ChatInterface({
|
||||
pendingPermissionRequests={pendingPermissionRequests}
|
||||
handlePermissionDecision={handlePermissionDecision}
|
||||
handleGrantToolPermission={handleGrantToolPermission}
|
||||
claudeStatus={claudeStatus}
|
||||
isLoading={isLoading}
|
||||
activity={sessionActivity}
|
||||
isLoading={isProcessing}
|
||||
onAbortSession={handleAbortSession}
|
||||
provider={provider}
|
||||
permissionMode={permissionMode}
|
||||
onModeSwitch={cyclePermissionMode}
|
||||
thinkingMode={thinkingMode}
|
||||
setThinkingMode={setThinkingMode}
|
||||
tokenBudget={tokenBudget}
|
||||
onShowTokenUsage={showCostModal}
|
||||
slashCommandsCount={slashCommandsCount}
|
||||
onToggleCommandMenu={handleToggleCommandMenu}
|
||||
hasInput={Boolean(input.trim())}
|
||||
|
||||
80
src/components/chat/view/subcomponents/ActivityIndicator.tsx
Normal file
80
src/components/chat/view/subcomponents/ActivityIndicator.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Shimmer } from '../../../../shared/view/ui';
|
||||
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
||||
|
||||
type ActivityIndicatorProps = {
|
||||
activity: SessionActivity | null;
|
||||
onAbort?: () => void;
|
||||
};
|
||||
|
||||
const ACTION_KEYS = [
|
||||
'claudeStatus.actions.thinking',
|
||||
'claudeStatus.actions.processing',
|
||||
'claudeStatus.actions.analyzing',
|
||||
'claudeStatus.actions.working',
|
||||
'claudeStatus.actions.computing',
|
||||
'claudeStatus.actions.reasoning',
|
||||
];
|
||||
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||
|
||||
/**
|
||||
* Minimal response-in-progress indicator, in the spirit of the inline status
|
||||
* lines in Claude Code / Codex / OpenCode: a shimmering activity label, the
|
||||
* elapsed time, and an interrupt affordance. Rendered only while the viewed
|
||||
* session has an entry in the processing map; it disappears the instant that
|
||||
* entry is removed.
|
||||
*/
|
||||
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const startedAt = activity?.startedAt ?? null;
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (startedAt === null) return;
|
||||
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
|
||||
update();
|
||||
const timer = setInterval(update, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [startedAt]);
|
||||
|
||||
if (!activity) return null;
|
||||
|
||||
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
||||
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
|
||||
.replace(/\.+$/, '');
|
||||
|
||||
const minutes = Math.floor(elapsedSeconds / 60);
|
||||
const seconds = elapsedSeconds % 60;
|
||||
const elapsedLabel = minutes < 1
|
||||
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
|
||||
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in mb-2 w-full duration-300">
|
||||
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1">
|
||||
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
|
||||
<Shimmer className="text-xs font-medium">{`${label}…`}</Shimmer>
|
||||
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
|
||||
|
||||
{activity.canInterrupt && onAbort && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
|
||||
>
|
||||
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
|
||||
<rect x="5" y="5" width="14" height="14" rx="2" />
|
||||
</svg>
|
||||
<span>{t('claudeStatus.stop', { defaultValue: 'Stop' })}</span>
|
||||
<kbd className="hidden rounded border border-border/60 px-1 text-[10px] text-muted-foreground/70 sm:inline-block">
|
||||
esc
|
||||
</kbd>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,23 +2,17 @@ import { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
ClipboardEvent,
|
||||
Dispatch,
|
||||
FormEvent,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
TouchEvent,
|
||||
} from 'react';
|
||||
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
|
||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
||||
import CommandMenu from './CommandMenu';
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
import ImageAttachment from './ImageAttachment';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
import ThinkingModeSelector from './ThinkingModeSelector';
|
||||
import TokenUsageSummary from './TokenUsageSummary';
|
||||
|
||||
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
||||
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputHeader,
|
||||
@@ -30,6 +24,12 @@ import {
|
||||
PromptInputSubmit,
|
||||
} from '../../../../shared/view/ui';
|
||||
|
||||
import CommandMenu from './CommandMenu';
|
||||
import ActivityIndicator from './ActivityIndicator';
|
||||
import ImageAttachment from './ImageAttachment';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
import TokenUsageSummary from './TokenUsageSummary';
|
||||
|
||||
interface MentionableFile {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -52,15 +52,13 @@ interface ChatComposerProps {
|
||||
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
|
||||
) => void;
|
||||
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
||||
claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
|
||||
activity: SessionActivity | null;
|
||||
isLoading: boolean;
|
||||
onAbortSession: () => void;
|
||||
provider: Provider | string;
|
||||
permissionMode: PermissionMode | string;
|
||||
onModeSwitch: () => void;
|
||||
thinkingMode: string;
|
||||
setThinkingMode: Dispatch<SetStateAction<string>>;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
onShowTokenUsage: () => void;
|
||||
slashCommandsCount: number;
|
||||
onToggleCommandMenu: () => void;
|
||||
hasInput: boolean;
|
||||
@@ -107,15 +105,13 @@ export default function ChatComposer({
|
||||
pendingPermissionRequests,
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
claudeStatus,
|
||||
activity,
|
||||
isLoading,
|
||||
onAbortSession,
|
||||
provider,
|
||||
permissionMode,
|
||||
onModeSwitch,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
tokenBudget,
|
||||
onShowTokenUsage,
|
||||
slashCommandsCount,
|
||||
onToggleCommandMenu,
|
||||
hasInput,
|
||||
@@ -176,12 +172,7 @@ export default function ChatComposer({
|
||||
return (
|
||||
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
|
||||
{!hasPendingPermissions && (
|
||||
<ClaudeStatus
|
||||
status={claudeStatus}
|
||||
isLoading={isLoading}
|
||||
onAbort={onAbortSession}
|
||||
provider={provider}
|
||||
/>
|
||||
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
|
||||
)}
|
||||
|
||||
{pendingPermissionRequests.length > 0 && (
|
||||
@@ -295,6 +286,7 @@ export default function ChatComposer({
|
||||
|
||||
<PromptInputTextarea
|
||||
ref={textareaRef}
|
||||
dir="auto"
|
||||
value={input}
|
||||
onChange={onInputChange}
|
||||
onClick={onTextareaClick}
|
||||
@@ -357,11 +349,7 @@ export default function ChatComposer({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{provider === 'claude' && (
|
||||
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
||||
)}
|
||||
|
||||
<TokenUsageSummary usage={tokenBudget} />
|
||||
<TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
|
||||
|
||||
<PromptInputButton
|
||||
tooltip={{ content: t('input.showAllCommands') }}
|
||||
@@ -382,7 +370,7 @@ export default function ChatComposer({
|
||||
<PromptInputButton
|
||||
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
|
||||
onClick={onClearInput}
|
||||
className="hidden sm:No-flex"
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<XIcon />
|
||||
</PromptInputButton>
|
||||
@@ -399,7 +387,8 @@ export default function ChatComposer({
|
||||
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
|
||||
</div>
|
||||
<PromptInputSubmit
|
||||
disabled={!input.trim() || isLoading}
|
||||
onClick={isLoading ? onAbortSession : undefined}
|
||||
disabled={!isLoading && !input.trim()}
|
||||
className="h-10 w-10 sm:h-10 sm:w-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
|
||||
type ClaudeStatusProps = {
|
||||
status: {
|
||||
text?: string;
|
||||
tokens?: number;
|
||||
can_interrupt?: boolean;
|
||||
} | null;
|
||||
onAbort?: () => void;
|
||||
isLoading: boolean;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
const ACTION_KEYS = [
|
||||
'claudeStatus.actions.thinking',
|
||||
'claudeStatus.actions.processing',
|
||||
'claudeStatus.actions.analyzing',
|
||||
'claudeStatus.actions.working',
|
||||
'claudeStatus.actions.computing',
|
||||
'claudeStatus.actions.reasoning',
|
||||
];
|
||||
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||
|
||||
const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
||||
claude: 'messageTypes.claude',
|
||||
codex: 'messageTypes.codex',
|
||||
cursor: 'messageTypes.cursor',
|
||||
gemini: 'messageTypes.gemini',
|
||||
opencode: 'messageTypes.opencode',
|
||||
};
|
||||
|
||||
function formatElapsedTime(totalSeconds: number) {
|
||||
const mins = Math.floor(totalSeconds / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
return mins < 1 ? `${secs}s` : `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
export default function ClaudeStatus({
|
||||
status,
|
||||
onAbort,
|
||||
isLoading,
|
||||
provider = 'claude',
|
||||
}: ClaudeStatusProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [dots, setDots] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setElapsedTime(0);
|
||||
return;
|
||||
}
|
||||
const startTime = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
|
||||
}, 1000);
|
||||
const dotTimer = setInterval(() => {
|
||||
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
clearInterval(dotTimer);
|
||||
};
|
||||
}, [isLoading]);
|
||||
|
||||
if (!isLoading && !status) return null;
|
||||
|
||||
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
||||
const statusText = (status?.text || actionWords[Math.floor(elapsedTime / 3) % actionWords.length]).replace(/[.]+$/, '');
|
||||
|
||||
const providerLabel = t(PROVIDER_LABEL_KEYS[provider] || 'claudeStatus.providers.assistant', { defaultValue: 'Assistant' });
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 mb-3 w-full duration-500">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between gap-3 overflow-hidden rounded-full border border-border/50 bg-slate-100 px-3 py-1.5 shadow-sm backdrop-blur-md dark:bg-slate-900">
|
||||
|
||||
{/* Left Side: Identity & Status */}
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/20 ring-1 ring-primary/10">
|
||||
<SessionProviderLogo provider={provider} className="h-3.5 w-3.5" />
|
||||
{isLoading && (
|
||||
<span className="absolute inset-0 animate-pulse rounded-full ring-2 ring-emerald-500/20" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col sm:flex-row sm:items-center sm:gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70">
|
||||
{providerLabel}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", isLoading ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{statusText}<span className="inline-block w-4 text-primary">{isLoading ? dots : ''}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Metrics & Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading && status?.can_interrupt !== false && onAbort && (
|
||||
<>
|
||||
<div className="hidden items-center rounded-md bg-muted/50 px-2 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground sm:flex">
|
||||
{formatElapsedTime(elapsedTime)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="group flex items-center gap-1.5 rounded-full bg-destructive/10 px-2.5 py-1 text-[10px] font-bold text-destructive transition-all hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<svg className="h-3 w-3 fill-current" viewBox="0 0 24 24">
|
||||
<path d="M6 6h12v12H6z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">STOP</span>
|
||||
<kbd className="hidden rounded bg-black/10 px-1 text-[9px] group-hover:bg-white/20 sm:block">
|
||||
ESC
|
||||
</kbd>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
import type {
|
||||
ChatMessage,
|
||||
@@ -8,10 +9,10 @@ import type {
|
||||
Provider,
|
||||
} from '../../types/types';
|
||||
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
||||
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
|
||||
|
||||
import { Markdown } from './Markdown';
|
||||
import MessageCopyControl from './MessageCopyControl';
|
||||
|
||||
@@ -41,10 +42,9 @@ type InteractiveOption = {
|
||||
isSelected: boolean;
|
||||
};
|
||||
|
||||
type PermissionGrantState = 'idle' | 'granted' | 'error';
|
||||
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
||||
|
||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
((prevMessage.type === 'assistant') ||
|
||||
@@ -53,8 +53,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
(prevMessage.type === 'error'));
|
||||
const messageRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
|
||||
const userCopyContent = String(message.content || '');
|
||||
const formattedMessageContent = useMemo(
|
||||
() => formatUsageLimitText(String(message.content || '')),
|
||||
@@ -73,10 +71,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
!message.isThinking;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setPermissionGrantState('idle');
|
||||
}, [permissionSuggestion?.entry, message.toolId]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = messageRef.current;
|
||||
if (!autoExpandTools || !node || !message.isToolUse) return;
|
||||
@@ -120,7 +114,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
/* User message bubble on the right */
|
||||
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
|
||||
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
|
||||
<div className="whitespace-pre-wrap break-words text-sm">
|
||||
<div dir="auto" className="whitespace-pre-wrap break-words text-sm">
|
||||
{message.content}
|
||||
</div>
|
||||
{message.images && message.images.length > 0 && (
|
||||
@@ -241,55 +235,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
||||
{String(message.toolResult.content || '')}
|
||||
</Markdown>
|
||||
{permissionSuggestion && (
|
||||
<div className="mt-4 border-t border-red-200/60 pt-3 dark:border-red-800/60">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!onGrantToolPermission) return;
|
||||
const result = onGrantToolPermission(permissionSuggestion);
|
||||
if (result?.success) {
|
||||
setPermissionGrantState('granted');
|
||||
} else {
|
||||
setPermissionGrantState('error');
|
||||
}
|
||||
}}
|
||||
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
|
||||
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||
? 'cursor-default border-green-300/70 bg-green-100 text-green-800 dark:border-green-800/60 dark:bg-green-900/30 dark:text-green-200'
|
||||
: 'border-red-300/70 bg-white/80 text-red-700 hover:bg-white dark:border-red-800/60 dark:bg-gray-900/40 dark:text-red-200 dark:hover:bg-gray-900/70'
|
||||
}`}
|
||||
>
|
||||
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||
? t('permissions.added')
|
||||
: t('permissions.grant', { tool: permissionSuggestion.toolName })}
|
||||
</button>
|
||||
{onShowSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
|
||||
className="text-xs text-red-700 underline hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
|
||||
>
|
||||
{t('permissions.openSettings')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
|
||||
{t('permissions.addTo', { entry: permissionSuggestion.entry })}
|
||||
</div>
|
||||
{permissionGrantState === 'error' && (
|
||||
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
||||
{t('permissions.error')}
|
||||
</div>
|
||||
)}
|
||||
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
|
||||
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
|
||||
{t('permissions.retry')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -405,7 +350,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
) : (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<div dir="auto" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{/* Reasoning accordion */}
|
||||
{showThinking && message.reasoning && (
|
||||
<Reasoning className="mb-3" defaultOpen={false}>
|
||||
|
||||
@@ -277,11 +277,15 @@ export default function ProviderSelectionEmptyState({
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">{model.label}</div>
|
||||
{model.description && (
|
||||
{/*
|
||||
// * Temporarly commented out because the description of models from claude
|
||||
// * was a bit inconsistent. Will return it back when it becomes more consistent.
|
||||
*/}
|
||||
{/* {model.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{model.description}
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
||||
@@ -321,6 +325,7 @@ export default function ProviderSelectionEmptyState({
|
||||
|
||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground/60">
|
||||
<Trans
|
||||
ns="chat"
|
||||
i18nKey="providerSelection.pressToSearch"
|
||||
values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }}
|
||||
components={{
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import { useState, useRef, useEffect, useCallback, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Brain, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { thinkingModes } from '../../constants/thinkingModes';
|
||||
|
||||
type ThinkingModeSelectorProps = {
|
||||
selectedMode: string;
|
||||
onModeChange: (modeId: string) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
|
||||
|
||||
// Mapping from mode ID to translation key
|
||||
const modeKeyMap: Record<string, string> = {
|
||||
'think-hard': 'thinkHard',
|
||||
'think-harder': 'thinkHarder'
|
||||
};
|
||||
// Create translated modes for display
|
||||
const translatedModes = thinkingModes.map(mode => {
|
||||
const modeKey = modeKeyMap[mode.id] || mode.id;
|
||||
return {
|
||||
...mode,
|
||||
name: t(`thinkingMode.modes.${modeKey}.name`),
|
||||
description: t(`thinkingMode.modes.${modeKey}.description`),
|
||||
prefix: t(`thinkingMode.modes.${modeKey}.prefix`)
|
||||
};
|
||||
});
|
||||
|
||||
const closeDropdown = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
if (!trigger || !dropdown || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const viewportPadding = window.innerWidth < 640 ? 12 : 16;
|
||||
const spacing = 8;
|
||||
const width = Math.min(window.innerWidth - viewportPadding * 2, window.innerWidth < 640 ? 320 : 256);
|
||||
let left = triggerRect.left + triggerRect.width / 2 - width / 2;
|
||||
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - width - viewportPadding));
|
||||
|
||||
const measuredHeight = dropdown.offsetHeight || 0;
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom - spacing - viewportPadding;
|
||||
const spaceAbove = triggerRect.top - spacing - viewportPadding;
|
||||
const openBelow = spaceBelow >= Math.min(measuredHeight || 320, 320) || spaceBelow >= spaceAbove;
|
||||
const availableHeight = Math.min(
|
||||
window.innerHeight - viewportPadding * 2,
|
||||
Math.max(180, openBelow ? spaceBelow : spaceAbove),
|
||||
);
|
||||
const panelHeight = Math.min(measuredHeight || availableHeight, availableHeight);
|
||||
const top = openBelow
|
||||
? Math.min(triggerRect.bottom + spacing, window.innerHeight - viewportPadding - panelHeight)
|
||||
: Math.max(viewportPadding, triggerRect.top - spacing - panelHeight);
|
||||
|
||||
setDropdownStyle({
|
||||
position: 'fixed',
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
maxHeight: availableHeight,
|
||||
zIndex: 80,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setDropdownStyle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rafId = window.requestAnimationFrame(updateDropdownPosition);
|
||||
const handleViewportChange = () => updateDropdownPosition();
|
||||
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
window.addEventListener('scroll', handleViewportChange, true);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
window.removeEventListener('scroll', handleViewportChange, true);
|
||||
};
|
||||
}, [isOpen, updateDropdownPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, closeDropdown]);
|
||||
|
||||
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
|
||||
const IconComponent = currentMode.icon || Brain;
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={containerRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
}}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'
|
||||
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
|
||||
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
|
||||
}`}
|
||||
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<IconComponent className={`h-5 w-5 ${currentMode.color}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={dropdownStyle || { position: 'fixed', top: 0, left: 0, visibility: 'hidden' }}
|
||||
className="flex flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
>
|
||||
<div className="border-b border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t('thinkingMode.selector.title')}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeDropdown}
|
||||
className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('thinkingMode.selector.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto py-1">
|
||||
{translatedModes.map((mode) => {
|
||||
const ModeIcon = mode.icon;
|
||||
const isSelected = mode.id === selectedMode;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onModeChange(mode.id);
|
||||
closeDropdown();
|
||||
}}
|
||||
className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-0.5 ${mode.icon ? mode.color : 'text-gray-400'}`}>
|
||||
{ModeIcon ? <ModeIcon className="h-5 w-5" /> : <div className="h-5 w-5" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-medium ${isSelected ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{mode.name}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||||
{t('thinkingMode.selector.active')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{mode.description}
|
||||
</p>
|
||||
{mode.prefix && (
|
||||
<code className="mt-1 inline-block rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-700">
|
||||
{mode.prefix}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<strong>Tip:</strong> {t('thinkingMode.selector.tip')}
|
||||
</p>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThinkingModeSelector;
|
||||
@@ -2,6 +2,7 @@ import { ActivityIcon } from 'lucide-react';
|
||||
|
||||
type TokenUsageSummaryProps = {
|
||||
usage: Record<string, unknown> | null;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const formatTokenCount = (value: number) => {
|
||||
@@ -29,7 +30,7 @@ const readUsageNumber = (value: unknown) => {
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
|
||||
export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryProps) {
|
||||
const breakdown =
|
||||
usage?.breakdown && typeof usage.breakdown === 'object'
|
||||
? usage.breakdown as Record<string, unknown>
|
||||
@@ -39,15 +40,18 @@ export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
|
||||
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground sm:gap-2 sm:px-2.5"
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5"
|
||||
title={`${usedTokens.toLocaleString()} tokens used`}
|
||||
aria-label="Show token usage"
|
||||
>
|
||||
<span className="grid h-5 w-5 place-items-center rounded-md bg-primary/10 text-primary">
|
||||
<ActivityIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span>
|
||||
<span className="hidden text-muted-foreground/70 sm:inline">tokens</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function EditorSidebar({
|
||||
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`flex h-full min-w-0 flex-shrink-0 ${editorExpanded ? 'flex-1' : ''}`}>
|
||||
<div ref={containerRef} className={`flex h-full min-w-0 ${editorExpanded ? 'flex-1' : ''}`}>
|
||||
{!editorExpanded && (
|
||||
<div
|
||||
ref={resizeHandleRef}
|
||||
|
||||
@@ -6,6 +6,14 @@ export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';
|
||||
|
||||
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
|
||||
|
||||
export const MAX_FILE_UPLOAD_SIZE_MB = 200;
|
||||
|
||||
export const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
|
||||
|
||||
export const MAX_FILE_UPLOAD_SIZE_LABEL = `${MAX_FILE_UPLOAD_SIZE_MB}MB`;
|
||||
|
||||
export const MAX_FILE_UPLOAD_COUNT = 20;
|
||||
|
||||
export const IMAGE_FILE_EXTENSIONS = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useCallback, useState, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
import { IS_PLATFORM } from '../../../constants/config';
|
||||
import type { Project } from '../../../types/app';
|
||||
import { api } from '../../../utils/api';
|
||||
import {
|
||||
MAX_FILE_UPLOAD_COUNT,
|
||||
MAX_FILE_UPLOAD_SIZE_BYTES,
|
||||
MAX_FILE_UPLOAD_SIZE_LABEL,
|
||||
} from '../constants/constants';
|
||||
|
||||
type UseFileTreeUploadOptions = {
|
||||
selectedProject: Project | null;
|
||||
@@ -8,6 +15,141 @@ type UseFileTreeUploadOptions = {
|
||||
showToast: (message: string, type: 'success' | 'error') => void;
|
||||
};
|
||||
|
||||
export type FileTreeUploadProgressState = {
|
||||
status: 'uploading' | 'complete' | 'error';
|
||||
progress: number;
|
||||
fileCount: number;
|
||||
uploadedCount?: number;
|
||||
fileName?: string;
|
||||
targetPath?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type UploadResponse = {
|
||||
error?: string;
|
||||
message?: string;
|
||||
files?: unknown[];
|
||||
uploadedCount?: number;
|
||||
requestedFileCount?: number;
|
||||
};
|
||||
|
||||
const COMPLETE_PROGRESS_CLEAR_DELAY_MS = 1400;
|
||||
const ERROR_PROGRESS_CLEAR_DELAY_MS = 3200;
|
||||
|
||||
const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files');
|
||||
|
||||
const getRelativePath = (file: File) => {
|
||||
const fileWithRelativePath = file as File & { webkitRelativePath?: string };
|
||||
return fileWithRelativePath.webkitRelativePath || file.name;
|
||||
};
|
||||
|
||||
const getFileDisplayName = (file: File) => {
|
||||
const relativePath = getRelativePath(file);
|
||||
return relativePath.split(/[\\/]/).pop() || file.name;
|
||||
};
|
||||
|
||||
const validateFilesForUpload = (files: File[]): string | null => {
|
||||
if (files.length > MAX_FILE_UPLOAD_COUNT) {
|
||||
return `You can upload up to ${MAX_FILE_UPLOAD_COUNT} files at once.`;
|
||||
}
|
||||
|
||||
const oversizedFile = files.find((file) => file.size > MAX_FILE_UPLOAD_SIZE_BYTES);
|
||||
if (oversizedFile) {
|
||||
return `${getFileDisplayName(oversizedFile)} is larger than ${MAX_FILE_UPLOAD_SIZE_LABEL}.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseUploadResponse = (xhr: XMLHttpRequest): UploadResponse => {
|
||||
if (!xhr.responseText) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(xhr.responseText) as UploadResponse;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const formatUploadSuccessMessage = (uploadedCount: number, requestedFileCount: number) => {
|
||||
if (uploadedCount !== requestedFileCount) {
|
||||
return `Uploaded ${uploadedCount} of ${requestedFileCount} ${pluralizeFiles(requestedFileCount)}`;
|
||||
}
|
||||
|
||||
return `Uploaded ${uploadedCount} ${pluralizeFiles(uploadedCount)} successfully`;
|
||||
};
|
||||
|
||||
const buildUploadFormData = (files: File[], targetPath: string) => {
|
||||
const formData = new FormData();
|
||||
const relativePaths: string[] = [];
|
||||
|
||||
formData.append('targetPath', targetPath);
|
||||
formData.append('requestedFileCount', String(files.length));
|
||||
|
||||
files.forEach((file) => {
|
||||
const relativePath = getRelativePath(file);
|
||||
const cleanFile = new File([file], relativePath.split(/[\\/]/).pop() || file.name, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
});
|
||||
|
||||
formData.append('files', cleanFile);
|
||||
relativePaths.push(relativePath);
|
||||
});
|
||||
|
||||
formData.append('relativePaths', JSON.stringify(relativePaths));
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
const uploadFormDataWithProgress = (
|
||||
projectId: string,
|
||||
formData: FormData,
|
||||
onProgress: (progress: number) => void,
|
||||
) =>
|
||||
new Promise<UploadResponse>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open('POST', `/api/projects/${encodeURIComponent(projectId)}/files/upload`);
|
||||
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!IS_PLATFORM && token) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!event.lengthComputable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep 100% for the server response so the UI can distinguish transfer
|
||||
// completion from the final write/refresh step.
|
||||
onProgress(Math.min(99, Math.round((event.loaded / event.total) * 100)));
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
const refreshedToken = xhr.getResponseHeader('X-Refreshed-Token');
|
||||
if (refreshedToken) {
|
||||
localStorage.setItem('auth-token', refreshedToken);
|
||||
}
|
||||
|
||||
const payload = parseUploadResponse(xhr);
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(payload.error || payload.message || `Upload failed with status ${xhr.status}`));
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error('Upload failed. Check your connection and try again.'));
|
||||
xhr.onabort = () => reject(new Error('Upload canceled.'));
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
// Helper function to read all files from a directory entry recursively
|
||||
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
|
||||
const files: File[] = [];
|
||||
@@ -57,6 +199,48 @@ const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry,
|
||||
return files;
|
||||
};
|
||||
|
||||
const collectDroppedFiles = async (dataTransfer: DataTransfer) => {
|
||||
const files: File[] = [];
|
||||
|
||||
// Use DataTransferItemList for folder support
|
||||
const { items } = dataTransfer;
|
||||
if (items) {
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind !== 'file') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
||||
if (!entry) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
files.push(file);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
(entry as FileSystemFileEntry).file(resolve, reject);
|
||||
});
|
||||
files.push(file);
|
||||
} else if (entry.isDirectory) {
|
||||
// Pass the directory name as basePath so files include the folder path
|
||||
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
|
||||
files.push(...dirFiles);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// Fallback for browsers that don't support webkitGetAsEntry
|
||||
for (const file of Array.from(dataTransfer.files)) {
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
export const useFileTreeUpload = ({
|
||||
selectedProject,
|
||||
onRefresh,
|
||||
@@ -65,20 +249,150 @@ export const useFileTreeUpload = ({
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<FileTreeUploadProgressState | null>(null);
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
const clearProgressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
const clearProgressTimer = useCallback(() => {
|
||||
if (clearProgressTimerRef.current) {
|
||||
clearTimeout(clearProgressTimerRef.current);
|
||||
clearProgressTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleProgressClear = useCallback(
|
||||
(delay: number) => {
|
||||
clearProgressTimer();
|
||||
clearProgressTimerRef.current = setTimeout(() => {
|
||||
setUploadProgress(null);
|
||||
clearProgressTimerRef.current = null;
|
||||
}, delay);
|
||||
},
|
||||
[clearProgressTimer],
|
||||
);
|
||||
|
||||
useEffect(() => clearProgressTimer, [clearProgressTimer]);
|
||||
|
||||
const setUploadError = useCallback(
|
||||
(message: string, fileCount: number, targetPath = '', fileName?: string, progress = 0) => {
|
||||
setUploadProgress({
|
||||
status: 'error',
|
||||
progress,
|
||||
fileCount,
|
||||
fileName,
|
||||
targetPath,
|
||||
error: message,
|
||||
});
|
||||
scheduleProgressClear(ERROR_PROGRESS_CLEAR_DELAY_MS);
|
||||
},
|
||||
[scheduleProgressClear],
|
||||
);
|
||||
|
||||
const uploadFiles = useCallback(
|
||||
async (files: File[], targetPath = '') => {
|
||||
if (files.length === 0) {
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = files.length === 1 ? getFileDisplayName(files[0]) : undefined;
|
||||
|
||||
if (!selectedProject) {
|
||||
const message = 'Select a project before uploading files.';
|
||||
showToast(message, 'error');
|
||||
setUploadError(message, files.length, targetPath, fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateFilesForUpload(files);
|
||||
if (validationError) {
|
||||
showToast(validationError, 'error');
|
||||
setUploadError(validationError, files.length, targetPath, fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
clearProgressTimer();
|
||||
setOperationLoading(true);
|
||||
setUploadProgress({
|
||||
status: 'uploading',
|
||||
progress: 0,
|
||||
fileCount: files.length,
|
||||
fileName,
|
||||
targetPath,
|
||||
});
|
||||
|
||||
let latestProgress = 0;
|
||||
|
||||
try {
|
||||
const response = await uploadFormDataWithProgress(
|
||||
selectedProject.projectId,
|
||||
buildUploadFormData(files, targetPath),
|
||||
(progress) => {
|
||||
latestProgress = progress;
|
||||
setUploadProgress((current) =>
|
||||
current && current.status === 'uploading'
|
||||
? { ...current, progress }
|
||||
: current,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const uploadedCount =
|
||||
typeof response.uploadedCount === 'number' ? response.uploadedCount : response.files?.length ?? files.length;
|
||||
const requestedFileCount =
|
||||
typeof response.requestedFileCount === 'number' ? response.requestedFileCount : files.length;
|
||||
|
||||
setUploadProgress({
|
||||
status: 'complete',
|
||||
progress: 100,
|
||||
fileCount: requestedFileCount,
|
||||
uploadedCount,
|
||||
fileName,
|
||||
targetPath,
|
||||
});
|
||||
|
||||
showToast(formatUploadSuccessMessage(uploadedCount, requestedFileCount), 'success');
|
||||
scheduleProgressClear(COMPLETE_PROGRESS_CLEAR_DELAY_MS);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Upload failed';
|
||||
console.error('Upload error:', err);
|
||||
showToast(message, 'error');
|
||||
setUploadError(message, files.length, targetPath, fileName, latestProgress);
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
setDropTarget(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
clearProgressTimer,
|
||||
onRefresh,
|
||||
scheduleProgressClear,
|
||||
selectedProject,
|
||||
setUploadError,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
async (fileList: FileList | File[]) => {
|
||||
await uploadFiles(Array.from(fileList), '');
|
||||
},
|
||||
[uploadFiles],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
const handleDragOver = useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const handleDragLeave = useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Only set isDragOver to false if we're leaving the entire tree
|
||||
@@ -88,103 +402,35 @@ export const useFileTreeUpload = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
const handleDrop = useCallback(
|
||||
async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
const targetPath = dropTarget || '';
|
||||
setOperationLoading(true);
|
||||
const targetPath = dropTarget || '';
|
||||
|
||||
try {
|
||||
const files: File[] = [];
|
||||
|
||||
// Use DataTransferItemList for folder support
|
||||
const items = e.dataTransfer.items;
|
||||
if (items) {
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
||||
|
||||
if (entry) {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
(entry as FileSystemFileEntry).file(resolve, reject);
|
||||
});
|
||||
files.push(file);
|
||||
} else if (entry.isDirectory) {
|
||||
// Pass the directory name as basePath so files include the folder path
|
||||
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
|
||||
files.push(...dirFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback for browsers that don't support webkitGetAsEntry
|
||||
const fileList = e.dataTransfer.files;
|
||||
for (const file of Array.from(fileList)) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
setOperationLoading(false);
|
||||
try {
|
||||
const files = await collectDroppedFiles(e.dataTransfer);
|
||||
await uploadFiles(files, targetPath);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Could not read dropped files';
|
||||
console.error('Upload error:', err);
|
||||
showToast(message, 'error');
|
||||
setUploadError(message, 0, targetPath);
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[dropTarget, setUploadError, showToast, uploadFiles],
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('targetPath', targetPath);
|
||||
|
||||
// Store relative paths separately since FormData strips path info from File.name
|
||||
const relativePaths: string[] = [];
|
||||
files.forEach((file) => {
|
||||
// Create a new file with just the filename (without path) for FormData
|
||||
// but store the relative path separately
|
||||
const cleanFile = new File([file], file.name.split('/').pop()!, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
});
|
||||
formData.append('files', cleanFile);
|
||||
relativePaths.push(file.name); // Keep the full relative path
|
||||
});
|
||||
|
||||
// Send relative paths as a JSON array
|
||||
formData.append('relativePaths', JSON.stringify(relativePaths));
|
||||
|
||||
const response = await api.post(
|
||||
// File upload endpoint is keyed by DB projectId post-migration.
|
||||
`/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`,
|
||||
formData
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
showToast(
|
||||
`Uploaded ${files.length} file(s)`,
|
||||
'success'
|
||||
);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err);
|
||||
showToast(err instanceof Error ? err.message : 'Upload failed', 'error');
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
setDropTarget(null);
|
||||
}
|
||||
}, [dropTarget, selectedProject, onRefresh, showToast]);
|
||||
|
||||
const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => {
|
||||
const handleItemDragOver = useCallback((e: DragEvent, itemPath: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDropTarget(itemPath);
|
||||
}, []);
|
||||
|
||||
const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => {
|
||||
const handleItemDrop = useCallback((e: DragEvent, itemPath: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDropTarget(itemPath);
|
||||
@@ -194,7 +440,9 @@ export const useFileTreeUpload = ({
|
||||
isDragOver,
|
||||
dropTarget,
|
||||
operationLoading,
|
||||
uploadProgress,
|
||||
treeRef,
|
||||
handleFileSelect,
|
||||
handleDragEnter,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
|
||||
import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
|
||||
@@ -13,10 +14,12 @@ import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
|
||||
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
|
||||
import { Project } from '../../../types/app';
|
||||
import { ScrollArea, Input } from '../../../shared/view/ui';
|
||||
|
||||
import FileTreeBody from './FileTreeBody';
|
||||
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
|
||||
import FileTreeHeader from './FileTreeHeader';
|
||||
import FileTreeLoadingState from './FileTreeLoadingState';
|
||||
import FileTreeUploadProgress from './FileTreeUploadProgress';
|
||||
import ImageViewer from './ImageViewer';
|
||||
|
||||
|
||||
@@ -66,6 +69,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
||||
onRefresh: refreshFiles,
|
||||
showToast,
|
||||
});
|
||||
const operationLoading = operations.operationLoading || upload.operationLoading;
|
||||
|
||||
// Focus input when creating new item
|
||||
useEffect(() => {
|
||||
@@ -146,14 +150,19 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
||||
onViewModeChange={changeViewMode}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onUploadFiles={upload.handleFileSelect}
|
||||
onNewFile={() => operations.handleStartCreate('', 'file')}
|
||||
onNewFolder={() => operations.handleStartCreate('', 'directory')}
|
||||
onRefresh={refreshFiles}
|
||||
onCollapseAll={collapseAll}
|
||||
loading={loading}
|
||||
operationLoading={operations.operationLoading}
|
||||
operationLoading={operationLoading}
|
||||
isUploading={upload.uploadProgress?.status === 'uploading'}
|
||||
uploadProgress={upload.uploadProgress?.progress ?? null}
|
||||
/>
|
||||
|
||||
<FileTreeUploadProgress upload={upload.uploadProgress} />
|
||||
|
||||
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
|
||||
|
||||
<ScrollArea className="flex-1 px-2 py-1">
|
||||
@@ -184,7 +193,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
||||
}, 100);
|
||||
}}
|
||||
className="h-6 flex-1 text-sm"
|
||||
disabled={operations.operationLoading}
|
||||
disabled={operationLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -213,7 +222,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
||||
handleConfirmRename={operations.handleConfirmRename}
|
||||
handleCancelRename={operations.handleCancelRename}
|
||||
renameInputRef={renameInputRef}
|
||||
operationLoading={operations.operationLoading}
|
||||
operationLoading={operationLoading}
|
||||
/>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -251,17 +260,17 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={operations.handleCancelDelete}
|
||||
disabled={operations.operationLoading}
|
||||
disabled={operationLoading}
|
||||
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={operations.handleConfirmDelete}
|
||||
disabled={operations.operationLoading}
|
||||
disabled={operationLoading}
|
||||
className="flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{operations.operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('fileTree.delete.confirm', 'Delete')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { ChevronDown, Eye, FileText, FolderPlus, List, Loader2, RefreshCw, Search, TableProperties, Upload, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Input } from '../../../shared/view/ui';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { MAX_FILE_UPLOAD_SIZE_LABEL } from '../constants/constants';
|
||||
import type { FileTreeViewMode } from '../types/types';
|
||||
|
||||
type FileTreeHeaderProps = {
|
||||
@@ -12,11 +16,14 @@ type FileTreeHeaderProps = {
|
||||
// Toolbar actions
|
||||
onNewFile?: () => void;
|
||||
onNewFolder?: () => void;
|
||||
onUploadFiles?: (files: FileList) => void;
|
||||
onRefresh?: () => void;
|
||||
onCollapseAll?: () => void;
|
||||
// Loading state
|
||||
loading?: boolean;
|
||||
operationLoading?: boolean;
|
||||
isUploading?: boolean;
|
||||
uploadProgress?: number | null;
|
||||
};
|
||||
|
||||
export default function FileTreeHeader({
|
||||
@@ -26,12 +33,24 @@ export default function FileTreeHeader({
|
||||
onSearchQueryChange,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onUploadFiles,
|
||||
onRefresh,
|
||||
onCollapseAll,
|
||||
loading,
|
||||
operationLoading,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
}: FileTreeHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUploadInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { files } = event.target;
|
||||
if (files && files.length > 0) {
|
||||
onUploadFiles?.(files);
|
||||
}
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-b border-border px-3 pb-2 pt-3">
|
||||
@@ -40,6 +59,50 @@ export default function FileTreeHeader({
|
||||
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Action buttons */}
|
||||
{onUploadFiles && (
|
||||
<>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUploadInputChange}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative h-7 w-7 p-0"
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
title={
|
||||
isUploading
|
||||
? t('fileTree.uploadingFiles', 'Uploading files')
|
||||
: t('fileTree.uploadFiles', 'Upload files (max {{size}} each)', {
|
||||
size: MAX_FILE_UPLOAD_SIZE_LABEL,
|
||||
})
|
||||
}
|
||||
aria-label={t('fileTree.uploadFiles', 'Upload files (max {{size}} each)', {
|
||||
size: MAX_FILE_UPLOAD_SIZE_LABEL,
|
||||
})}
|
||||
disabled={operationLoading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isUploading && typeof uploadProgress === 'number' && (
|
||||
<span className="absolute bottom-0.5 left-1/2 h-0.5 w-4 -translate-x-1/2 overflow-hidden rounded-full bg-primary/20">
|
||||
<span
|
||||
className="block h-full rounded-full bg-primary transition-[width] duration-150"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{onNewFile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
90
src/components/file-tree/view/FileTreeUploadProgress.tsx
Normal file
90
src/components/file-tree/view/FileTreeUploadProgress.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { AlertCircle, CheckCircle2, Upload } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { FileTreeUploadProgressState } from '../hooks/useFileTreeUpload';
|
||||
|
||||
type FileTreeUploadProgressProps = {
|
||||
upload: FileTreeUploadProgressState | null;
|
||||
};
|
||||
|
||||
const clampProgress = (progress: number) => Math.min(100, Math.max(0, progress));
|
||||
|
||||
const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files');
|
||||
|
||||
export default function FileTreeUploadProgress({ upload }: FileTreeUploadProgressProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!upload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const progress = clampProgress(upload.progress);
|
||||
const isUploading = upload.status === 'uploading';
|
||||
const isComplete = upload.status === 'complete';
|
||||
const isError = upload.status === 'error';
|
||||
const fileSummary =
|
||||
upload.fileCount === 1 && upload.fileName
|
||||
? upload.fileName
|
||||
: `${upload.fileCount} ${pluralizeFiles(upload.fileCount)}`;
|
||||
|
||||
const title = isUploading
|
||||
? t('fileTree.uploadingFiles', 'Uploading files')
|
||||
: isComplete
|
||||
? t('fileTree.uploadComplete', 'Upload complete')
|
||||
: t('fileTree.uploadFailed', 'Upload failed');
|
||||
|
||||
const detail = isError
|
||||
? upload.error
|
||||
: isComplete && typeof upload.uploadedCount === 'number'
|
||||
? t('fileTree.uploadedCount', 'Uploaded {{uploaded}} of {{total}} {{label}}', {
|
||||
uploaded: upload.uploadedCount,
|
||||
total: upload.fileCount,
|
||||
label: pluralizeFiles(upload.fileCount),
|
||||
})
|
||||
: fileSummary;
|
||||
|
||||
const Icon = isError ? AlertCircle : isComplete ? CheckCircle2 : Upload;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-b px-3 py-2 transition-colors',
|
||||
isError
|
||||
? 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||
: isComplete
|
||||
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||
: 'border-primary/20 bg-primary/10 text-foreground',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-[36px] items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md',
|
||||
isError ? 'bg-red-500/15' : isComplete ? 'bg-emerald-500/15' : 'bg-primary/15',
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-3.5 w-3.5', isUploading && 'animate-pulse')} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate text-xs font-medium">{title}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{isUploading ? `${progress}%` : isComplete ? t('common.done', 'Done') : t('common.failed', 'Failed')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[11px] text-muted-foreground">{detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-background/80">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-[width] duration-200',
|
||||
isError ? 'bg-red-500' : isComplete ? 'bg-emerald-500' : 'bg-primary',
|
||||
)}
|
||||
style={{ width: `${isError ? Math.max(progress, 8) : progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { AppTab, Project, ProjectSession } from '../../../types/app';
|
||||
import type { SessionNavigationOptions } from '../../chat/types/types';
|
||||
|
||||
export type SessionLifecycleHandler = (sessionId?: string | null) => void;
|
||||
import type {
|
||||
MarkSessionIdle,
|
||||
MarkSessionProcessing,
|
||||
SessionActivityMap,
|
||||
} from '../../../hooks/useSessionProtection';
|
||||
import type { SessionEstablishedContext, SessionNavigationOptions } from '../../chat/types/types';
|
||||
|
||||
export type TaskMasterTask = {
|
||||
id: string | number;
|
||||
@@ -41,17 +44,15 @@ export type MainContentProps = {
|
||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||
ws: WebSocket | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
latestMessage: unknown;
|
||||
isMobile: boolean;
|
||||
onMenuClick: () => void;
|
||||
isLoading: boolean;
|
||||
onInputFocusChange: (focused: boolean) => void;
|
||||
onSessionActive: SessionLifecycleHandler;
|
||||
onSessionInactive: SessionLifecycleHandler;
|
||||
onSessionProcessing: SessionLifecycleHandler;
|
||||
onSessionNotProcessing: SessionLifecycleHandler;
|
||||
processingSessions: Set<string>;
|
||||
onSessionProcessing: MarkSessionProcessing;
|
||||
onSessionIdle: MarkSessionIdle;
|
||||
processingSessions: SessionActivityMap;
|
||||
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||
onSessionEstablished: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||
onShowSettings: () => void;
|
||||
externalMessageUpdate: number;
|
||||
newSessionTrigger: number;
|
||||
|
||||
@@ -37,17 +37,15 @@ function MainContent({
|
||||
setActiveTab,
|
||||
ws,
|
||||
sendMessage,
|
||||
latestMessage,
|
||||
isMobile,
|
||||
onMenuClick,
|
||||
isLoading,
|
||||
onInputFocusChange,
|
||||
onSessionActive,
|
||||
onSessionInactive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onSessionIdle,
|
||||
processingSessions,
|
||||
onNavigateToSession,
|
||||
onSessionEstablished,
|
||||
onShowSettings,
|
||||
externalMessageUpdate,
|
||||
newSessionTrigger,
|
||||
@@ -128,15 +126,13 @@ function MainContent({
|
||||
selectedSession={selectedSession}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
latestMessage={latestMessage}
|
||||
onFileOpen={handleFileOpen}
|
||||
onInputFocusChange={onInputFocusChange}
|
||||
onSessionActive={onSessionActive}
|
||||
onSessionInactive={onSessionInactive}
|
||||
onSessionProcessing={onSessionProcessing}
|
||||
onSessionNotProcessing={onSessionNotProcessing}
|
||||
onSessionIdle={onSessionIdle}
|
||||
processingSessions={processingSessions}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
onSessionEstablished={onSessionEstablished}
|
||||
onShowSettings={onShowSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
|
||||
@@ -26,6 +26,7 @@ const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-start
|
||||
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
|
||||
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
|
||||
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
|
||||
const PRISM_CLOUDCLI_PLUGIN_URL = 'https://github.com/jakeefr/cloudcli-plugin-prism';
|
||||
|
||||
type PluginRecommendation = {
|
||||
id: string;
|
||||
@@ -72,6 +73,14 @@ const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
|
||||
icon: Clock,
|
||||
source: 'unofficial',
|
||||
},
|
||||
{
|
||||
id: 'prism',
|
||||
translationKey: 'prismCloudCLI',
|
||||
repoUrl: PRISM_CLOUDCLI_PLUGIN_URL,
|
||||
installedNames: ['prism'],
|
||||
icon: Activity,
|
||||
source: 'unofficial'
|
||||
}
|
||||
];
|
||||
|
||||
function repoSlug(repoUrl: string) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { setNotificationSoundEnabled } from '../../../utils/notificationSound';
|
||||
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
|
||||
import {
|
||||
DEFAULT_CODE_EDITOR_SETTINGS,
|
||||
@@ -107,6 +109,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
||||
channels: {
|
||||
inApp: true,
|
||||
webPush: false,
|
||||
sound: true,
|
||||
},
|
||||
events: {
|
||||
actionRequired: true,
|
||||
@@ -115,6 +118,25 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
||||
},
|
||||
});
|
||||
|
||||
const normalizeNotificationPreferences = (
|
||||
preferences?: Partial<NotificationPreferencesState> | null,
|
||||
): NotificationPreferencesState => {
|
||||
const defaults = createDefaultNotificationPreferences();
|
||||
|
||||
return {
|
||||
channels: {
|
||||
inApp: preferences?.channels?.inApp ?? defaults.channels.inApp,
|
||||
webPush: preferences?.channels?.webPush ?? defaults.channels.webPush,
|
||||
sound: preferences?.channels?.sound ?? defaults.channels.sound,
|
||||
},
|
||||
events: {
|
||||
actionRequired: preferences?.events?.actionRequired ?? defaults.events.actionRequired,
|
||||
stop: preferences?.events?.stop ?? defaults.events.stop,
|
||||
error: preferences?.events?.error ?? defaults.events.error,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export function useSettingsController({ isOpen, initialTab }: UseSettingsControllerArgs) {
|
||||
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
@@ -186,7 +208,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
if (notificationResponse.ok) {
|
||||
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
|
||||
if (notificationData.success && notificationData.preferences) {
|
||||
setNotificationPreferences(notificationData.preferences);
|
||||
setNotificationPreferences(normalizeNotificationPreferences(notificationData.preferences));
|
||||
} else {
|
||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||
}
|
||||
@@ -301,6 +323,10 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
void refreshProviderAuthStatuses();
|
||||
}, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationSoundEnabled(notificationPreferences.channels.sound);
|
||||
}, [notificationPreferences.channels.sound]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
|
||||
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { LLMProvider } from '../../../types/app';
|
||||
import type { ProviderAuthStatus } from '../../provider-auth/types';
|
||||
|
||||
@@ -29,6 +30,7 @@ export type NotificationPreferencesState = {
|
||||
channels: {
|
||||
inApp: boolean;
|
||||
webPush: boolean;
|
||||
sound: boolean;
|
||||
};
|
||||
events: {
|
||||
actionRequired: boolean;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Bell, BellOff, BellRing, Loader2 } from 'lucide-react';
|
||||
import { Bell, BellOff, BellRing, Loader2, Play, Volume2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '../../../../shared/view/ui';
|
||||
import { playChatCompletionSound } from '../../../../utils/notificationSound';
|
||||
import type { NotificationPreferencesState } from '../../types/types';
|
||||
|
||||
type NotificationsSettingsTabProps = {
|
||||
@@ -82,6 +85,54 @@ export default function NotificationsSettingsTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="h-4 w-4 text-blue-600" />
|
||||
<h4 className="font-medium text-foreground">
|
||||
{t('notifications.sound.title', { defaultValue: 'Sound' })}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('notifications.sound.description', {
|
||||
defaultValue: 'Play a short tone when a chat run finishes.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex shrink-0 items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPreferences.channels.sound}
|
||||
onChange={(event) =>
|
||||
onNotificationPreferencesChange({
|
||||
...notificationPreferences,
|
||||
channels: {
|
||||
...notificationPreferences.channels,
|
||||
sound: event.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{t('notifications.sound.enabled', { defaultValue: 'Enabled' })}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void playChatCompletionSound({ force: true });
|
||||
}}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{t('notifications.sound.test', { defaultValue: 'Test sound' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
||||
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import type { FitAddon } from '@xterm/addon-fit';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
import { TERMINAL_INIT_DELAY_MS } from '../constants/constants';
|
||||
import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket';
|
||||
@@ -31,8 +32,8 @@ type UseShellConnectionResult = {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
closeSocket: () => void;
|
||||
connectToShell: () => void;
|
||||
disconnectFromShell: () => void;
|
||||
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
||||
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
||||
};
|
||||
|
||||
export function useShellConnection({
|
||||
@@ -54,6 +55,8 @@ export function useShellConnection({
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const connectingRef = useRef(false);
|
||||
const forceRestartOnInitRef = useRef(false);
|
||||
const suppressAutoConnectRef = useRef(false);
|
||||
|
||||
const handleProcessCompletion = useCallback(
|
||||
(output: string) => {
|
||||
@@ -141,6 +144,8 @@ export function useShellConnection({
|
||||
}
|
||||
|
||||
currentFitAddon.fit();
|
||||
const forceRestart = forceRestartOnInitRef.current;
|
||||
forceRestartOnInitRef.current = false;
|
||||
|
||||
sendSocketMessage(socket, {
|
||||
type: 'init',
|
||||
@@ -152,6 +157,7 @@ export function useShellConnection({
|
||||
rows: currentTerminal.rows,
|
||||
initialCommand: initialCommandRef.current,
|
||||
isPlainShell: isPlainShellRef.current,
|
||||
forceRestart,
|
||||
});
|
||||
}, TERMINAL_INIT_DELAY_MS);
|
||||
};
|
||||
@@ -177,6 +183,7 @@ export function useShellConnection({
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
connectingRef.current = false;
|
||||
forceRestartOnInitRef.current = false;
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -195,27 +202,40 @@ export function useShellConnection({
|
||||
],
|
||||
);
|
||||
|
||||
const connectToShell = useCallback(() => {
|
||||
const connectToShell = useCallback((options?: { forceRestart?: boolean }) => {
|
||||
if (!isInitialized || isConnected || isConnecting || connectingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
forceRestartOnInitRef.current = Boolean(options?.forceRestart);
|
||||
suppressAutoConnectRef.current = false;
|
||||
connectingRef.current = true;
|
||||
setIsConnecting(true);
|
||||
connectWebSocket(true);
|
||||
}, [connectWebSocket, isConnected, isConnecting, isInitialized]);
|
||||
|
||||
const disconnectFromShell = useCallback(() => {
|
||||
const disconnectFromShell = useCallback((options?: { suppressAutoConnect?: boolean }) => {
|
||||
if (options?.suppressAutoConnect) {
|
||||
suppressAutoConnectRef.current = true;
|
||||
}
|
||||
|
||||
closeSocket();
|
||||
clearTerminalScreen();
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
connectingRef.current = false;
|
||||
forceRestartOnInitRef.current = false;
|
||||
setAuthUrl('');
|
||||
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoConnect || !isInitialized || isConnecting || isConnected) {
|
||||
if (
|
||||
!autoConnect ||
|
||||
suppressAutoConnectRef.current ||
|
||||
!isInitialized ||
|
||||
isConnecting ||
|
||||
isConnected
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MutableRefObject, RefObject } from 'react';
|
||||
import type { FitAddon } from '@xterm/addon-fit';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
|
||||
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
|
||||
@@ -15,6 +16,7 @@ export type ShellInitMessage = {
|
||||
rows: number;
|
||||
initialCommand: string | null | undefined;
|
||||
isPlainShell: boolean;
|
||||
forceRestart?: boolean;
|
||||
};
|
||||
|
||||
export type ShellResizeMessage = {
|
||||
@@ -69,8 +71,8 @@ export type UseShellRuntimeResult = {
|
||||
isConnecting: boolean;
|
||||
authUrl: string;
|
||||
authUrlVersion: number;
|
||||
connectToShell: () => void;
|
||||
disconnectFromShell: () => void;
|
||||
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
||||
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
||||
openAuthUrlInBrowser: (url?: string) => boolean;
|
||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { useShellRuntime } from '../hooks/useShellRuntime';
|
||||
import { sendSocketMessage } from '../utils/socket';
|
||||
import { getSessionDisplayName } from '../utils/auth';
|
||||
|
||||
import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
|
||||
import ShellEmptyState from './subcomponents/ShellEmptyState';
|
||||
import ShellHeader from './subcomponents/ShellHeader';
|
||||
@@ -46,6 +48,8 @@ export default function Shell({
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [cliPromptOptions, setCliPromptOptions] = useState<CliPromptOption[] | null>(null);
|
||||
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const restartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const restartAfterInitRef = useRef(false);
|
||||
const onOutputRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const {
|
||||
@@ -140,6 +144,7 @@ export default function Shell({
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current);
|
||||
if (restartTimerRef.current) clearTimeout(restartTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -190,12 +195,42 @@ export default function Shell({
|
||||
);
|
||||
|
||||
const handleRestartShell = useCallback(() => {
|
||||
restartAfterInitRef.current = true;
|
||||
setIsRestarting(true);
|
||||
window.setTimeout(() => {
|
||||
if (restartTimerRef.current) {
|
||||
clearTimeout(restartTimerRef.current);
|
||||
}
|
||||
restartTimerRef.current = setTimeout(() => {
|
||||
setIsRestarting(false);
|
||||
restartTimerRef.current = null;
|
||||
}, SHELL_RESTART_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
const handleDisconnectShell = useCallback(() => {
|
||||
restartAfterInitRef.current = false;
|
||||
if (restartTimerRef.current) {
|
||||
clearTimeout(restartTimerRef.current);
|
||||
restartTimerRef.current = null;
|
||||
}
|
||||
setIsRestarting(false);
|
||||
disconnectFromShell({ suppressAutoConnect: true });
|
||||
}, [disconnectFromShell]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!restartAfterInitRef.current ||
|
||||
isRestarting ||
|
||||
!isInitialized ||
|
||||
isConnected ||
|
||||
isConnecting
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
restartAfterInitRef.current = false;
|
||||
connectToShell({ forceRestart: true });
|
||||
}, [connectToShell, isConnected, isConnecting, isInitialized, isRestarting]);
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<ShellEmptyState
|
||||
@@ -254,7 +289,7 @@ export default function Shell({
|
||||
isRestarting={isRestarting}
|
||||
hasSession={Boolean(selectedSession)}
|
||||
sessionDisplayNameShort={sessionDisplayNameShort}
|
||||
onDisconnect={disconnectFromShell}
|
||||
onDisconnect={handleDisconnectShell}
|
||||
onRestart={handleRestartShell}
|
||||
statusNewSessionText={t('shell.status.newSession')}
|
||||
statusInitializingText={t('shell.status.initializing')}
|
||||
@@ -263,7 +298,7 @@ export default function Shell({
|
||||
disconnectTitle={t('shell.actions.disconnectTitle')}
|
||||
restartLabel={t('shell.actions.restart')}
|
||||
restartTitle={t('shell.actions.restartTitle')}
|
||||
disableRestart={isRestarting || isConnected}
|
||||
disableRestart={isRestarting || !isInitialized}
|
||||
/>
|
||||
|
||||
<div className="relative flex-1 overflow-hidden p-2">
|
||||
@@ -281,7 +316,7 @@ export default function Shell({
|
||||
connectLabel={t('shell.actions.connect')}
|
||||
connectTitle={t('shell.actions.connectTitle')}
|
||||
connectingLabel={t('shell.connecting')}
|
||||
onConnect={connectToShell}
|
||||
onConnect={handleRestartShell}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Loader2, RotateCcw } from 'lucide-react';
|
||||
|
||||
type ShellConnectionOverlayProps = {
|
||||
mode: 'loading' | 'connect' | 'connecting';
|
||||
description: string;
|
||||
@@ -19,40 +21,42 @@ export default function ShellConnectionOverlay({
|
||||
}: ShellConnectionOverlayProps) {
|
||||
if (mode === 'loading') {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
|
||||
<div className="text-white">{loadingLabel}</div>
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90">
|
||||
<div className="inline-flex items-center gap-2 text-sm font-medium text-gray-100">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-300" aria-hidden="true" />
|
||||
<span>{loadingLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'connect') {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||
<div className="w-full max-w-sm text-center">
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6">
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnect}
|
||||
className="flex w-full items-center justify-center space-x-2 rounded-lg bg-green-600 px-6 py-3 text-base font-medium text-white transition-colors hover:bg-green-700 sm:w-auto"
|
||||
className="pointer-events-auto inline-flex min-h-12 w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-md bg-emerald-600 px-5 py-3 text-base font-semibold text-white shadow-lg shadow-emerald-950/30 transition-colors hover:bg-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-300 focus:ring-offset-2 focus:ring-offset-gray-950 active:bg-emerald-700"
|
||||
title={connectTitle}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>{connectLabel}</span>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate">{connectLabel}</span>
|
||||
</button>
|
||||
<p className="mt-3 px-2 text-sm text-gray-400">{description}</p>
|
||||
<p className="max-w-md break-words px-2 text-sm leading-6 text-gray-300">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||
<div className="w-full max-w-sm text-center">
|
||||
<div className="flex items-center justify-center space-x-3 text-yellow-400">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6">
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center">
|
||||
<div className="flex items-center justify-center gap-3 text-yellow-300">
|
||||
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
|
||||
<span className="text-base font-medium">{connectingLabel}</span>
|
||||
</div>
|
||||
<p className="mt-3 px-2 text-sm text-gray-400">{description}</p>
|
||||
<p className="max-w-md break-words px-2 text-sm leading-6 text-gray-300">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { RotateCcw, X } from 'lucide-react';
|
||||
|
||||
type ShellHeaderProps = {
|
||||
isConnected: boolean;
|
||||
isInitialized: boolean;
|
||||
@@ -50,34 +52,27 @@ export default function ShellHeader({
|
||||
{isRestarting && <span className="text-xs text-blue-400">{statusRestartingText}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDisconnect}
|
||||
className="flex items-center space-x-1 rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md bg-red-600 px-3 text-xs font-medium text-white transition-colors hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400/70 focus:ring-offset-2 focus:ring-offset-gray-800"
|
||||
title={disconnectTitle}
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{disconnectLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestart}
|
||||
disabled={disableRestart}
|
||||
className="flex items-center space-x-1 text-xs text-gray-400 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-gray-600/80 bg-gray-700/70 px-3 text-xs font-medium text-gray-100 transition-colors hover:border-blue-400/70 hover:bg-blue-600/80 hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-400/70 focus:ring-offset-2 focus:ring-offset-gray-800 disabled:cursor-not-allowed disabled:border-transparent disabled:bg-transparent disabled:text-gray-500 disabled:opacity-60"
|
||||
title={restartTitle}
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<RotateCcw className={`h-3.5 w-3.5 ${isRestarting ? 'animate-spin' : ''}`} aria-hidden="true" />
|
||||
<span>{restartLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TFunction } from 'i18next';
|
||||
import { api } from '../../../utils/api';
|
||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionActivityMap } from '../../../hooks/useSessionProtection';
|
||||
import type {
|
||||
ArchivedProjectListItem,
|
||||
ArchivedSessionListItem,
|
||||
@@ -81,6 +82,7 @@ type UseSidebarControllerArgs = {
|
||||
projects: Project[];
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
activeSessions: SessionActivityMap;
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
t: TFunction;
|
||||
@@ -100,6 +102,7 @@ export function useSidebarController({
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedSession: _selectedSession,
|
||||
activeSessions,
|
||||
isLoading,
|
||||
isMobile,
|
||||
t,
|
||||
@@ -146,6 +149,8 @@ export function useSidebarController({
|
||||
const onRefreshRef = useRef(onRefresh);
|
||||
|
||||
const isSidebarCollapsed = !isMobile && !sidebarVisible;
|
||||
const activeSessionIds = useMemo(() => new Set(activeSessions.keys()), [activeSessions]);
|
||||
const runningSessionsCount = activeSessionIds.size;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
@@ -582,9 +587,48 @@ export function useSidebarController({
|
||||
[projectSortOrder, projectsWithResolvedStarState],
|
||||
);
|
||||
|
||||
const runningProjects = useMemo(() => {
|
||||
if (activeSessionIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return sortedProjects.reduce<Project[]>((acc, project) => {
|
||||
const sessions = (project.sessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const cursorSessions = (project.cursorSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const codexSessions = (project.codexSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const geminiSessions = (project.geminiSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const opencodeSessions = (project.opencodeSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const runningCount =
|
||||
sessions.length
|
||||
+ cursorSessions.length
|
||||
+ codexSessions.length
|
||||
+ geminiSessions.length
|
||||
+ opencodeSessions.length;
|
||||
|
||||
if (runningCount === 0) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push({
|
||||
...project,
|
||||
sessions,
|
||||
cursorSessions,
|
||||
codexSessions,
|
||||
geminiSessions,
|
||||
opencodeSessions,
|
||||
sessionMeta: {
|
||||
...project.sessionMeta,
|
||||
total: runningCount,
|
||||
hasMore: false,
|
||||
},
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
}, [activeSessionIds, sortedProjects]);
|
||||
|
||||
const filteredProjects = useMemo(
|
||||
() => filterProjects(sortedProjects, debouncedSearchQuery),
|
||||
[debouncedSearchQuery, sortedProjects],
|
||||
() => filterProjects(searchMode === 'running' ? runningProjects : sortedProjects, debouncedSearchQuery),
|
||||
[debouncedSearchQuery, runningProjects, searchMode, sortedProjects],
|
||||
);
|
||||
|
||||
const filteredArchivedSessions = useMemo(() => {
|
||||
@@ -914,6 +958,7 @@ export function useSidebarController({
|
||||
sessionDeleteConfirmation,
|
||||
showVersionModal,
|
||||
filteredProjects,
|
||||
runningSessionsCount,
|
||||
archivedProjects: filteredArchivedProjects,
|
||||
archivedSessions: filteredArchivedSessions,
|
||||
archivedSessionsCount: archivedProjects.length + archivedSessions.length,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionActivityMap } from '../../../hooks/useSessionProtection';
|
||||
|
||||
export type ProjectSortOrder = 'name' | 'date';
|
||||
export type SidebarSearchMode = 'projects' | 'conversations' | 'archived';
|
||||
export type SidebarSearchMode = 'projects' | 'conversations' | 'running' | 'archived';
|
||||
export type ArchivedProjectListItem = Project & { isArchived: true };
|
||||
|
||||
export type SessionWithProvider = ProjectSession & {
|
||||
@@ -40,6 +41,7 @@ export type SidebarProps = {
|
||||
projects: Project[];
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
activeSessions: SessionActivityMap;
|
||||
onProjectSelect: (project: Project) => void;
|
||||
onSessionSelect: (session: ProjectSession) => void;
|
||||
onNewSession: (project: Project) => void;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user