mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-21 08:32:04 +08:00
Compare commits
2 Commits
feat/unifi
...
feature/im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04edf7d9c4 | ||
|
|
ed9f0d74aa |
87
CHANGELOG.md
87
CHANGELOG.md
@@ -3,93 +3,6 @@
|
|||||||
All notable changes to CloudCLI UI will be documented in this file.
|
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
|
|
||||||
|
|
||||||
* add opencode support ([#762](https://github.com/siteboon/claudecodeui/issues/762)) ([374e9de](https://github.com/siteboon/claudecodeui/commit/374e9de71934c41ce2c19c796e35a19234b240ec))
|
|
||||||
* **sidebar:** tooltip for the active-session indicator dot ([#782](https://github.com/siteboon/claudecodeui/issues/782)) ([27e509a](https://github.com/siteboon/claudecodeui/commit/27e509a9b8bb25c35ae0abbda44c536e15c332c8))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **chat:** prevent double send on mobile by removing redundant submit handlers ([#719](https://github.com/siteboon/claudecodeui/issues/719)) ([dbc41dc](https://github.com/siteboon/claudecodeui/commit/dbc41dc91dbf1fb54f92f5536d64646b4e924f31))
|
|
||||||
* preserve WebSocket frame type in plugin proxy ([#594](https://github.com/siteboon/claudecodeui/issues/594)) ([36b860e](https://github.com/siteboon/claudecodeui/commit/36b860e322454df62ebf5309018590b596e6b913)), closes [CoderLuii/HolyClaude#11](https://github.com/CoderLuii/HolyClaude/issues/11)
|
|
||||||
* refine token usage reporting ([#807](https://github.com/siteboon/claudecodeui/issues/807)) ([38bf21d](https://github.com/siteboon/claudecodeui/commit/38bf21ddf554ed28676d86b5221c25adf6f07afd))
|
|
||||||
* refresh Claude auth status after login flow ([#617](https://github.com/siteboon/claudecodeui/issues/617)) ([1e125f3](https://github.com/siteboon/claudecodeui/commit/1e125f3db5248399cd50dc3d40b1f8f44cf7ccb6))
|
|
||||||
* **sidebar:** keep session rename input visible while editing ([#781](https://github.com/siteboon/claudecodeui/issues/781)) ([951f587](https://github.com/siteboon/claudecodeui/commit/951f58751c152fbbb3f8b3ce3c814c06c061de18))
|
|
||||||
|
|
||||||
### Styling
|
|
||||||
|
|
||||||
* fix project star button location by replacing folder icon ([#793](https://github.com/siteboon/claudecodeui/issues/793)) ([295bad9](https://github.com/siteboon/claudecodeui/commit/295bad9c006b669878cbf52940794f29f7370178))
|
|
||||||
|
|
||||||
## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13)
|
## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -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>
|
<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>
|
</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.zh-TW.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.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
|
- **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)
|
- **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
|
- **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 zur Laufzeit über `GET /api/providers/:provider/models`)
|
- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`public/modelConstants.js`](public/modelConstants.js))
|
||||||
|
|
||||||
|
|
||||||
## Schnellstart
|
## 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>
|
<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>
|
</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.zh-TW.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> · <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>
|
<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>
|
</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.zh-TW.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.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)
|
- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||||
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리
|
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리
|
||||||
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`GET /api/providers/:provider/models` API에서 전체 지원 모델 확인)
|
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`public/modelConstants.js`에서 전체 지원 모델 확인)
|
||||||
|
|
||||||
## 빠른 시작
|
## 빠른 시작
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<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>
|
</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.zh-TW.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.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
|
- **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)
|
- **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
|
- **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 (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
|
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](public/modelConstants.js) for the full list of supported models)
|
||||||
|
|
||||||
|
|
||||||
## Quick Start
|
## 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>
|
<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>
|
</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.zh-TW.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.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)
|
- **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||||
- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow
|
- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow
|
||||||
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (полный список поддерживаемых моделей доступен через `GET /api/providers/:provider/models`)
|
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`public/modelConstants.js`](public/modelConstants.js) для полного списка поддерживаемых моделей)
|
||||||
|
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|||||||
@@ -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>
|
<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>
|
</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.zh-TW.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.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
|
- **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)
|
- **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
|
- **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 `GET /api/providers/:provider/models` API'sine bak)
|
- **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)
|
||||||
|
|
||||||
|
|
||||||
## Hızlı Başlangıç
|
## 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>
|
<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>
|
</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.zh-TW.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> · <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>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
|
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
|
||||||
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||||
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理
|
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理
|
||||||
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表可通过 `GET /api/providers/:provider/models` 接口获取)
|
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`public/modelConstants.js`](public/modelConstants.js))
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
|||||||
242
README.zh-TW.md
242
README.zh-TW.md
@@ -1,242 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
# 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
261
package-lock.json
generated
261
package-lock.json
generated
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.34.0",
|
"version": "1.32.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.34.0",
|
"version": "1.32.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-html": "^6.4.9",
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
@@ -39,7 +39,6 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cross-spawn": "^7.0.3",
|
"cross-spawn": "^7.0.3",
|
||||||
"dompurify": "^3.4.7",
|
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
@@ -139,33 +138,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||||
"version": "0.3.165",
|
"version": "0.2.116",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.165.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.116.tgz",
|
||||||
"integrity": "sha512-wEUJNTAWkE6KMV35abqGi30lwhZz+jQLMtLh4SuTN2Hllzsysq8kmQFgcWulza3FLHG/GHzGHPi0+Sp2fb8xlw==",
|
"integrity": "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw==",
|
||||||
"license": "SEE LICENSE IN README.md",
|
"license": "SEE LICENSE IN README.md",
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.81.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.165",
|
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116",
|
||||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.165",
|
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116",
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.165",
|
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116",
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.165",
|
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116",
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.165",
|
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116",
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.165",
|
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116",
|
||||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.165",
|
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116",
|
||||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.165"
|
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@anthropic-ai/sdk": ">=0.93.0",
|
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||||
"version": "0.3.165",
|
"version": "0.2.116",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.165.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.116.tgz",
|
||||||
"integrity": "sha512-obVodJmppNc6lgcM6Y5y3VCQLrYO2curOXrRaziKtjxYbuZP7kYsUhnonMvGoVAQh3uHKz2tivQDeztvWe3f9w==",
|
"integrity": "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -176,9 +177,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||||
"version": "0.3.165",
|
"version": "0.2.116",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.165.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.116.tgz",
|
||||||
"integrity": "sha512-0jc1tlYLXzPvZIkHKGHzsEEKq2YqTS8oHSNFroqLgbhrIk1Zy05ZXbciI289VDAe1Fq2a+qcUhkXct8Parx1Rg==",
|
"integrity": "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -189,9 +190,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||||
"version": "0.3.165",
|
"version": "0.2.116",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.165.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.116.tgz",
|
||||||
"integrity": "sha512-t87HgDPPaRYMTTB5cqA0M36Fyq4DOny89yk71BMgA8hAzhOjV9bla8pMVZTuX3xYYPjsa/TOmxSzwI8GZLf4Aw==",
|
"integrity": "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -202,9 +203,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||||
"version": "0.3.165",
|
"version": "0.2.116",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.165.tgz",
|
"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-Rccmr5chZdZJVRvoB0nildB5PTKX+amatUho9JIcNOf1iX/6ej39fwf8q9W1MRHYP7AEc4t9GrSAGLcn7/JO4w==",
|
"integrity": "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -215,9 +216,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||||
"version": "0.3.165",
|
"version": "0.2.116",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.165.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.116.tgz",
|
||||||
"integrity": "sha512-Y8fEW0zKBn0XZI5AOQWHep0Srz0qsCauynTWkhsC6J2vSPxkTiOxv2hmb7qdfiNlFn0k1etCWVFoRkhhFJzGfg==",
|
"integrity": "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -228,9 +229,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||||
"version": "0.3.165",
|
"version": "0.2.116",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.165.tgz",
|
"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-Y9Acr1RmydfEX+t+3mFn0K9VOx6nfyo08QuQH9R6ap1YYZWuobze++pNUY/rzwbQjXqcbjORtPKbO/kLQtSr9w==",
|
"integrity": "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -241,9 +242,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||||
"version": "0.3.165",
|
"version": "0.2.116",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.165.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.116.tgz",
|
||||||
"integrity": "sha512-4Q01L3xaDDCvlOhABf2MnO7v7yJxKwwDyiMr+DaneUSvuh1qH0YE7qErSYLf6D9VfH8TdRwKZXwQplVVwCoHWw==",
|
"integrity": "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -254,9 +255,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||||
"version": "0.3.165",
|
"version": "0.2.116",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.165.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.116.tgz",
|
||||||
"integrity": "sha512-Y0uOx7b7ZnkguvFFI5T5fSLnRA/e0uvMC++gSnyz6XMpNekgWc3+Mny7Dv2NO22nKbV2YiFsj6MkYYFEd51BDw==",
|
"integrity": "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -267,14 +268,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/sdk": {
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
"version": "0.100.1",
|
"version": "0.81.0",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.100.1.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.81.0.tgz",
|
||||||
"integrity": "sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==",
|
"integrity": "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1"
|
||||||
"standardwebhooks": "^1.0.0"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"anthropic-ai-sdk": "bin/cli"
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
@@ -1837,7 +1836,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
},
|
},
|
||||||
@@ -2602,7 +2600,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.9",
|
"@hono/node-server": "^1.19.9",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
@@ -2643,7 +2640,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-types": "^3.0.0",
|
"mime-types": "^3.0.0",
|
||||||
"negotiator": "^1.0.0"
|
"negotiator": "^1.0.0"
|
||||||
@@ -2653,11 +2649,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||||
"version": "8.20.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -2674,7 +2669,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
@@ -2699,7 +2693,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -2713,7 +2706,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.6.0"
|
"node": ">=6.6.0"
|
||||||
}
|
}
|
||||||
@@ -2723,7 +2715,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -2767,7 +2758,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
@@ -2789,7 +2779,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -2799,7 +2788,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"depd": "~2.0.0",
|
"depd": "~2.0.0",
|
||||||
"inherits": "~2.0.4",
|
"inherits": "~2.0.4",
|
||||||
@@ -2820,7 +2808,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
},
|
},
|
||||||
@@ -2836,15 +2823,13 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
|
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -2854,7 +2839,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -2867,7 +2851,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "^1.54.0"
|
"mime-db": "^1.54.0"
|
||||||
},
|
},
|
||||||
@@ -2884,17 +2867,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
|
"node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
|
||||||
"version": "6.15.2",
|
"version": "6.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -2910,7 +2891,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||||
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "~3.1.2",
|
"bytes": "~3.1.2",
|
||||||
"http-errors": "~2.0.1",
|
"http-errors": "~2.0.1",
|
||||||
@@ -2926,7 +2906,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
@@ -2953,7 +2932,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
@@ -2973,42 +2951,22 @@
|
|||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
|
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
|
||||||
"version": "2.1.0",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^2.0.0",
|
"content-type": "^1.0.5",
|
||||||
"media-typer": "^1.1.0",
|
"media-typer": "^1.1.0",
|
||||||
"mime-types": "^3.0.0"
|
"mime-types": "^3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 0.6"
|
||||||
},
|
|
||||||
"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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
@@ -4325,13 +4283,6 @@
|
|||||||
"url": "https://ko-fi.com/dangreen"
|
"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": {
|
"node_modules/@tailwindcss/typography": {
|
||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
|
||||||
@@ -4629,13 +4580,6 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
|
||||||
"version": "2.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
@@ -5465,7 +5409,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.0.0"
|
"ajv": "^8.0.0"
|
||||||
},
|
},
|
||||||
@@ -5479,11 +5422,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv-formats/node_modules/ajv": {
|
"node_modules/ajv-formats/node_modules/ajv": {
|
||||||
"version": "8.20.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -5499,8 +5441,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/ansi-escapes": {
|
"node_modules/ansi-escapes": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
@@ -7544,15 +7485,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
|
||||||
"version": "3.4.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
|
|
||||||
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
|
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@types/trusted-types": "^2.0.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dot-prop": {
|
"node_modules/dot-prop": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
|
||||||
@@ -8566,7 +8498,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eventsource-parser": "^3.0.1"
|
"eventsource-parser": "^3.0.1"
|
||||||
},
|
},
|
||||||
@@ -8575,11 +8506,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eventsource-parser": {
|
"node_modules/eventsource-parser": {
|
||||||
"version": "3.1.0",
|
"version": "3.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
|
||||||
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
|
"integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
@@ -8687,13 +8617,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-rate-limit": {
|
"node_modules/express-rate-limit": {
|
||||||
"version": "8.5.2",
|
"version": "8.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
|
||||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-address": "^10.2.0"
|
"ip-address": "10.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
@@ -8809,13 +8738,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||||
@@ -9873,11 +9795,10 @@
|
|||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.23",
|
"version": "4.12.14",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
||||||
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
|
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
@@ -10249,9 +10170,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
@@ -10694,8 +10615,7 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/is-regex": {
|
"node_modules/is-regex": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
@@ -10966,11 +10886,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jose": {
|
"node_modules/jose": {
|
||||||
"version": "6.2.3",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
@@ -11032,7 +10951,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"ts-algebra": "^2.0.0"
|
"ts-algebra": "^2.0.0"
|
||||||
@@ -11052,8 +10970,7 @@
|
|||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
@@ -14045,7 +13962,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||||
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
@@ -15346,7 +15262,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"depd": "^2.0.0",
|
"depd": "^2.0.0",
|
||||||
@@ -15363,7 +15278,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
@@ -16422,17 +16336,6 @@
|
|||||||
"node": ">=12.0.0"
|
"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": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
@@ -17143,8 +17046,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
@@ -19030,7 +18932,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
|
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
|
||||||
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.28 || ^4"
|
"zod": "^3.25.28 || ^4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.34.0",
|
"version": "1.32.0",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist-server/server/index.js",
|
"main": "dist-server/server/index.js",
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"server/",
|
"server/",
|
||||||
"shared/",
|
"shared/",
|
||||||
"public/api-docs.html",
|
"public/api-docs.html",
|
||||||
|
"public/modelConstants.js",
|
||||||
"dist/",
|
"dist/",
|
||||||
"dist-server/",
|
"dist-server/",
|
||||||
"scripts/",
|
"scripts/",
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
"author": "CloudCLI UI Contributors",
|
"author": "CloudCLI UI Contributors",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-html": "^6.4.9",
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
@@ -95,7 +96,6 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cross-spawn": "^7.0.3",
|
"cross-spawn": "^7.0.3",
|
||||||
"dompurify": "^3.4.7",
|
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
|||||||
@@ -820,49 +820,31 @@ data: {"type":"done"}</code></pre>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script type="module">
|
||||||
|
// Import model constants
|
||||||
|
import { PROVIDERS } from './modelConstants.js';
|
||||||
|
|
||||||
// Dynamic URL replacement
|
// Dynamic URL replacement
|
||||||
const apiUrl = window.location.origin;
|
const apiUrl = window.location.origin;
|
||||||
document.querySelectorAll('.api-url').forEach(el => {
|
document.querySelectorAll('.api-url').forEach(el => {
|
||||||
el.textContent = apiUrl;
|
el.textContent = apiUrl;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Populate model documentation from the live provider API
|
// Dynamically populate model documentation
|
||||||
const PROVIDER_ORDER = [
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
{ id: 'claude', name: 'Anthropic' },
|
|
||||||
{ id: 'codex', name: 'OpenAI' },
|
|
||||||
{ id: 'gemini', name: 'Google' },
|
|
||||||
{ id: 'cursor', name: 'Cursor' },
|
|
||||||
{ id: 'opencode', name: 'OpenCode' },
|
|
||||||
];
|
|
||||||
|
|
||||||
async function populateModels() {
|
|
||||||
const modelCell = document.getElementById('model-options-cell');
|
const modelCell = document.getElementById('model-options-cell');
|
||||||
if (!modelCell) return;
|
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>');
|
||||||
|
|
||||||
const token = localStorage.getItem('auth-token');
|
modelCell.innerHTML = `
|
||||||
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
Model identifier for the AI provider:<br><br>
|
||||||
|
${providerModels}
|
||||||
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
|
// Tab switching
|
||||||
window.showTab = function(tabName) {
|
window.showTab = function(tabName) {
|
||||||
|
|||||||
841
public/modelConstants.js
Normal file
841
public/modelConstants.js
Normal file
@@ -0,0 +1,841 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
- **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)
|
- **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
|
- **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 (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
|
- **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)
|
||||||
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|||||||
@@ -28,14 +28,10 @@ import {
|
|||||||
} from './services/notification-orchestrator.js';
|
} from './services/notification-orchestrator.js';
|
||||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
import { createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
const activeSessions = new Map();
|
const activeSessions = new Map();
|
||||||
const pendingToolApprovals = 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;
|
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
|
||||||
|
|
||||||
@@ -208,7 +204,7 @@ function mapCliOptionsToSDK(options = {}) {
|
|||||||
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
||||||
|
|
||||||
// Map model (default to sonnet)
|
// Map model (default to sonnet)
|
||||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
|
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
||||||
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
|
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
|
||||||
// Model logged at query start below
|
// Model logged at query start below
|
||||||
|
|
||||||
@@ -308,11 +304,7 @@ function extractTokenBudget(sdkMessage) {
|
|||||||
|
|
||||||
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
|
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
|
||||||
if (messageUsage && typeof messageUsage === 'object') {
|
if (messageUsage && typeof messageUsage === 'object') {
|
||||||
const directInputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
|
const inputTokens = 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 outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
|
||||||
const totalUsed = inputTokens + outputTokens;
|
const totalUsed = inputTokens + outputTokens;
|
||||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
||||||
@@ -322,9 +314,6 @@ function extractTokenBudget(sdkMessage) {
|
|||||||
total: contextWindow,
|
total: contextWindow,
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
cacheReadTokens,
|
|
||||||
cacheCreationTokens,
|
|
||||||
cacheTokens,
|
|
||||||
breakdown: {
|
breakdown: {
|
||||||
input: inputTokens,
|
input: inputTokens,
|
||||||
output: outputTokens,
|
output: outputTokens,
|
||||||
@@ -735,18 +724,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
// Clean up temporary image files
|
// Clean up temporary image files
|
||||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||||
|
|
||||||
// Send the terminal completion event — skipped for aborted runs, whose
|
// Send completion event
|
||||||
// terminal `complete` (aborted: true) was already sent by abort-session.
|
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
|
||||||
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
|
|
||||||
if (!wasAborted) {
|
|
||||||
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
|
|
||||||
}
|
|
||||||
notifyRunStopped({
|
notifyRunStopped({
|
||||||
userId: ws?.userId || null,
|
userId: ws?.userId || null,
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
sessionId: capturedSessionId || sessionId || null,
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
sessionName: sessionSummary,
|
sessionName: sessionSummary,
|
||||||
stopReason: wasAborted ? 'aborted' : 'completed'
|
stopReason: 'completed'
|
||||||
});
|
});
|
||||||
// Complete
|
// Complete
|
||||||
|
|
||||||
@@ -761,22 +746,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
// Clean up temporary image files on error
|
// Clean up temporary image files on error
|
||||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
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
|
// Check if Claude CLI is installed for a clearer error message
|
||||||
const installed = await providerAuthService.isProviderInstalled('claude');
|
const installed = await providerAuthService.isProviderInstalled('claude');
|
||||||
const errorContent = !installed
|
const errorContent = !installed
|
||||||
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
|
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
|
||||||
: error.message;
|
: error.message;
|
||||||
|
|
||||||
// Send error to WebSocket, then the terminal complete
|
// Send error to WebSocket
|
||||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
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({
|
notifyRunFailed({
|
||||||
userId: ws?.userId || null,
|
userId: ws?.userId || null,
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
@@ -803,10 +780,6 @@ async function abortClaudeSDKSession(sessionId) {
|
|||||||
try {
|
try {
|
||||||
console.log(`Aborting SDK session: ${sessionId}`);
|
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
|
// Call interrupt() on the query instance
|
||||||
await session.instance.interrupt();
|
await session.instance.interrupt();
|
||||||
|
|
||||||
@@ -822,8 +795,6 @@ async function abortClaudeSDKSession(sessionId) {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error aborting session ${sessionId}:`, error);
|
console.error(`Error aborting session ${sessionId}:`, error);
|
||||||
// The run keeps going; let it emit its own terminal complete.
|
|
||||||
abortedSessionIds.delete(sessionId);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ async function sandboxCommand(args) {
|
|||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
|
|
||||||
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
||||||
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
|
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
|
||||||
|
|
||||||
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
||||||
try {
|
try {
|
||||||
@@ -554,7 +554,7 @@ async function sandboxCommand(args) {
|
|||||||
|
|
||||||
// Step 3: Start CloudCLI inside the sandbox
|
// Step 3: Start CloudCLI inside the sandbox
|
||||||
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
||||||
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
|
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
|
||||||
|
|
||||||
// Step 4: Forward port
|
// Step 4: Forward port
|
||||||
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
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 { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
import { createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
// Use cross-spawn on Windows for better command execution
|
// Use cross-spawn on Windows for better command execution
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
@@ -34,10 +34,6 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||||
let hasRetriedWithTrust = false;
|
let hasRetriedWithTrust = false;
|
||||||
let settled = 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
|
// Use tools settings passed from frontend, or defaults
|
||||||
const settings = toolsSettings || {
|
const settings = toolsSettings || {
|
||||||
@@ -201,15 +197,15 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'result': {
|
case 'result': {
|
||||||
// Session complete — terminal lifecycle event for this run
|
// Session complete — send stream end + lifecycle complete with result payload
|
||||||
if (!completeSent) {
|
const resultText = typeof response.result === 'string' ? response.result : '';
|
||||||
completeSent = true;
|
ws.send(createNormalizedMessage({
|
||||||
ws.send(createCompleteMessage({
|
kind: 'complete',
|
||||||
provider: 'cursor',
|
exitCode: response.subtype === 'success' ? 0 : 1,
|
||||||
sessionId: capturedSessionId || sessionId || null,
|
resultText,
|
||||||
exitCode: response.subtype === 'success' ? 0 : 1,
|
isError: response.subtype !== 'success',
|
||||||
}));
|
sessionId: capturedSessionId || sessionId, provider: 'cursor',
|
||||||
}
|
}));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,12 +271,7 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal complete — unless the `result` line already sent it, or the
|
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' }));
|
||||||
// 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) {
|
if (code === 0) {
|
||||||
notifyTerminalState({ code });
|
notifyTerminalState({ code });
|
||||||
@@ -306,10 +297,6 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
: error.message;
|
: error.message;
|
||||||
|
|
||||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
|
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 });
|
notifyTerminalState({ error });
|
||||||
|
|
||||||
settleOnce(() => reject(error));
|
settleOnce(() => reject(error));
|
||||||
@@ -327,9 +314,6 @@ function abortCursorSession(sessionId) {
|
|||||||
const process = activeCursorProcesses.get(sessionId);
|
const process = activeCursorProcesses.get(sessionId);
|
||||||
if (process) {
|
if (process) {
|
||||||
console.log(`Aborting Cursor session: ${sessionId}`);
|
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');
|
process.kill('SIGTERM');
|
||||||
activeCursorProcesses.delete(sessionId);
|
activeCursorProcesses.delete(sessionId);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import GeminiResponseHandler from './gemini-response-handler.js';
|
|||||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
import { createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
@@ -129,9 +129,6 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||||
let assistantBlocks = []; // Accumulate the full response blocks including tools
|
let assistantBlocks = []; // Accumulate the full response blocks including tools
|
||||||
// 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
|
// Use tools settings passed from frontend, or defaults
|
||||||
const settings = toolsSettings || {
|
const settings = toolsSettings || {
|
||||||
@@ -489,12 +486,7 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
|
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal complete — skipped for aborted runs (abort-session
|
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' }));
|
||||||
// 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
|
// Clean up temporary image files if any
|
||||||
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
|
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
|
||||||
@@ -574,10 +566,6 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
|
|
||||||
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
||||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
|
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 });
|
notifyTerminalState({ error });
|
||||||
|
|
||||||
reject(error);
|
reject(error);
|
||||||
@@ -602,9 +590,6 @@ function abortGeminiSession(sessionId) {
|
|||||||
|
|
||||||
if (geminiProc) {
|
if (geminiProc) {
|
||||||
try {
|
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');
|
geminiProc.kill('SIGTERM');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (activeGeminiProcesses.has(processKey)) {
|
if (activeGeminiProcesses.has(processKey)) {
|
||||||
|
|||||||
215
server/index.js
215
server/index.js
@@ -84,17 +84,9 @@ const __dirname = getModuleDir(import.meta.url);
|
|||||||
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
||||||
const APP_ROOT = findAppRoot(__dirname);
|
const APP_ROOT = findAppRoot(__dirname);
|
||||||
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
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);
|
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 app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
@@ -900,27 +892,27 @@ const uploadFilesHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: MAX_FILE_UPLOAD_SIZE_BYTES,
|
fileSize: 50 * 1024 * 1024, // 50MB limit
|
||||||
files: MAX_FILE_UPLOAD_COUNT
|
files: 20 // Max 20 files at once
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use multer middleware
|
// Use multer middleware
|
||||||
uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => {
|
uploadMiddleware.array('files', 20)(req, res, async (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Multer error:', err);
|
console.error('Multer error:', err);
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_UPLOAD_SIZE_MB}MB.` });
|
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
|
||||||
}
|
}
|
||||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
if (err.code === 'LIMIT_FILE_COUNT') {
|
||||||
return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` });
|
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
|
||||||
}
|
}
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { projectId } = req.params;
|
const { projectId } = req.params;
|
||||||
const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body;
|
const { targetPath, relativePaths } = req.body;
|
||||||
|
|
||||||
// Parse relative paths if provided (for folder uploads)
|
// Parse relative paths if provided (for folder uploads)
|
||||||
let filePaths = [];
|
let filePaths = [];
|
||||||
@@ -944,11 +936,6 @@ const uploadFilesHandler = async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'No files provided' });
|
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.
|
// Resolve the project directory through the DB using the new projectId.
|
||||||
const projectRoot = await projectsDb.getProjectPathById(projectId);
|
const projectRoot = await projectsDb.getProjectPathById(projectId);
|
||||||
if (!projectRoot) {
|
if (!projectRoot) {
|
||||||
@@ -1027,10 +1014,8 @@ const uploadFilesHandler = async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
files: uploadedFiles,
|
files: uploadedFiles,
|
||||||
uploadedCount: uploadedFiles.length,
|
|
||||||
requestedFileCount,
|
|
||||||
targetPath: resolvedTargetDir,
|
targetPath: resolvedTargetDir,
|
||||||
message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully`
|
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading files:', error);
|
console.error('Error uploading files:', error);
|
||||||
@@ -1401,8 +1386,6 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
||||||
let inputTokens = 0;
|
let inputTokens = 0;
|
||||||
let outputTokens = 0;
|
let outputTokens = 0;
|
||||||
let cacheReadTokens = 0;
|
|
||||||
let cacheCreationTokens = 0;
|
|
||||||
|
|
||||||
// Find the latest assistant message with usage data (scan from end)
|
// Find the latest assistant message with usage data (scan from end)
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
@@ -1414,11 +1397,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
const usage = entry.message.usage;
|
const usage = entry.message.usage;
|
||||||
|
|
||||||
// Use token counts from latest assistant message only
|
// Use token counts from latest assistant message only
|
||||||
const directInputTokens = readUsageNumber(usage.input_tokens ?? usage.inputTokens);
|
inputTokens = usage.input_tokens || 0;
|
||||||
cacheReadTokens = readUsageNumber(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? usage.cacheReadTokens);
|
outputTokens = usage.output_tokens || 0;
|
||||||
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
|
break; // Stop after finding the latest assistant message
|
||||||
}
|
}
|
||||||
@@ -1429,16 +1409,12 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalUsed = inputTokens + outputTokens;
|
const totalUsed = inputTokens + outputTokens;
|
||||||
const cacheTokens = cacheReadTokens + cacheCreationTokens;
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
used: totalUsed,
|
used: totalUsed,
|
||||||
total: contextWindow,
|
total: contextWindow,
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
cacheReadTokens,
|
|
||||||
cacheCreationTokens,
|
|
||||||
cacheTokens,
|
|
||||||
breakdown: {
|
breakdown: {
|
||||||
input: inputTokens,
|
input: inputTokens,
|
||||||
output: outputTokens
|
output: outputTokens
|
||||||
@@ -1507,133 +1483,74 @@ function permToRwx(perm) {
|
|||||||
return r + w + x;
|
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) {
|
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
||||||
// Using fsPromises from import
|
// Using fsPromises from import
|
||||||
let entries;
|
const items = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await acquire();
|
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
||||||
try {
|
|
||||||
entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
for (const entry of entries) {
|
||||||
} finally {
|
// Debug: log all entries including hidden files
|
||||||
release();
|
|
||||||
|
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Only log non-permission errors to avoid spam
|
// Only log non-permission errors to avoid spam
|
||||||
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
||||||
console.error('Error reading directory:', error);
|
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) => {
|
return items.sort((a, b) => {
|
||||||
if (a.type !== b.type) {
|
if (a.type !== b.type) {
|
||||||
return a.type === 'directory' ? -1 : 1;
|
return a.type === 'directory' ? -1 : 1;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ type NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: boolean;
|
inApp: boolean;
|
||||||
webPush: boolean;
|
webPush: boolean;
|
||||||
sound: boolean;
|
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
actionRequired: boolean;
|
actionRequired: boolean;
|
||||||
@@ -23,7 +22,6 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: false,
|
inApp: false,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
sound: true,
|
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
actionRequired: true,
|
actionRequired: true,
|
||||||
@@ -39,7 +37,6 @@ function normalizeNotificationPreferences(value: unknown): NotificationPreferenc
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: source.channels?.inApp === true,
|
inApp: source.channels?.inApp === true,
|
||||||
webPush: source.channels?.webPush === true,
|
webPush: source.channels?.webPush === true,
|
||||||
sound: source.channels?.sound !== false,
|
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
actionRequired: source.events?.actionRequired !== false,
|
actionRequired: source.events?.actionRequired !== false,
|
||||||
|
|||||||
@@ -67,17 +67,8 @@ function resolveRouteErrorMessage(error: unknown): string {
|
|||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (_req, res) => {
|
||||||
const skipSynchronization =
|
const projects = await getProjectsWithSessions();
|
||||||
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);
|
res.json(projects);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
|
|||||||
- Update `server/modules/providers/provider.routes.ts`.
|
- Update `server/modules/providers/provider.routes.ts`.
|
||||||
- Update `server/routes/agent.js` if the provider is launchable from the agent runtime.
|
- 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 `server/index.js` if the provider needs runtime boot or shutdown wiring.
|
||||||
- Update the `PROVIDER_ORDER` list in `public/api-docs.html` if the provider should appear in the public API docs.
|
- Update `public/modelConstants.js` if the provider appears in README or public API docs.
|
||||||
- Update `src/components/chat/hooks/useChatProviderState.ts` and
|
- Update `src/components/chat/hooks/useChatProviderState.ts` and
|
||||||
`src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` if
|
`src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` if
|
||||||
the provider should be selectable in chat.
|
the provider should be selectable in chat.
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ type ClaudeCredentialsStatus = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasErrorCode = (error: unknown, code: string): boolean => (
|
|
||||||
error instanceof Error && 'code' in error && error.code === code
|
|
||||||
);
|
|
||||||
|
|
||||||
export class ClaudeProviderAuth implements IProviderAuth {
|
export class ClaudeProviderAuth implements IProviderAuth {
|
||||||
/**
|
/**
|
||||||
* Checks whether the Claude Code CLI is available on this host.
|
* Checks whether the Claude Code CLI is available on this host.
|
||||||
@@ -81,12 +77,6 @@ export class ClaudeProviderAuth implements IProviderAuth {
|
|||||||
* Checks Claude credentials in the same priority order used by Claude Code.
|
* Checks Claude credentials in the same priority order used by Claude Code.
|
||||||
*/
|
*/
|
||||||
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
|
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
|
||||||
const missingCredentialsError = 'Claude CLI is not authenticated. Run claude /login or configure ANTHROPIC_API_KEY.';
|
|
||||||
|
|
||||||
if (process.env.ANTHROPIC_AUTH_TOKEN?.trim()) {
|
|
||||||
return { authenticated: true, email: 'Auth Token', method: 'api_key' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.ANTHROPIC_API_KEY?.trim()) {
|
if (process.env.ANTHROPIC_API_KEY?.trim()) {
|
||||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||||
}
|
}
|
||||||
@@ -120,33 +110,15 @@ export class ClaudeProviderAuth implements IProviderAuth {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
email: null,
|
email,
|
||||||
method: null,
|
method: 'credentials_file',
|
||||||
error: 'Claude login has expired. Run claude /login again.',
|
error: 'OAuth token has expired. Please re-authenticate with claude login',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { authenticated: false, email: null, method: null };
|
||||||
authenticated: false,
|
} catch {
|
||||||
email: null,
|
return { authenticated: false, email: null, method: null };
|
||||||
method: null,
|
|
||||||
error: missingCredentialsError,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
let errorMessage = 'Unable to read Claude credentials. Run claude /login again.';
|
|
||||||
|
|
||||||
if (hasErrorCode(error, 'ENOENT')) {
|
|
||||||
errorMessage = missingCredentialsError;
|
|
||||||
} else if (error instanceof SyntaxError) {
|
|
||||||
errorMessage = 'Claude credentials are unreadable. Run claude /login again.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
authenticated: false,
|
|
||||||
email: null,
|
|
||||||
method: null,
|
|
||||||
error: errorMessage,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,28 +18,18 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
|||||||
{
|
{
|
||||||
value: 'default',
|
value: 'default',
|
||||||
label: 'Default (recommended)',
|
label: 'Default (recommended)',
|
||||||
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
|
description: 'Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'fable',
|
value: 'sonnet',
|
||||||
label: 'Fable',
|
label: 'Sonnet',
|
||||||
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
|
description: 'Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok',
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "sonnet",
|
|
||||||
label: "Sonnet",
|
|
||||||
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'sonnet[1m]',
|
value: 'sonnet[1m]',
|
||||||
label: 'Sonnet (1M context)',
|
label: 'Sonnet (1M context)',
|
||||||
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
|
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',
|
value: 'haiku',
|
||||||
label: 'Haiku',
|
label: 'Haiku',
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
|
|||||||
|
|
||||||
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
||||||
const PROVIDER_MODELS_CACHE_VERSION = 1;
|
const PROVIDER_MODELS_CACHE_VERSION = 1;
|
||||||
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
|
|
||||||
|
|
||||||
type ProviderModelsServiceDependencies = {
|
type ProviderModelsServiceDependencies = {
|
||||||
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
||||||
@@ -233,42 +232,10 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
|
|||||||
return request;
|
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 (
|
const getProviderModels = async (
|
||||||
provider: LLMProvider,
|
provider: LLMProvider,
|
||||||
options: ProviderModelsOptions = {},
|
options: ProviderModelsOptions = {},
|
||||||
): Promise<ProviderModelsResult> => {
|
): Promise<ProviderModelsResult> => {
|
||||||
if (UNCACHED_PROVIDERS.has(provider)) {
|
|
||||||
const pendingRequest = pendingRequests.get(provider);
|
|
||||||
if (pendingRequest) {
|
|
||||||
return pendingRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
return loadDirectModels(provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.bypassCache) {
|
if (options.bypassCache) {
|
||||||
const pendingRequest = pendingRequests.get(provider);
|
const pendingRequest = pendingRequests.get(provider);
|
||||||
if (pendingRequest) {
|
if (pendingRequest) {
|
||||||
|
|||||||
@@ -130,37 +130,6 @@ 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 () => {
|
test('provider model cache is persisted across service instances', async () => {
|
||||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
|
||||||
const cachePath = path.join(tempRoot, 'models-cache.json');
|
const cachePath = path.join(tempRoot, 'models-cache.json');
|
||||||
|
|||||||
@@ -133,10 +133,9 @@ flowchart TD
|
|||||||
|
|
||||||
### Chat Notes
|
### Chat Notes
|
||||||
|
|
||||||
1. **Unified terminal lifecycle**: every provider run ends with exactly one `complete` message built by `createCompleteMessage()` (`server/shared/utils.ts`), regardless of provider: `{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }`. Failed runs emit an informational `error` message first, then the terminal `complete` with `success: false`. Mid-run `error` messages (e.g. stderr output) are non-terminal; the frontend only treats `complete` as end-of-run.
|
1. `abort-session` returns a normalized `complete` message with `aborted: true`.
|
||||||
2. `abort-session` sends the terminal `complete` (`aborted: true`) on behalf of the cancelled run; providers detect the abort and skip their own `complete` so the client sees exactly one.
|
2. `check-session-status` returns `{ type: "session-status", isProcessing }`.
|
||||||
3. `check-session-status` returns `{ type: "session-status", isProcessing }`.
|
3. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
|
||||||
4. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
|
|
||||||
|
|
||||||
## `/shell` Terminal Flow
|
## `/shell` Terminal Flow
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
AuthenticatedWebSocketRequest,
|
AuthenticatedWebSocketRequest,
|
||||||
LLMProvider,
|
LLMProvider,
|
||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
import { createCompleteMessage, parseIncomingJsonObject } from '@/shared/utils.js';
|
import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js';
|
||||||
|
|
||||||
type ChatIncomingMessage = AnyRecord & {
|
type ChatIncomingMessage = AnyRecord & {
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -173,14 +173,14 @@ export function handleChatConnection(
|
|||||||
success = await dependencies.abortClaudeSDKSession(sessionId);
|
success = await dependencies.abortClaudeSDKSession(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal complete on behalf of the cancelled run — providers skip
|
|
||||||
// their own complete for aborted runs so the client sees exactly one.
|
|
||||||
writer.send(
|
writer.send(
|
||||||
createCompleteMessage({
|
createNormalizedMessage({
|
||||||
provider,
|
kind: 'complete',
|
||||||
sessionId,
|
|
||||||
exitCode: success ? 0 : 1,
|
exitCode: success ? 0 : 1,
|
||||||
aborted: true,
|
aborted: true,
|
||||||
|
success,
|
||||||
|
sessionId,
|
||||||
|
provider,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -202,11 +202,13 @@ export function handleChatConnection(
|
|||||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
||||||
const success = dependencies.abortCursorSession(sessionId);
|
const success = dependencies.abortCursorSession(sessionId);
|
||||||
writer.send(
|
writer.send(
|
||||||
createCompleteMessage({
|
createNormalizedMessage({
|
||||||
provider: 'cursor',
|
kind: 'complete',
|
||||||
sessionId,
|
|
||||||
exitCode: success ? 0 : 1,
|
exitCode: success ? 0 : 1,
|
||||||
aborted: true,
|
aborted: true,
|
||||||
|
success,
|
||||||
|
sessionId,
|
||||||
|
provider: 'cursor',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -26,15 +26,15 @@ export function handlePluginWsProxy(
|
|||||||
console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`);
|
console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
upstream.on('message', (data, isBinary) => {
|
upstream.on('message', (data) => {
|
||||||
if (clientWs.readyState === WebSocket.OPEN) {
|
if (clientWs.readyState === WebSocket.OPEN) {
|
||||||
clientWs.send(data, { binary: isBinary });
|
clientWs.send(data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
clientWs.on('message', (data, isBinary) => {
|
clientWs.on('message', (data) => {
|
||||||
if (upstream.readyState === WebSocket.OPEN) {
|
if (upstream.readyState === WebSocket.OPEN) {
|
||||||
upstream.send(data, { binary: isBinary });
|
upstream.send(data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ type ShellIncomingMessage = {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
initialCommand?: string;
|
initialCommand?: string;
|
||||||
isPlainShell?: boolean;
|
isPlainShell?: boolean;
|
||||||
forceRestart?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PtySessionEntry = {
|
type PtySessionEntry = {
|
||||||
@@ -181,7 +180,6 @@ export function handleShellConnection(
|
|||||||
const hasSession = readBoolean(data.hasSession);
|
const hasSession = readBoolean(data.hasSession);
|
||||||
const provider = readString(data.provider, 'claude');
|
const provider = readString(data.provider, 'claude');
|
||||||
const initialCommand = readString(data.initialCommand);
|
const initialCommand = readString(data.initialCommand);
|
||||||
const forceRestart = readBoolean(data.forceRestart);
|
|
||||||
const isPlainShell =
|
const isPlainShell =
|
||||||
readBoolean(data.isPlainShell) ||
|
readBoolean(data.isPlainShell) ||
|
||||||
(!!initialCommand && !hasSession) ||
|
(!!initialCommand && !hasSession) ||
|
||||||
@@ -202,7 +200,7 @@ export function handleShellConnection(
|
|||||||
: '';
|
: '';
|
||||||
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
|
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
|
||||||
|
|
||||||
if (isLoginCommand || forceRestart) {
|
if (isLoginCommand) {
|
||||||
const oldSession = ptySessionsMap.get(ptySessionKey);
|
const oldSession = ptySessionsMap.get(ptySessionKey);
|
||||||
if (oldSession) {
|
if (oldSession) {
|
||||||
if (oldSession.timeoutId) {
|
if (oldSession.timeoutId) {
|
||||||
@@ -213,8 +211,7 @@ export function handleShellConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSession =
|
const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
|
||||||
isLoginCommand || forceRestart ? null : ptySessionsMap.get(ptySessionKey);
|
|
||||||
if (existingSession) {
|
if (existingSession) {
|
||||||
shellProcess = existingSession.pty;
|
shellProcess = existingSession.pty;
|
||||||
if (existingSession.timeoutId) {
|
if (existingSession.timeoutId) {
|
||||||
@@ -371,10 +368,6 @@ export function handleShellConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = ptySessionsMap.get(ptySessionKey);
|
const session = ptySessionsMap.get(ptySessionKey);
|
||||||
if (session && session.pty !== shellProcess) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||||
session.ws.send(
|
session.ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -458,10 +451,6 @@ export function handleShellConnection(
|
|||||||
|
|
||||||
session.ws = null;
|
session.ws = null;
|
||||||
session.timeoutId = setTimeout(() => {
|
session.timeoutId = setTimeout(() => {
|
||||||
if (ptySessionsMap.get(ptySessionKey as string) !== session) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.pty.kill();
|
session.pty.kill();
|
||||||
ptySessionsMap.delete(ptySessionKey as string);
|
ptySessionsMap.delete(ptySessionKey as string);
|
||||||
}, PTY_SESSION_TIMEOUT);
|
}, PTY_SESSION_TIMEOUT);
|
||||||
|
|||||||
@@ -20,13 +20,7 @@ export function verifyWebSocketClient(
|
|||||||
dependencies: WebSocketAuthDependencies
|
dependencies: WebSocketAuthDependencies
|
||||||
): boolean {
|
): boolean {
|
||||||
const request = info.req as AuthenticatedWebSocketRequest;
|
const request = info.req as AuthenticatedWebSocketRequest;
|
||||||
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
|
console.log('WebSocket connection attempt to:', request.url);
|
||||||
const loggedUrl = new URL(upgradeUrl);
|
|
||||||
if (loggedUrl.searchParams.has('token')) {
|
|
||||||
loggedUrl.searchParams.set('token', 'REDACTED');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('WebSocket connection attempt to:', `${loggedUrl.pathname}${loggedUrl.search}`);
|
|
||||||
|
|
||||||
// Platform mode: use the first DB user and skip token checks.
|
// Platform mode: use the first DB user and skip token checks.
|
||||||
if (dependencies.isPlatform) {
|
if (dependencies.isPlatform) {
|
||||||
@@ -42,6 +36,7 @@ export function verifyWebSocketClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OSS mode: read JWT from query string first, then Authorization header.
|
// OSS mode: read JWT from query string first, then Authorization header.
|
||||||
|
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
|
||||||
const token =
|
const token =
|
||||||
upgradeUrl.searchParams.get('token') ??
|
upgradeUrl.searchParams.get('token') ??
|
||||||
request.headers.authorization?.split(' ')[1] ??
|
request.headers.authorization?.split(' ')[1] ??
|
||||||
|
|||||||
@@ -31,24 +31,6 @@ export function createWebSocketServer(
|
|||||||
});
|
});
|
||||||
|
|
||||||
wss.on('connection', (ws, request) => {
|
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 incomingRequest = request as AuthenticatedWebSocketRequest;
|
||||||
const url = incomingRequest.url ?? '/';
|
const url = incomingRequest.url ?? '/';
|
||||||
const pathname = new URL(url, 'http://localhost').pathname;
|
const pathname = new URL(url, 'http://localhost').pathname;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
|
|||||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
import { createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
// Track active sessions
|
// Track active sessions
|
||||||
const activeCodexSessions = new Map();
|
const activeCodexSessions = new Map();
|
||||||
@@ -352,26 +352,21 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the terminal completion event — skipped for aborted runs, whose
|
// Send completion event
|
||||||
// terminal `complete` (aborted: true) was already sent by abort-session.
|
if (!terminalFailure) {
|
||||||
const runSession = capturedSessionId ? activeCodexSessions.get(capturedSessionId) : null;
|
sendMessage(ws, createNormalizedMessage({
|
||||||
const runAborted = runSession?.status === 'aborted' || abortController.signal.aborted;
|
kind: 'complete',
|
||||||
if (!runAborted) {
|
actualSessionId: capturedSessionId || thread.id || sessionId || null,
|
||||||
sendMessage(ws, createCompleteMessage({
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
|
provider: 'codex'
|
||||||
|
}));
|
||||||
|
notifyRunStopped({
|
||||||
|
userId: ws?.userId || null,
|
||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
sessionId: capturedSessionId || sessionId || null,
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
actualSessionId: capturedSessionId || thread.id || sessionId || null,
|
sessionName: sessionSummary,
|
||||||
exitCode: terminalFailure ? 1 : 0,
|
stopReason: 'completed'
|
||||||
}));
|
});
|
||||||
if (!terminalFailure) {
|
|
||||||
notifyRunStopped({
|
|
||||||
userId: ws?.userId || null,
|
|
||||||
provider: 'codex',
|
|
||||||
sessionId: capturedSessionId || sessionId || null,
|
|
||||||
sessionName: sessionSummary,
|
|
||||||
stopReason: 'completed'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -391,11 +386,6 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
: error.message;
|
: error.message;
|
||||||
|
|
||||||
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
|
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) {
|
if (!terminalFailure) {
|
||||||
notifyRunFailed({
|
notifyRunFailed({
|
||||||
userId: ws?.userId || null,
|
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 { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
|
import { createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
|
||||||
|
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
|
|
||||||
@@ -92,9 +92,6 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|||||||
let stdoutLineBuffer = '';
|
let stdoutLineBuffer = '';
|
||||||
let terminalNotificationSent = false;
|
let terminalNotificationSent = false;
|
||||||
let opencodeProcess = null;
|
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 } = {}) => {
|
const notifyTerminalState = ({ code = null, error = null } = {}) => {
|
||||||
if (terminalNotificationSent) {
|
if (terminalNotificationSent) {
|
||||||
@@ -259,12 +256,13 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal complete — skipped for aborted runs (abort-session
|
ws.send(createNormalizedMessage({
|
||||||
// already sent the aborted complete on this run's behalf).
|
kind: 'complete',
|
||||||
if (!completeSent && !opencodeProcess.aborted) {
|
exitCode: code,
|
||||||
completeSent = true;
|
isNewSession: !sessionId && !!command,
|
||||||
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: code }));
|
sessionId: finalSessionId,
|
||||||
}
|
provider: 'opencode',
|
||||||
|
}));
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
notifyTerminalState({ code });
|
notifyTerminalState({ code });
|
||||||
@@ -304,10 +302,6 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|||||||
sessionId: finalSessionId,
|
sessionId: finalSessionId,
|
||||||
provider: 'opencode',
|
provider: 'opencode',
|
||||||
}));
|
}));
|
||||||
if (!completeSent && !opencodeProcess.aborted) {
|
|
||||||
completeSent = true;
|
|
||||||
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: 1 }));
|
|
||||||
}
|
|
||||||
notifyTerminalState({ error });
|
notifyTerminalState({ error });
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
@@ -321,9 +315,6 @@ function abortOpenCodeSession(sessionId) {
|
|||||||
return false;
|
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');
|
process.kill('SIGTERM');
|
||||||
activeOpenCodeProcesses.delete(sessionId);
|
activeOpenCodeProcesses.delete(sessionId);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -592,14 +592,12 @@ class ResponseCollector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputTokens = totalInput + totalCacheRead + totalCacheCreation;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inputTokens,
|
inputTokens: totalInput,
|
||||||
outputTokens: totalOutput,
|
outputTokens: totalOutput,
|
||||||
cacheReadTokens: totalCacheRead,
|
cacheReadTokens: totalCacheRead,
|
||||||
cacheCreationTokens: totalCacheCreation,
|
cacheCreationTokens: totalCacheCreation,
|
||||||
totalTokens: inputTokens + totalOutput
|
totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -646,7 +644,7 @@ class ResponseCollector {
|
|||||||
*
|
*
|
||||||
* @param {string} model - (Optional) Model identifier for providers.
|
* @param {string} model - (Optional) Model identifier for providers.
|
||||||
*
|
*
|
||||||
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable'
|
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
|
||||||
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
|
* 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',
|
* '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',
|
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
|
||||||
|
|||||||
@@ -268,35 +268,16 @@ Custom commands can be created in:
|
|||||||
tokenUsage.contextWindow ??
|
tokenUsage.contextWindow ??
|
||||||
0,
|
0,
|
||||||
) || 0;
|
) || 0;
|
||||||
const normalizedInputValue =
|
const inputTokensRaw =
|
||||||
tokenUsage.inputTokens ??
|
|
||||||
tokenUsage.input ??
|
|
||||||
tokenUsage.cumulativeInputTokens ??
|
|
||||||
tokenUsage.breakdown?.input ??
|
|
||||||
tokenUsage.promptTokens;
|
|
||||||
const directInputTokens =
|
|
||||||
Number(
|
Number(
|
||||||
normalizedInputValue ??
|
tokenUsage.inputTokens ??
|
||||||
|
tokenUsage.input ??
|
||||||
tokenUsage.input_tokens ??
|
tokenUsage.input_tokens ??
|
||||||
0
|
tokenUsage.cumulativeInputTokens ??
|
||||||
) || 0;
|
tokenUsage.breakdown?.input ??
|
||||||
const cacheReadTokens =
|
tokenUsage.promptTokens ??
|
||||||
Number(
|
|
||||||
tokenUsage.cacheReadTokens ??
|
|
||||||
tokenUsage.cache_read_input_tokens ??
|
|
||||||
tokenUsage.cacheReadInputTokens ??
|
|
||||||
0,
|
0,
|
||||||
) || 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 =
|
const outputTokens =
|
||||||
Number(
|
Number(
|
||||||
tokenUsage.outputTokens ??
|
tokenUsage.outputTokens ??
|
||||||
@@ -307,9 +288,8 @@ Custom commands can be created in:
|
|||||||
tokenUsage.completionTokens ??
|
tokenUsage.completionTokens ??
|
||||||
0,
|
0,
|
||||||
) || 0;
|
) || 0;
|
||||||
const computedUsed = inputTokens + outputTokens;
|
const hasTokenBreakdown = inputTokensRaw > 0 || outputTokens > 0;
|
||||||
const hasTokenBreakdown = computedUsed > 0;
|
const used = reportedUsed || inputTokensRaw + outputTokens;
|
||||||
const used = Math.max(reportedUsed, computedUsed);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "builtin",
|
type: "builtin",
|
||||||
@@ -322,7 +302,7 @@ Custom commands can be created in:
|
|||||||
...(hasTokenBreakdown
|
...(hasTokenBreakdown
|
||||||
? {
|
? {
|
||||||
tokenBreakdown: {
|
tokenBreakdown: {
|
||||||
input: inputTokens,
|
input: inputTokensRaw,
|
||||||
output: outputTokens,
|
output: outputTokens,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
|
|||||||
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
|
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One selectable model row in a provider model catalog.
|
* One selectable model row (matches the documentation `public/modelConstants.js` option shape).
|
||||||
*/
|
*/
|
||||||
export type ProviderModelOption = {
|
export type ProviderModelOption = {
|
||||||
value: string;
|
value: string;
|
||||||
|
|||||||
@@ -346,43 +346,6 @@ 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
//----------------- MCP CONFIG PARSING UTILITIES ------------
|
//----------------- MCP CONFIG PARSING UTILITIES ------------
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
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_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
|
||||||
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
||||||
|
|||||||
@@ -7,41 +7,6 @@ const runningPlugins = new Map();
|
|||||||
// Map<pluginName, Promise<port>> — in-flight start operations
|
// Map<pluginName, Promise<port>> — in-flight start operations
|
||||||
const startingPlugins = new Map();
|
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.
|
* Start a plugin's server subprocess.
|
||||||
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
|
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
|
||||||
@@ -61,9 +26,15 @@ export function startPluginServer(name, pluginDir, serverEntry) {
|
|||||||
|
|
||||||
const serverPath = path.join(pluginDir, serverEntry);
|
const serverPath = path.join(pluginDir, serverEntry);
|
||||||
|
|
||||||
|
// Restricted env — only essentials, no host secrets
|
||||||
const pluginProcess = spawn('node', [serverPath], {
|
const pluginProcess = spawn('node', [serverPath], {
|
||||||
cwd: pluginDir,
|
cwd: pluginDir,
|
||||||
env: buildPluginEnv(name),
|
env: {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
HOME: process.env.HOME,
|
||||||
|
NODE_ENV: process.env.NODE_ENV || 'production',
|
||||||
|
PLUGIN_NAME: name,
|
||||||
|
},
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
95
src/App.tsx
95
src/App.tsx
@@ -1,6 +1,5 @@
|
|||||||
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import { AuthProvider, ProtectedRoute } from './components/auth';
|
import { AuthProvider, ProtectedRoute } from './components/auth';
|
||||||
import { TaskMasterProvider } from './contexts/TaskMasterContext';
|
import { TaskMasterProvider } from './contexts/TaskMasterContext';
|
||||||
@@ -10,99 +9,7 @@ import { PluginsProvider } from './contexts/PluginsContext';
|
|||||||
import AppContent from './components/app/AppContent';
|
import AppContent from './components/app/AppContent';
|
||||||
import i18n from './i18n/config.js';
|
import i18n from './i18n/config.js';
|
||||||
|
|
||||||
const DEPLOYMENT_ASSET_DIRECTORIES = new Set(['assets', 'static', 'icons', 'images']);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect the router basename from explicit runtime config or deployment hints.
|
|
||||||
*
|
|
||||||
* CloudCLI can be served from a path prefix by a reverse proxy, for example:
|
|
||||||
* /ai/manifest.json
|
|
||||||
* /ai/assets/index-abc123.js
|
|
||||||
* /ai/icons/icon-192x192.png
|
|
||||||
*
|
|
||||||
* React Router needs that prefix as its basename, but the packaged app should
|
|
||||||
* also keep working when served directly from the domain root. The direct-root
|
|
||||||
* case is easy to misread because asset URLs such as /icons/icon-192x192.png
|
|
||||||
* contain a directory even though there is no application basename.
|
|
||||||
*/
|
|
||||||
function detectRouterBasename() {
|
|
||||||
const explicitBasename = typeof window !== 'undefined' ? window.__ROUTER_BASENAME__ || '' : '';
|
|
||||||
if (explicitBasename) {
|
|
||||||
// Keep the deployment escape hatch authoritative. A trailing slash is
|
|
||||||
// harmless for humans but React Router expects a normalized basename.
|
|
||||||
return explicitBasename.replace(/\/+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidatePaths = [
|
|
||||||
{ kind: 'manifest' as const, value: document.querySelector('link[rel="manifest"]')?.getAttribute('href') },
|
|
||||||
{ kind: 'script' as const, value: document.querySelector('script[type="module"][src]')?.getAttribute('src') },
|
|
||||||
...Array.from(
|
|
||||||
document.querySelectorAll(
|
|
||||||
'link[rel~="icon"][href], link[rel="apple-touch-icon"][href], link[rel="apple-touch-icon-precomposed"][href], link[rel="mask-icon"][href]'
|
|
||||||
)
|
|
||||||
).map((node) => ({
|
|
||||||
kind: 'icon' as const,
|
|
||||||
value: node.getAttribute('href'),
|
|
||||||
})),
|
|
||||||
].filter((candidate): candidate is { kind: 'manifest' | 'script' | 'icon'; value: string } => Boolean(candidate.value));
|
|
||||||
|
|
||||||
let detectedBasename = '';
|
|
||||||
for (const candidate of candidatePaths) {
|
|
||||||
try {
|
|
||||||
const candidateUrl = new URL(candidate.value, document.baseURI || window.location.href);
|
|
||||||
if (candidateUrl.origin !== window.location.origin) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathname = candidateUrl.pathname;
|
|
||||||
const normalizedPathname = pathname.replace(/\/+$/, '');
|
|
||||||
|
|
||||||
let normalized = '';
|
|
||||||
if (candidate.kind === 'script') {
|
|
||||||
const match = normalizedPathname.match(/^(.*)\/assets\//);
|
|
||||||
normalized = match?.[1] ? match[1].replace(/\/+$/, '') : '';
|
|
||||||
} else {
|
|
||||||
const manifestMatch = normalizedPathname.match(/^(.*)\/(?:manifest\.json|site\.webmanifest)$/);
|
|
||||||
const iconMatch = normalizedPathname.match(
|
|
||||||
/^(.*)\/(?:favicon(?:\.[^/]+)?|apple-touch-icon(?:-[^/]+)?(?:\.[^/]+)?|mask-icon(?:\.[^/]+)?|[^/]*icon[^/]*)$/
|
|
||||||
);
|
|
||||||
const match = candidate.kind === 'manifest' ? manifestMatch : iconMatch;
|
|
||||||
if (match?.[1]) {
|
|
||||||
const segments = match[1].split('/').filter(Boolean);
|
|
||||||
|
|
||||||
// Strip directories that describe where static files live, not where
|
|
||||||
// the app is mounted. This must also run for a single segment:
|
|
||||||
// /icons/icon-192x192.png -> ''
|
|
||||||
// /ai/icons/icon-192x192.png -> '/ai'
|
|
||||||
// The previous implementation only stripped while more than one
|
|
||||||
// segment remained, which incorrectly turned root deployments into a
|
|
||||||
// Router basename of /icons and caused a blank page after login.
|
|
||||||
while (segments.length > 0 && DEPLOYMENT_ASSET_DIRECTORIES.has(segments[segments.length - 1])) {
|
|
||||||
segments.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
normalized = segments.length > 0 ? `/${segments.join('/')}` : '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.length > detectedBasename.length) {
|
|
||||||
detectedBasename = normalized;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore invalid candidate URLs and continue checking other hints.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return detectedBasename;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const routerBasename = detectRouterBasename();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
@@ -112,7 +19,7 @@ export default function App() {
|
|||||||
<TasksSettingsProvider>
|
<TasksSettingsProvider>
|
||||||
<TaskMasterProvider>
|
<TaskMasterProvider>
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Router basename={routerBasename}>
|
<Router basename={window.__ROUTER_BASENAME__ || ''}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<AppContent />} />
|
<Route path="/" element={<AppContent />} />
|
||||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||||
|
|||||||
@@ -28,9 +28,12 @@ function AppContentInner() {
|
|||||||
const wasConnectedRef = useRef(false);
|
const wasConnectedRef = useRef(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
activeSessions,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
markSessionProcessing,
|
markSessionAsActive,
|
||||||
markSessionIdle,
|
markSessionAsInactive,
|
||||||
|
markSessionAsProcessing,
|
||||||
|
markSessionAsNotProcessing,
|
||||||
} = useSessionProtection();
|
} = useSessionProtection();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -54,7 +57,7 @@ function AppContentInner() {
|
|||||||
navigate,
|
navigate,
|
||||||
latestMessage,
|
latestMessage,
|
||||||
isMobile,
|
isMobile,
|
||||||
activeSessions: processingSessions,
|
activeSessions,
|
||||||
});
|
});
|
||||||
|
|
||||||
usePaletteOpsRegister({
|
usePaletteOpsRegister({
|
||||||
@@ -182,8 +185,10 @@ function AppContentInner() {
|
|||||||
onMenuClick={() => setSidebarOpen(true)}
|
onMenuClick={() => setSidebarOpen(true)}
|
||||||
isLoading={isLoadingProjects}
|
isLoading={isLoadingProjects}
|
||||||
onInputFocusChange={setIsInputFocused}
|
onInputFocusChange={setIsInputFocused}
|
||||||
onSessionProcessing={markSessionProcessing}
|
onSessionActive={markSessionAsActive}
|
||||||
onSessionIdle={markSessionIdle}
|
onSessionInactive={markSessionAsInactive}
|
||||||
|
onSessionProcessing={markSessionAsProcessing}
|
||||||
|
onSessionNotProcessing={markSessionAsNotProcessing}
|
||||||
processingSessions={processingSessions}
|
processingSessions={processingSessions}
|
||||||
onNavigateToSession={(targetSessionId: string, options) =>
|
onNavigateToSession={(targetSessionId: string, options) =>
|
||||||
navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) })
|
navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) })
|
||||||
|
|||||||
44
src/components/chat/constants/thinkingModes.ts
Normal file
44
src/components/chat/constants/thinkingModes.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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,8 +12,7 @@ import type {
|
|||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
|
import { thinkingModes } from '../constants/thinkingModes';
|
||||||
import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
|
||||||
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
||||||
import { safeLocalStorage } from '../utils/chatStorage';
|
import { safeLocalStorage } from '../utils/chatStorage';
|
||||||
import type {
|
import type {
|
||||||
@@ -27,6 +26,10 @@ import { escapeRegExp } from '../utils/chatFormatting';
|
|||||||
import { useFileMentions } from './useFileMentions';
|
import { useFileMentions } from './useFileMentions';
|
||||||
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
|
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
|
||||||
|
|
||||||
|
type PendingViewSession = {
|
||||||
|
startedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface UseChatComposerStateArgs {
|
interface UseChatComposerStateArgs {
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
@@ -44,12 +47,17 @@ interface UseChatComposerStateArgs {
|
|||||||
tokenBudget: Record<string, unknown> | null;
|
tokenBudget: Record<string, unknown> | null;
|
||||||
sendMessage: (message: unknown) => void;
|
sendMessage: (message: unknown) => void;
|
||||||
sendByCtrlEnter?: boolean;
|
sendByCtrlEnter?: boolean;
|
||||||
onSessionProcessing?: MarkSessionProcessing;
|
onSessionActive?: (sessionId?: string | null) => void;
|
||||||
|
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||||
onInputFocusChange?: (focused: boolean) => void;
|
onInputFocusChange?: (focused: boolean) => void;
|
||||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
|
pendingViewSessionRef: { current: PendingViewSession | null };
|
||||||
scrollToBottom: () => void;
|
scrollToBottom: () => void;
|
||||||
addMessage: (msg: ChatMessage) => 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;
|
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
|
||||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||||
}
|
}
|
||||||
@@ -170,12 +178,17 @@ export function useChatComposerState({
|
|||||||
tokenBudget,
|
tokenBudget,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
|
onSessionActive,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
onFileOpen,
|
onFileOpen,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
|
pendingViewSessionRef,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
addMessage,
|
addMessage,
|
||||||
|
setIsLoading,
|
||||||
|
setCanAbortSession,
|
||||||
|
setClaudeStatus,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
}: UseChatComposerStateArgs) {
|
}: UseChatComposerStateArgs) {
|
||||||
@@ -191,6 +204,7 @@ export function useChatComposerState({
|
|||||||
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
|
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
|
||||||
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
|
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
|
||||||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||||
|
const [thinkingMode, setThinkingMode] = useState('none');
|
||||||
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
|
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -299,7 +313,7 @@ export function useChatComposerState({
|
|||||||
}, [addMessage]);
|
}, [addMessage]);
|
||||||
|
|
||||||
const executeCommand = useCallback(
|
const executeCommand = useCallback(
|
||||||
async (command: SlashCommand, rawInput?: string, options?: { preserveInput?: boolean }) => {
|
async (command: SlashCommand, rawInput?: string) => {
|
||||||
if (!command || !selectedProject) {
|
if (!command || !selectedProject) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -356,10 +370,8 @@ export function useChatComposerState({
|
|||||||
const result = (await response.json()) as CommandExecutionResult;
|
const result = (await response.json()) as CommandExecutionResult;
|
||||||
if (result.type === 'builtin') {
|
if (result.type === 'builtin') {
|
||||||
handleBuiltInCommand(result);
|
handleBuiltInCommand(result);
|
||||||
if (!options?.preserveInput) {
|
setInput('');
|
||||||
setInput('');
|
inputValueRef.current = '';
|
||||||
inputValueRef.current = '';
|
|
||||||
}
|
|
||||||
} else if (result.type === 'custom') {
|
} else if (result.type === 'custom') {
|
||||||
await handleCustomCommand(result);
|
await handleCustomCommand(result);
|
||||||
}
|
}
|
||||||
@@ -390,19 +402,6 @@ 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 {
|
const {
|
||||||
slashCommands,
|
slashCommands,
|
||||||
slashCommandsCount,
|
slashCommandsCount,
|
||||||
@@ -563,7 +562,11 @@ export function useChatComposerState({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageContent = currentInput;
|
let messageContent = currentInput;
|
||||||
|
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
|
||||||
|
if (selectedThinkingMode && selectedThinkingMode.prefix) {
|
||||||
|
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
|
||||||
|
}
|
||||||
|
|
||||||
let uploadedImages: unknown[] = [];
|
let uploadedImages: unknown[] = [];
|
||||||
if (attachedImages.length > 0) {
|
if (attachedImages.length > 0) {
|
||||||
@@ -608,18 +611,27 @@ export function useChatComposerState({
|
|||||||
};
|
};
|
||||||
|
|
||||||
addMessage(userMessage);
|
addMessage(userMessage);
|
||||||
// Mark this request as processing in the per-session activity map (the
|
setIsLoading(true); // Processing banner starts
|
||||||
// single source of truth the indicator derives from). A brand-new
|
setCanAbortSession(true);
|
||||||
// conversation has no session id yet, so it is tracked under the
|
setClaudeStatus({
|
||||||
// pending placeholder until `session_created` announces the real id.
|
text: 'Processing',
|
||||||
onSessionProcessing?.(effectiveSessionId || PENDING_SESSION_ID, {
|
tokens: 0,
|
||||||
statusText: null,
|
can_interrupt: true,
|
||||||
canInterrupt: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
setTimeout(() => scrollToBottom(), 100);
|
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 = () => {
|
const getToolsSettings = () => {
|
||||||
try {
|
try {
|
||||||
const settingsKey =
|
const settingsKey =
|
||||||
@@ -737,6 +749,7 @@ export function useChatComposerState({
|
|||||||
setUploadingImages(new Map());
|
setUploadingImages(new Map());
|
||||||
setImageErrors(new Map());
|
setImageErrors(new Map());
|
||||||
setIsTextareaExpanded(false);
|
setIsTextareaExpanded(false);
|
||||||
|
setThinkingMode('none');
|
||||||
|
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto';
|
textareaRef.current.style.height = 'auto';
|
||||||
@@ -755,16 +768,22 @@ export function useChatComposerState({
|
|||||||
geminiModel,
|
geminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
onSessionActive,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
|
pendingViewSessionRef,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
provider,
|
provider,
|
||||||
resetCommandMenuState,
|
resetCommandMenuState,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
setCanAbortSession,
|
||||||
addMessage,
|
addMessage,
|
||||||
|
setClaudeStatus,
|
||||||
|
setIsLoading,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
|
thinkingMode,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -974,11 +993,15 @@ export function useChatComposerState({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setPendingPermissionRequests((previous) =>
|
setPendingPermissionRequests((previous) => {
|
||||||
previous.filter((request) => !validIds.includes(request.requestId)),
|
const next = previous.filter((request) => !validIds.includes(request.requestId));
|
||||||
);
|
if (next.length === 0) {
|
||||||
|
setClaudeStatus(null);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[sendMessage, setPendingPermissionRequests],
|
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
@@ -997,6 +1020,8 @@ export function useChatComposerState({
|
|||||||
textareaRef,
|
textareaRef,
|
||||||
inputHighlightRef,
|
inputHighlightRef,
|
||||||
isTextareaExpanded,
|
isTextareaExpanded,
|
||||||
|
thinkingMode,
|
||||||
|
setThinkingMode,
|
||||||
slashCommandsCount,
|
slashCommandsCount,
|
||||||
filteredCommands,
|
filteredCommands,
|
||||||
frequentCommands,
|
frequentCommands,
|
||||||
@@ -1034,6 +1059,5 @@ export function useChatComposerState({
|
|||||||
isInputFocused,
|
isInputFocused,
|
||||||
commandModalPayload,
|
commandModalPayload,
|
||||||
closeCommandModal,
|
closeCommandModal,
|
||||||
showCostModal,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ import type { NormalizedMessage } from '../../../stores/useSessionStore';
|
|||||||
import type { ChatMessage, SubagentChildTool } from '../types/types';
|
import type { ChatMessage, SubagentChildTool } from '../types/types';
|
||||||
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
|
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[]
|
* Convert NormalizedMessage[] from the session store into ChatMessage[]
|
||||||
* that the existing UI components expect.
|
* that the existing UI components expect.
|
||||||
@@ -26,12 +20,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
|||||||
|
|
||||||
// First pass: collect tool results for attachment
|
// First pass: collect tool results for attachment
|
||||||
const toolResultMap = new Map<string, NormalizedMessage>();
|
const toolResultMap = new Map<string, NormalizedMessage>();
|
||||||
const toolUseIds = new Set<string>();
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (msg.kind === 'tool_use' && msg.toolId) {
|
|
||||||
toolUseIds.add(msg.toolId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||||
toolResultMap.set(msg.toolId, msg);
|
toolResultMap.set(msg.toolId, msg);
|
||||||
}
|
}
|
||||||
@@ -108,7 +97,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
|||||||
|
|
||||||
const toolResult = tr
|
const toolResult = tr
|
||||||
? {
|
? {
|
||||||
content: formatToolResultContent(tr.content),
|
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
|
||||||
isError: Boolean(tr.isError),
|
isError: Boolean(tr.isError),
|
||||||
toolUseResult: (tr as any).toolUseResult,
|
toolUseResult: (tr as any).toolUseResult,
|
||||||
}
|
}
|
||||||
@@ -202,25 +191,8 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
// tool_result is handled via attachment to tool_use above
|
// 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;
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { useEffect, useRef } from 'react';
|
|||||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||||
|
|
||||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||||
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
|
|
||||||
import { playChatCompletionSound } from '../../../utils/notificationSound';
|
|
||||||
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
|
|
||||||
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
|
||||||
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
|
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
|
||||||
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||||
|
|
||||||
|
type PendingViewSession = {
|
||||||
|
startedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
type LatestChatMessage = {
|
type LatestChatMessage = {
|
||||||
type?: string;
|
type?: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
@@ -53,14 +53,18 @@ interface UseChatRealtimeHandlersArgs {
|
|||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
setCurrentSessionId: (sessionId: string | null) => void;
|
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;
|
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
||||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||||
|
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
||||||
streamTimerRef: MutableRefObject<number | null>;
|
streamTimerRef: MutableRefObject<number | null>;
|
||||||
accumulatedStreamRef: MutableRefObject<string>;
|
accumulatedStreamRef: MutableRefObject<string>;
|
||||||
/** When each session's `check-session-status` was last sent; guards stale idle replies. */
|
onSessionInactive?: (sessionId?: string | null) => void;
|
||||||
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
|
onSessionActive?: (sessionId?: string | null) => void;
|
||||||
onSessionProcessing?: MarkSessionProcessing;
|
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||||
onSessionIdle?: MarkSessionIdle;
|
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||||
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
|
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
|
||||||
onWebSocketReconnect?: () => void;
|
onWebSocketReconnect?: () => void;
|
||||||
sessionStore: SessionStore;
|
sessionStore: SessionStore;
|
||||||
@@ -76,13 +80,18 @@ export function useChatRealtimeHandlers({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
|
setIsLoading,
|
||||||
|
setCanAbortSession,
|
||||||
|
setClaudeStatus,
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
|
pendingViewSessionRef,
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
statusCheckSentAtRef,
|
onSessionInactive,
|
||||||
|
onSessionActive,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionIdle,
|
onSessionNotProcessing,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onWebSocketReconnect,
|
onWebSocketReconnect,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
@@ -127,24 +136,35 @@ export function useChatRealtimeHandlers({
|
|||||||
|
|
||||||
const status = msg.status;
|
const status = msg.status;
|
||||||
if (status) {
|
if (status) {
|
||||||
onSessionProcessing?.(statusSessionId, {
|
const statusInfo = {
|
||||||
statusText: status.text || null,
|
text: status.text || 'Working...',
|
||||||
canInterrupt: status.can_interrupt !== false,
|
tokens: status.tokens || 0,
|
||||||
});
|
can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
|
||||||
|
};
|
||||||
|
setClaudeStatus(statusInfo);
|
||||||
|
setIsLoading(true);
|
||||||
|
setCanAbortSession(statusInfo.can_interrupt);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reply to check-session-status (or unsolicited processing update)
|
// Legacy isProcessing format from check-session-status
|
||||||
|
const isCurrentSession =
|
||||||
|
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
|
||||||
|
|
||||||
if (msg.isProcessing) {
|
if (msg.isProcessing) {
|
||||||
|
onSessionActive?.(statusSessionId);
|
||||||
onSessionProcessing?.(statusSessionId);
|
onSessionProcessing?.(statusSessionId);
|
||||||
|
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle reply: ignore it if a newer request started after the check
|
onSessionInactive?.(statusSessionId);
|
||||||
// was sent — the reply describes the older request.
|
onSessionNotProcessing?.(statusSessionId);
|
||||||
onSessionIdle?.(statusSessionId, {
|
if (isCurrentSession) {
|
||||||
ifStartedBefore: statusCheckSentAtRef.current.get(statusSessionId),
|
setIsLoading(false);
|
||||||
});
|
setCanAbortSession(false);
|
||||||
|
setClaudeStatus(null);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,15 +236,23 @@ export function useChatRealtimeHandlers({
|
|||||||
// We no longer synthesize client-side placeholder IDs. Until the provider
|
// We no longer synthesize client-side placeholder IDs. Until the provider
|
||||||
// announces `session_created`, the active id is expected to be null.
|
// announces `session_created`, the active id is expected to be null.
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
|
console.log('Session created with ID:', newSessionId);
|
||||||
|
console.log('Existing session ID:', currentSessionId);
|
||||||
setCurrentSessionId(newSessionId);
|
setCurrentSessionId(newSessionId);
|
||||||
setPendingPermissionRequests((prev) =>
|
setPendingPermissionRequests((prev) =>
|
||||||
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
|
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// The in-flight request now has a concrete session id: migrate the
|
pendingViewSessionRef.current = null;
|
||||||
// processing entry from the pending placeholder.
|
onSessionActive?.(newSessionId);
|
||||||
onSessionIdle?.(PENDING_SESSION_ID);
|
|
||||||
onSessionProcessing?.(newSessionId);
|
onSessionProcessing?.(newSessionId);
|
||||||
|
setIsLoading(true);
|
||||||
|
setCanAbortSession(true);
|
||||||
|
setClaudeStatus({
|
||||||
|
text: 'Processing',
|
||||||
|
tokens: 0,
|
||||||
|
can_interrupt: true,
|
||||||
|
});
|
||||||
onNavigateToSession?.(newSessionId);
|
onNavigateToSession?.(newSessionId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -241,28 +269,22 @@ export function useChatRealtimeHandlers({
|
|||||||
}
|
}
|
||||||
accumulatedStreamRef.current = '';
|
accumulatedStreamRef.current = '';
|
||||||
|
|
||||||
// `complete` is the unified terminal event — every provider run ends
|
setIsLoading(false);
|
||||||
// with exactly one, regardless of success, failure, or abort. The
|
setCanAbortSession(false);
|
||||||
// indicator derives from the processing map, so deleting the entry
|
setClaudeStatus(null);
|
||||||
// hides it immediately and atomically.
|
|
||||||
onSessionIdle?.(sid);
|
|
||||||
onSessionIdle?.(PENDING_SESSION_ID);
|
|
||||||
setPendingPermissionRequests([]);
|
setPendingPermissionRequests([]);
|
||||||
|
onSessionInactive?.(sid);
|
||||||
|
onSessionNotProcessing?.(sid);
|
||||||
|
pendingViewSessionRef.current = null;
|
||||||
|
|
||||||
// Handle aborted case
|
// Handle aborted case
|
||||||
if (msg.aborted) {
|
if (msg.aborted) {
|
||||||
// Abort was requested — the complete event confirms it
|
// Abort was requested — the complete event confirms it
|
||||||
// No special UI action needed beyond clearing the processing entry above
|
// No special UI action needed beyond clearing loading state above
|
||||||
// The backend already sent any abort-related messages
|
// The backend already sent any abort-related messages
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Celebrate only successful runs (failed runs end with success: false).
|
|
||||||
if (msg.success !== false) {
|
|
||||||
showCompletionTitleIndicator();
|
|
||||||
void playChatCompletionSound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const actualSessionId =
|
const actualSessionId =
|
||||||
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
||||||
? msg.actualSessionId
|
? msg.actualSessionId
|
||||||
@@ -275,7 +297,6 @@ export function useChatRealtimeHandlers({
|
|||||||
|
|
||||||
if (actualSessionId && sid && actualSessionId !== sid) {
|
if (actualSessionId && sid && actualSessionId !== sid) {
|
||||||
sessionStore.replaceSessionId(sid, actualSessionId);
|
sessionStore.replaceSessionId(sid, actualSessionId);
|
||||||
onSessionIdle?.(actualSessionId);
|
|
||||||
|
|
||||||
if (isVisibleSession) {
|
if (isVisibleSession) {
|
||||||
setCurrentSessionId(actualSessionId);
|
setCurrentSessionId(actualSessionId);
|
||||||
@@ -291,9 +312,15 @@ export function useChatRealtimeHandlers({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'error' is an informational message row, not a terminal event —
|
case 'error': {
|
||||||
// providers emit it for mid-run stderr output too. Run teardown is
|
setIsLoading(false);
|
||||||
// always signalled by the unified 'complete' that follows.
|
setCanAbortSession(false);
|
||||||
|
setClaudeStatus(null);
|
||||||
|
onSessionInactive?.(sid);
|
||||||
|
onSessionNotProcessing?.(sid);
|
||||||
|
pendingViewSessionRef.current = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'permission_request': {
|
case 'permission_request': {
|
||||||
if (!msg.requestId) break;
|
if (!msg.requestId) break;
|
||||||
@@ -308,7 +335,9 @@ export function useChatRealtimeHandlers({
|
|||||||
receivedAt: new Date(),
|
receivedAt: new Date(),
|
||||||
}];
|
}];
|
||||||
});
|
});
|
||||||
onSessionProcessing?.(sid || PENDING_SESSION_ID);
|
setIsLoading(true);
|
||||||
|
setCanAbortSession(true);
|
||||||
|
setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,10 +352,13 @@ export function useChatRealtimeHandlers({
|
|||||||
if (msg.text === 'token_budget' && msg.tokenBudget) {
|
if (msg.text === 'token_budget' && msg.tokenBudget) {
|
||||||
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
|
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
|
||||||
} else if (msg.text) {
|
} else if (msg.text) {
|
||||||
onSessionProcessing?.(sid || PENDING_SESSION_ID, {
|
setClaudeStatus({
|
||||||
statusText: msg.text,
|
text: msg.text,
|
||||||
canInterrupt: msg.canInterrupt !== false,
|
tokens: msg.tokens || 0,
|
||||||
|
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
|
||||||
});
|
});
|
||||||
|
setIsLoading(true);
|
||||||
|
setCanAbortSession(msg.canInterrupt !== false);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -342,13 +374,18 @@ export function useChatRealtimeHandlers({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
|
setIsLoading,
|
||||||
|
setCanAbortSession,
|
||||||
|
setClaudeStatus,
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
|
pendingViewSessionRef,
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
statusCheckSentAtRef,
|
onSessionInactive,
|
||||||
|
onSessionActive,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionIdle,
|
onSessionNotProcessing,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onWebSocketReconnect,
|
onWebSocketReconnect,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
|||||||
import type { MutableRefObject } from 'react';
|
import type { MutableRefObject } from 'react';
|
||||||
|
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
|
|
||||||
import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection';
|
|
||||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||||
import type { ChatMessage, Provider } from '../types/types';
|
import type { ChatMessage, Provider } from '../types/types';
|
||||||
@@ -14,6 +12,10 @@ import { normalizedToChatMessages } from './useChatMessages';
|
|||||||
const MESSAGES_PER_PAGE = 20;
|
const MESSAGES_PER_PAGE = 20;
|
||||||
const INITIAL_VISIBLE_MESSAGES = 100;
|
const INITIAL_VISIBLE_MESSAGES = 100;
|
||||||
|
|
||||||
|
type PendingViewSession = {
|
||||||
|
startedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface UseChatSessionStateArgs {
|
interface UseChatSessionStateArgs {
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
@@ -22,11 +24,9 @@ interface UseChatSessionStateArgs {
|
|||||||
autoScrollToBottom?: boolean;
|
autoScrollToBottom?: boolean;
|
||||||
externalMessageUpdate?: number;
|
externalMessageUpdate?: number;
|
||||||
newSessionTrigger?: number;
|
newSessionTrigger?: number;
|
||||||
processingSessions?: SessionActivityMap;
|
processingSessions?: Set<string>;
|
||||||
onSessionIdle?: MarkSessionIdle;
|
|
||||||
resetStreamingState: () => void;
|
resetStreamingState: () => void;
|
||||||
/** When each session's `check-session-status` was last sent; guards stale idle replies. */
|
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
||||||
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
|
|
||||||
sessionStore: SessionStore;
|
sessionStore: SessionStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,19 +99,21 @@ export function useChatSessionState({
|
|||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
onSessionIdle,
|
|
||||||
resetStreamingState,
|
resetStreamingState,
|
||||||
statusCheckSentAtRef,
|
pendingViewSessionRef,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
}: UseChatSessionStateArgs) {
|
}: UseChatSessionStateArgs) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
|
||||||
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
||||||
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
|
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
|
||||||
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
||||||
const [totalMessages, setTotalMessages] = useState(0);
|
const [totalMessages, setTotalMessages] = useState(0);
|
||||||
|
const [canAbortSession, setCanAbortSession] = useState(false);
|
||||||
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
||||||
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
|
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
|
||||||
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
|
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 [allMessagesLoaded, setAllMessagesLoaded] = useState(false);
|
||||||
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
|
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
|
||||||
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
|
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
|
||||||
@@ -168,7 +170,10 @@ export function useChatSessionState({
|
|||||||
* - No coupling to unrelated external update signals.
|
* - No coupling to unrelated external update signals.
|
||||||
*/
|
*/
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
onSessionIdle?.(PENDING_SESSION_ID);
|
pendingViewSessionRef.current = null;
|
||||||
|
setClaudeStatus(null);
|
||||||
|
setCanAbortSession(false);
|
||||||
|
setIsLoading(false);
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
setPendingUserMessage(null);
|
setPendingUserMessage(null);
|
||||||
sessionStorage.removeItem('cursorSessionId');
|
sessionStorage.removeItem('cursorSessionId');
|
||||||
@@ -199,29 +204,13 @@ export function useChatSessionState({
|
|||||||
clearTimeout(loadAllFinishedTimerRef.current);
|
clearTimeout(loadAllFinishedTimerRef.current);
|
||||||
loadAllFinishedTimerRef.current = null;
|
loadAllFinishedTimerRef.current = null;
|
||||||
}
|
}
|
||||||
}, [newSessionTrigger, onSessionIdle, resetStreamingState]);
|
}, [newSessionTrigger, pendingViewSessionRef, 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 (or of the pending not-yet-created session on a fresh
|
|
||||||
// draft) — never stale local UI state from the last time it was open.
|
|
||||||
const sessionActivity = processingSessions?.get(activeSessionId ?? PENDING_SESSION_ID) ?? 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 */
|
/* Derive chatMessages from the store */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const activeSessionId = selectedSession?.id || currentSessionId || null;
|
||||||
const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);
|
const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);
|
||||||
const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null);
|
const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null);
|
||||||
|
|
||||||
@@ -394,59 +383,28 @@ export function useChatSessionState({
|
|||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
}, [selectedProject?.projectId, selectedSession?.id]);
|
}, [selectedProject?.projectId, selectedSession?.id]);
|
||||||
|
|
||||||
// Initial scroll to bottom — robust to lazy content reflow.
|
// Initial scroll to bottom
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
|
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
|
||||||
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
|
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
|
||||||
if (searchScrollActiveRef.current) { pendingInitialScrollRef.current = false; return; }
|
pendingInitialScrollRef.current = false;
|
||||||
|
if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200);
|
||||||
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]);
|
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
||||||
|
|
||||||
// Main session loading effect — store-based
|
// Main session loading effect — store-based
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSession || !selectedProject) {
|
if (!selectedSession || !selectedProject) {
|
||||||
// A new provider run can be in flight before the router has a canonical
|
// A new provider run can be in flight before the router has a canonical
|
||||||
// selectedSession. Keep the draft view intact until complete/error.
|
// selectedSession. Keep the processing banner alive until complete/error.
|
||||||
if (processingSessionsRef.current?.has(PENDING_SESSION_ID)) {
|
if (pendingViewSessionRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
|
pendingViewSessionRef.current = null;
|
||||||
|
setClaudeStatus(null);
|
||||||
|
setCanAbortSession(false);
|
||||||
|
setIsLoading(false);
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
sessionStorage.removeItem('cursorSessionId');
|
sessionStorage.removeItem('cursorSessionId');
|
||||||
messagesOffsetRef.current = 0;
|
messagesOffsetRef.current = 0;
|
||||||
@@ -468,6 +426,9 @@ export function useChatSessionState({
|
|||||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
||||||
if (sessionChanged) {
|
if (sessionChanged) {
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
|
pendingViewSessionRef.current = null;
|
||||||
|
setClaudeStatus(null);
|
||||||
|
setCanAbortSession(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset pagination/scroll state
|
// Reset pagination/scroll state
|
||||||
@@ -486,6 +447,7 @@ export function useChatSessionState({
|
|||||||
|
|
||||||
if (sessionChanged) {
|
if (sessionChanged) {
|
||||||
setTokenBudget(null);
|
setTokenBudget(null);
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentSessionId(selectedSession.id);
|
setCurrentSessionId(selectedSession.id);
|
||||||
@@ -493,11 +455,8 @@ export function useChatSessionState({
|
|||||||
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconcile processing state with the server. Recording the send time
|
// Check session status
|
||||||
// lets the reply handler discard idle replies that a newer request has
|
|
||||||
// since outdated.
|
|
||||||
if (ws) {
|
if (ws) {
|
||||||
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
|
|
||||||
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });
|
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,11 +481,11 @@ export function useChatSessionState({
|
|||||||
setIsLoadingSessionMessages(false);
|
setIsLoadingSessionMessages(false);
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
|
pendingViewSessionRef,
|
||||||
resetStreamingState,
|
resetStreamingState,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession?.id,
|
selectedSession?.id,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
statusCheckSentAtRef,
|
|
||||||
ws,
|
ws,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
]);
|
]);
|
||||||
@@ -540,7 +499,7 @@ export function useChatSessionState({
|
|||||||
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||||
|
|
||||||
// Skip store refresh during active streaming
|
// Skip store refresh during active streaming
|
||||||
if (!isProcessing) {
|
if (!isLoading) {
|
||||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||||
provider: (selectedSession.__provider || provider) as LLMProvider,
|
provider: (selectedSession.__provider || provider) as LLMProvider,
|
||||||
projectId: selectedProject.projectId,
|
projectId: selectedProject.projectId,
|
||||||
@@ -565,7 +524,7 @@ export function useChatSessionState({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
isProcessing,
|
isLoading,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Search navigation target
|
// Search navigation target
|
||||||
@@ -732,6 +691,16 @@ export function useChatSessionState({
|
|||||||
return () => container.removeEventListener('scroll', handleScroll);
|
return () => container.removeEventListener('scroll', handleScroll);
|
||||||
}, [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
|
// "Load all" overlay
|
||||||
const prevLoadingRef = useRef(false);
|
const prevLoadingRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -813,15 +782,16 @@ export function useChatSessionState({
|
|||||||
addMessage,
|
addMessage,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
rewindMessages,
|
rewindMessages,
|
||||||
sessionActivity,
|
isLoading,
|
||||||
isProcessing,
|
setIsLoading,
|
||||||
canAbortSession,
|
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
isLoadingSessionMessages,
|
isLoadingSessionMessages,
|
||||||
isLoadingMoreMessages,
|
isLoadingMoreMessages,
|
||||||
hasMoreMessages,
|
hasMoreMessages,
|
||||||
totalMessages,
|
totalMessages,
|
||||||
|
canAbortSession,
|
||||||
|
setCanAbortSession,
|
||||||
isUserScrolledUp,
|
isUserScrolledUp,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
@@ -834,6 +804,8 @@ export function useChatSessionState({
|
|||||||
isLoadingAllMessages,
|
isLoadingAllMessages,
|
||||||
loadAllJustFinished,
|
loadAllJustFinished,
|
||||||
showLoadAllOverlay,
|
showLoadAllOverlay,
|
||||||
|
claudeStatus,
|
||||||
|
setClaudeStatus,
|
||||||
createDiff,
|
createDiff,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
|
|||||||
@@ -393,8 +393,7 @@ export function useSlashCommands({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match / at start of input OR after whitespace, capturing the /word up to cursor.
|
const slashPattern = /^\/(\S*)$/;
|
||||||
const slashPattern = /(?:^|\s)(\/\S*)$/;
|
|
||||||
const match = textBeforeCursor.match(slashPattern);
|
const match = textBeforeCursor.match(slashPattern);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -402,9 +401,8 @@ export function useSlashCommands({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute actual position of / in the full input string.
|
const slashPos = 0;
|
||||||
const slashPos = match.index! + (match[0].length - match[1].length);
|
const query = match[1];
|
||||||
const query = match[1].slice(1); // strip leading /
|
|
||||||
|
|
||||||
setSlashPosition(slashPos);
|
setSlashPosition(slashPos);
|
||||||
setShowCommandMenu(true);
|
setShowCommandMenu(true);
|
||||||
|
|||||||
@@ -564,15 +564,11 @@ export function shouldHideToolResult(toolName: string, toolResult: any): boolean
|
|||||||
|
|
||||||
if (!config.result) return false;
|
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
|
// Always hidden
|
||||||
if (config.result.hidden) return true;
|
if (config.result.hidden) return true;
|
||||||
|
|
||||||
// Hide on success only
|
// Hide on success only
|
||||||
if (config.result.hideOnSuccess && toolResult) {
|
if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||||
import type {
|
|
||||||
MarkSessionIdle,
|
|
||||||
MarkSessionProcessing,
|
|
||||||
SessionActivityMap,
|
|
||||||
} from '../../../hooks/useSessionProtection';
|
|
||||||
|
|
||||||
export type Provider = LLMProvider;
|
export type Provider = LLMProvider;
|
||||||
|
|
||||||
@@ -115,9 +110,11 @@ export interface ChatInterfaceProps {
|
|||||||
latestMessage: any;
|
latestMessage: any;
|
||||||
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
||||||
onInputFocusChange?: (focused: boolean) => void;
|
onInputFocusChange?: (focused: boolean) => void;
|
||||||
onSessionProcessing?: MarkSessionProcessing;
|
onSessionActive?: (sessionId?: string | null) => void;
|
||||||
onSessionIdle?: MarkSessionIdle;
|
onSessionInactive?: (sessionId?: string | null) => void;
|
||||||
processingSessions?: SessionActivityMap;
|
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||||
|
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||||
|
processingSessions?: Set<string>;
|
||||||
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
autoExpandTools?: boolean;
|
autoExpandTools?: boolean;
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import ChatComposer from './subcomponents/ChatComposer';
|
|||||||
import CommandResultModal from './subcomponents/CommandResultModal';
|
import CommandResultModal from './subcomponents/CommandResultModal';
|
||||||
|
|
||||||
|
|
||||||
|
type PendingViewSession = {
|
||||||
|
startedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
function ChatInterface({
|
function ChatInterface({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -25,8 +29,10 @@ function ChatInterface({
|
|||||||
latestMessage,
|
latestMessage,
|
||||||
onFileOpen,
|
onFileOpen,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
|
onSessionActive,
|
||||||
|
onSessionInactive,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionIdle,
|
onSessionNotProcessing,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
@@ -45,9 +51,7 @@ function ChatInterface({
|
|||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
const streamTimerRef = useRef<number | null>(null);
|
const streamTimerRef = useRef<number | null>(null);
|
||||||
const accumulatedStreamRef = useRef('');
|
const accumulatedStreamRef = useRef('');
|
||||||
// When each session's `check-session-status` was last sent; idle replies
|
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
|
||||||
// older than a later local request are discarded as stale.
|
|
||||||
const statusCheckSentAtRef = useRef(new Map<string, number>());
|
|
||||||
|
|
||||||
const resetStreamingState = useCallback(() => {
|
const resetStreamingState = useCallback(() => {
|
||||||
if (streamTimerRef.current) {
|
if (streamTimerRef.current) {
|
||||||
@@ -88,15 +92,16 @@ function ChatInterface({
|
|||||||
const {
|
const {
|
||||||
chatMessages,
|
chatMessages,
|
||||||
addMessage,
|
addMessage,
|
||||||
sessionActivity,
|
isLoading,
|
||||||
isProcessing,
|
setIsLoading,
|
||||||
canAbortSession,
|
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
isLoadingSessionMessages,
|
isLoadingSessionMessages,
|
||||||
isLoadingMoreMessages,
|
isLoadingMoreMessages,
|
||||||
hasMoreMessages,
|
hasMoreMessages,
|
||||||
totalMessages,
|
totalMessages,
|
||||||
|
canAbortSession,
|
||||||
|
setCanAbortSession,
|
||||||
isUserScrolledUp,
|
isUserScrolledUp,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
@@ -109,6 +114,8 @@ function ChatInterface({
|
|||||||
isLoadingAllMessages,
|
isLoadingAllMessages,
|
||||||
loadAllJustFinished,
|
loadAllJustFinished,
|
||||||
showLoadAllOverlay,
|
showLoadAllOverlay,
|
||||||
|
claudeStatus,
|
||||||
|
setClaudeStatus,
|
||||||
createDiff,
|
createDiff,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
@@ -123,9 +130,8 @@ function ChatInterface({
|
|||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
onSessionIdle,
|
|
||||||
resetStreamingState,
|
resetStreamingState,
|
||||||
statusCheckSentAtRef,
|
pendingViewSessionRef,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,6 +141,8 @@ function ChatInterface({
|
|||||||
textareaRef,
|
textareaRef,
|
||||||
inputHighlightRef,
|
inputHighlightRef,
|
||||||
isTextareaExpanded,
|
isTextareaExpanded,
|
||||||
|
thinkingMode,
|
||||||
|
setThinkingMode,
|
||||||
slashCommandsCount,
|
slashCommandsCount,
|
||||||
filteredCommands,
|
filteredCommands,
|
||||||
frequentCommands,
|
frequentCommands,
|
||||||
@@ -172,7 +180,6 @@ function ChatInterface({
|
|||||||
isInputFocused: _isInputFocused,
|
isInputFocused: _isInputFocused,
|
||||||
commandModalPayload,
|
commandModalPayload,
|
||||||
closeCommandModal,
|
closeCommandModal,
|
||||||
showCostModal,
|
|
||||||
} = useChatComposerState({
|
} = useChatComposerState({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -185,40 +192,40 @@ function ChatInterface({
|
|||||||
codexModel,
|
codexModel,
|
||||||
geminiModel,
|
geminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
isLoading: isProcessing,
|
isLoading,
|
||||||
canAbortSession,
|
canAbortSession,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
|
onSessionActive,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
onFileOpen,
|
onFileOpen,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
|
pendingViewSessionRef,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
addMessage,
|
addMessage,
|
||||||
|
setIsLoading,
|
||||||
|
setCanAbortSession,
|
||||||
|
setClaudeStatus,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
});
|
});
|
||||||
|
|
||||||
// On WebSocket reconnect, re-fetch the current session's messages from the
|
// On WebSocket reconnect, re-fetch the current session's messages from the server
|
||||||
// server so missed streaming events are shown, then re-check the session's
|
// so missed streaming events are shown. Also reset isLoading.
|
||||||
// processing status — the authoritative reply restores or clears the
|
|
||||||
// activity indicator depending on whether the run is still active.
|
|
||||||
const handleWebSocketReconnect = useCallback(async () => {
|
const handleWebSocketReconnect = useCallback(async () => {
|
||||||
if (!selectedProject || !selectedSession) return;
|
if (!selectedProject || !selectedSession) return;
|
||||||
const providerVal =
|
const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
|
||||||
selectedSession.__provider
|
|
||||||
|| (localStorage.getItem('selected-provider') as LLMProvider)
|
|
||||||
|| 'claude';
|
|
||||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||||
provider: providerVal as LLMProvider,
|
provider: (selectedSession.__provider || providerVal) as LLMProvider,
|
||||||
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
|
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
|
||||||
projectId: selectedProject.projectId,
|
projectId: selectedProject.projectId,
|
||||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||||
});
|
});
|
||||||
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
|
setIsLoading(false);
|
||||||
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider: providerVal });
|
setCanAbortSession(false);
|
||||||
}, [selectedProject, selectedSession, sendMessage, sessionStore]);
|
}, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]);
|
||||||
|
|
||||||
useChatRealtimeHandlers({
|
useChatRealtimeHandlers({
|
||||||
latestMessage,
|
latestMessage,
|
||||||
@@ -226,20 +233,25 @@ function ChatInterface({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
|
setIsLoading,
|
||||||
|
setCanAbortSession,
|
||||||
|
setClaudeStatus,
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
|
pendingViewSessionRef,
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
statusCheckSentAtRef,
|
onSessionInactive,
|
||||||
|
onSessionActive,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionIdle,
|
onSessionNotProcessing,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onWebSocketReconnect: handleWebSocketReconnect,
|
onWebSocketReconnect: handleWebSocketReconnect,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canAbortSession) {
|
if (!isLoading || !canAbortSession) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +268,7 @@ function ChatInterface({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
|
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
|
||||||
};
|
};
|
||||||
}, [canAbortSession, handleAbortSession]);
|
}, [canAbortSession, handleAbortSession, isLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -351,13 +363,15 @@ function ChatInterface({
|
|||||||
pendingPermissionRequests={pendingPermissionRequests}
|
pendingPermissionRequests={pendingPermissionRequests}
|
||||||
handlePermissionDecision={handlePermissionDecision}
|
handlePermissionDecision={handlePermissionDecision}
|
||||||
handleGrantToolPermission={handleGrantToolPermission}
|
handleGrantToolPermission={handleGrantToolPermission}
|
||||||
activity={sessionActivity}
|
claudeStatus={claudeStatus}
|
||||||
isLoading={isProcessing}
|
isLoading={isLoading}
|
||||||
onAbortSession={handleAbortSession}
|
onAbortSession={handleAbortSession}
|
||||||
|
provider={provider}
|
||||||
permissionMode={permissionMode}
|
permissionMode={permissionMode}
|
||||||
onModeSwitch={cyclePermissionMode}
|
onModeSwitch={cyclePermissionMode}
|
||||||
|
thinkingMode={thinkingMode}
|
||||||
|
setThinkingMode={setThinkingMode}
|
||||||
tokenBudget={tokenBudget}
|
tokenBudget={tokenBudget}
|
||||||
onShowTokenUsage={showCostModal}
|
|
||||||
slashCommandsCount={slashCommandsCount}
|
slashCommandsCount={slashCommandsCount}
|
||||||
onToggleCommandMenu={handleToggleCommandMenu}
|
onToggleCommandMenu={handleToggleCommandMenu}
|
||||||
hasInput={Boolean(input.trim())}
|
hasInput={Boolean(input.trim())}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
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,17 +2,23 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import type {
|
import type {
|
||||||
ChangeEvent,
|
ChangeEvent,
|
||||||
ClipboardEvent,
|
ClipboardEvent,
|
||||||
|
Dispatch,
|
||||||
FormEvent,
|
FormEvent,
|
||||||
KeyboardEvent,
|
KeyboardEvent,
|
||||||
MouseEvent,
|
MouseEvent,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
RefObject,
|
RefObject,
|
||||||
|
SetStateAction,
|
||||||
TouchEvent,
|
TouchEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
|
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
|
||||||
|
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
||||||
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
import CommandMenu from './CommandMenu';
|
||||||
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
|
import ClaudeStatus from './ClaudeStatus';
|
||||||
|
import ImageAttachment from './ImageAttachment';
|
||||||
|
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||||
|
import ThinkingModeSelector from './ThinkingModeSelector';
|
||||||
|
import TokenUsageSummary from './TokenUsageSummary';
|
||||||
import {
|
import {
|
||||||
PromptInput,
|
PromptInput,
|
||||||
PromptInputHeader,
|
PromptInputHeader,
|
||||||
@@ -24,12 +30,6 @@ import {
|
|||||||
PromptInputSubmit,
|
PromptInputSubmit,
|
||||||
} from '../../../../shared/view/ui';
|
} 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 {
|
interface MentionableFile {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -52,13 +52,15 @@ interface ChatComposerProps {
|
|||||||
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
|
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
|
||||||
) => void;
|
) => void;
|
||||||
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
||||||
activity: SessionActivity | null;
|
claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onAbortSession: () => void;
|
onAbortSession: () => void;
|
||||||
|
provider: Provider | string;
|
||||||
permissionMode: PermissionMode | string;
|
permissionMode: PermissionMode | string;
|
||||||
onModeSwitch: () => void;
|
onModeSwitch: () => void;
|
||||||
|
thinkingMode: string;
|
||||||
|
setThinkingMode: Dispatch<SetStateAction<string>>;
|
||||||
tokenBudget: Record<string, unknown> | null;
|
tokenBudget: Record<string, unknown> | null;
|
||||||
onShowTokenUsage: () => void;
|
|
||||||
slashCommandsCount: number;
|
slashCommandsCount: number;
|
||||||
onToggleCommandMenu: () => void;
|
onToggleCommandMenu: () => void;
|
||||||
hasInput: boolean;
|
hasInput: boolean;
|
||||||
@@ -105,13 +107,15 @@ export default function ChatComposer({
|
|||||||
pendingPermissionRequests,
|
pendingPermissionRequests,
|
||||||
handlePermissionDecision,
|
handlePermissionDecision,
|
||||||
handleGrantToolPermission,
|
handleGrantToolPermission,
|
||||||
activity,
|
claudeStatus,
|
||||||
isLoading,
|
isLoading,
|
||||||
onAbortSession,
|
onAbortSession,
|
||||||
|
provider,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
onModeSwitch,
|
onModeSwitch,
|
||||||
|
thinkingMode,
|
||||||
|
setThinkingMode,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
onShowTokenUsage,
|
|
||||||
slashCommandsCount,
|
slashCommandsCount,
|
||||||
onToggleCommandMenu,
|
onToggleCommandMenu,
|
||||||
hasInput,
|
hasInput,
|
||||||
@@ -172,7 +176,12 @@ export default function ChatComposer({
|
|||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
|
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
|
||||||
{!hasPendingPermissions && (
|
{!hasPendingPermissions && (
|
||||||
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
|
<ClaudeStatus
|
||||||
|
status={claudeStatus}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onAbort={onAbortSession}
|
||||||
|
provider={provider}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pendingPermissionRequests.length > 0 && (
|
{pendingPermissionRequests.length > 0 && (
|
||||||
@@ -286,7 +295,6 @@ export default function ChatComposer({
|
|||||||
|
|
||||||
<PromptInputTextarea
|
<PromptInputTextarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
dir="auto"
|
|
||||||
value={input}
|
value={input}
|
||||||
onChange={onInputChange}
|
onChange={onInputChange}
|
||||||
onClick={onTextareaClick}
|
onClick={onTextareaClick}
|
||||||
@@ -349,7 +357,11 @@ export default function ChatComposer({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
|
{provider === 'claude' && (
|
||||||
|
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TokenUsageSummary usage={tokenBudget} />
|
||||||
|
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
tooltip={{ content: t('input.showAllCommands') }}
|
tooltip={{ content: t('input.showAllCommands') }}
|
||||||
@@ -370,7 +382,7 @@ export default function ChatComposer({
|
|||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
|
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
|
||||||
onClick={onClearInput}
|
onClick={onClearInput}
|
||||||
className="hidden sm:flex"
|
className="hidden sm:No-flex"
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
@@ -387,9 +399,16 @@ export default function ChatComposer({
|
|||||||
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
|
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
|
||||||
</div>
|
</div>
|
||||||
<PromptInputSubmit
|
<PromptInputSubmit
|
||||||
onClick={isLoading ? onAbortSession : undefined}
|
disabled={!input.trim() || isLoading}
|
||||||
disabled={!isLoading && !input.trim()}
|
|
||||||
className="h-10 w-10 sm:h-10 sm:w-10"
|
className="h-10 w-10 sm:h-10 sm:w-10"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit(event as unknown as MouseEvent<HTMLButtonElement>);
|
||||||
|
}}
|
||||||
|
onTouchStart={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit(event as unknown as TouchEvent<HTMLButtonElement>);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PromptInputFooter>
|
</PromptInputFooter>
|
||||||
|
|||||||
130
src/components/chat/view/subcomponents/ClaudeStatus.tsx
Normal file
130
src/components/chat/view/subcomponents/ClaudeStatus.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
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,6 +1,5 @@
|
|||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
@@ -9,10 +8,10 @@ import type {
|
|||||||
Provider,
|
Provider,
|
||||||
} from '../../types/types';
|
} from '../../types/types';
|
||||||
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
||||||
|
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
||||||
import type { Project } from '../../../../types/app';
|
import type { Project } from '../../../../types/app';
|
||||||
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
||||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
|
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
|
||||||
|
|
||||||
import { Markdown } from './Markdown';
|
import { Markdown } from './Markdown';
|
||||||
import MessageCopyControl from './MessageCopyControl';
|
import MessageCopyControl from './MessageCopyControl';
|
||||||
|
|
||||||
@@ -42,9 +41,10 @@ type InteractiveOption = {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PermissionGrantState = 'idle' | 'granted' | 'error';
|
||||||
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
||||||
|
|
||||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||||
((prevMessage.type === 'assistant') ||
|
((prevMessage.type === 'assistant') ||
|
||||||
@@ -53,6 +53,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
(prevMessage.type === 'error'));
|
(prevMessage.type === 'error'));
|
||||||
const messageRef = useRef<HTMLDivElement | null>(null);
|
const messageRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||||
|
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
|
||||||
const userCopyContent = String(message.content || '');
|
const userCopyContent = String(message.content || '');
|
||||||
const formattedMessageContent = useMemo(
|
const formattedMessageContent = useMemo(
|
||||||
() => formatUsageLimitText(String(message.content || '')),
|
() => formatUsageLimitText(String(message.content || '')),
|
||||||
@@ -71,6 +73,10 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
!message.isThinking;
|
!message.isThinking;
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPermissionGrantState('idle');
|
||||||
|
}, [permissionSuggestion?.entry, message.toolId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = messageRef.current;
|
const node = messageRef.current;
|
||||||
if (!autoExpandTools || !node || !message.isToolUse) return;
|
if (!autoExpandTools || !node || !message.isToolUse) return;
|
||||||
@@ -114,7 +120,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
/* User message bubble on the right */
|
/* 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="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="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 dir="auto" className="whitespace-pre-wrap break-words text-sm">
|
<div className="whitespace-pre-wrap break-words text-sm">
|
||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
{message.images && message.images.length > 0 && (
|
{message.images && message.images.length > 0 && (
|
||||||
@@ -235,6 +241,55 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
||||||
{String(message.toolResult.content || '')}
|
{String(message.toolResult.content || '')}
|
||||||
</Markdown>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -350,7 +405,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
</ReasoningContent>
|
</ReasoningContent>
|
||||||
</Reasoning>
|
</Reasoning>
|
||||||
) : (
|
) : (
|
||||||
<div dir="auto" className="text-sm text-gray-700 dark:text-gray-300">
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
{/* Reasoning accordion */}
|
{/* Reasoning accordion */}
|
||||||
{showThinking && message.reasoning && (
|
{showThinking && message.reasoning && (
|
||||||
<Reasoning className="mb-3" defaultOpen={false}>
|
<Reasoning className="mb-3" defaultOpen={false}>
|
||||||
|
|||||||
@@ -277,15 +277,11 @@ export default function ProviderSelectionEmptyState({
|
|||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate">{model.label}</div>
|
<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">
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
{model.description}
|
{model.description}
|
||||||
</div>
|
</div>
|
||||||
)} */}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
||||||
@@ -325,7 +321,6 @@ export default function ProviderSelectionEmptyState({
|
|||||||
|
|
||||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground/60">
|
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground/60">
|
||||||
<Trans
|
<Trans
|
||||||
ns="chat"
|
|
||||||
i18nKey="providerSelection.pressToSearch"
|
i18nKey="providerSelection.pressToSearch"
|
||||||
values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }}
|
values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }}
|
||||||
components={{
|
components={{
|
||||||
|
|||||||
244
src/components/chat/view/subcomponents/ThinkingModeSelector.tsx
Normal file
244
src/components/chat/view/subcomponents/ThinkingModeSelector.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
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,7 +2,6 @@ import { ActivityIcon } from 'lucide-react';
|
|||||||
|
|
||||||
type TokenUsageSummaryProps = {
|
type TokenUsageSummaryProps = {
|
||||||
usage: Record<string, unknown> | null;
|
usage: Record<string, unknown> | null;
|
||||||
onClick?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTokenCount = (value: number) => {
|
const formatTokenCount = (value: number) => {
|
||||||
@@ -30,7 +29,7 @@ const readUsageNumber = (value: unknown) => {
|
|||||||
return Number.isFinite(parsed) ? parsed : 0;
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryProps) {
|
export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
|
||||||
const breakdown =
|
const breakdown =
|
||||||
usage?.breakdown && typeof usage.breakdown === 'object'
|
usage?.breakdown && typeof usage.breakdown === 'object'
|
||||||
? usage.breakdown as Record<string, unknown>
|
? usage.breakdown as Record<string, unknown>
|
||||||
@@ -40,18 +39,15 @@ export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryP
|
|||||||
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
|
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
type="button"
|
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"
|
||||||
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`}
|
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">
|
<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" />
|
<ActivityIcon className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span>
|
<span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span>
|
||||||
<span className="hidden text-muted-foreground/70 sm:inline">tokens</span>
|
<span className="hidden text-muted-foreground/70 sm:inline">tokens</span>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function EditorSidebar({
|
|||||||
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
|
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={`flex h-full min-w-0 ${editorExpanded ? 'flex-1' : ''}`}>
|
<div ref={containerRef} className={`flex h-full min-w-0 flex-shrink-0 ${editorExpanded ? 'flex-1' : ''}`}>
|
||||||
{!editorExpanded && (
|
{!editorExpanded && (
|
||||||
<div
|
<div
|
||||||
ref={resizeHandleRef}
|
ref={resizeHandleRef}
|
||||||
|
|||||||
@@ -6,14 +6,6 @@ export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';
|
|||||||
|
|
||||||
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', '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([
|
export const IMAGE_FILE_EXTENSIONS = new Set([
|
||||||
'png',
|
'png',
|
||||||
'jpg',
|
'jpg',
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useState, useRef } from 'react';
|
||||||
import type { DragEvent } from 'react';
|
|
||||||
|
|
||||||
import { IS_PLATFORM } from '../../../constants/config';
|
|
||||||
import type { Project } from '../../../types/app';
|
import type { Project } from '../../../types/app';
|
||||||
import {
|
import { api } from '../../../utils/api';
|
||||||
MAX_FILE_UPLOAD_COUNT,
|
|
||||||
MAX_FILE_UPLOAD_SIZE_BYTES,
|
|
||||||
MAX_FILE_UPLOAD_SIZE_LABEL,
|
|
||||||
} from '../constants/constants';
|
|
||||||
|
|
||||||
type UseFileTreeUploadOptions = {
|
type UseFileTreeUploadOptions = {
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
@@ -15,141 +8,6 @@ type UseFileTreeUploadOptions = {
|
|||||||
showToast: (message: string, type: 'success' | 'error') => void;
|
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
|
// Helper function to read all files from a directory entry recursively
|
||||||
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
|
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
|
||||||
const files: File[] = [];
|
const files: File[] = [];
|
||||||
@@ -199,48 +57,6 @@ const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry,
|
|||||||
return files;
|
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 = ({
|
export const useFileTreeUpload = ({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -249,150 +65,20 @@ export const useFileTreeUpload = ({
|
|||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||||
const [operationLoading, setOperationLoading] = useState(false);
|
const [operationLoading, setOperationLoading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState<FileTreeUploadProgressState | null>(null);
|
|
||||||
const treeRef = useRef<HTMLDivElement>(null);
|
const treeRef = useRef<HTMLDivElement>(null);
|
||||||
const clearProgressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const clearProgressTimer = useCallback(() => {
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
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.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDragOver(true);
|
setIsDragOver(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: DragEvent) => {
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: DragEvent) => {
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Only set isDragOver to false if we're leaving the entire tree
|
// Only set isDragOver to false if we're leaving the entire tree
|
||||||
@@ -402,35 +88,103 @@ export const useFileTreeUpload = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
async (e: DragEvent) => {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
setIsDragOver(false);
|
||||||
setIsDragOver(false);
|
|
||||||
|
|
||||||
const targetPath = dropTarget || '';
|
const targetPath = dropTarget || '';
|
||||||
|
setOperationLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files = await collectDroppedFiles(e.dataTransfer);
|
const files: File[] = [];
|
||||||
await uploadFiles(files, targetPath);
|
|
||||||
} catch (err) {
|
// Use DataTransferItemList for folder support
|
||||||
const message = err instanceof Error ? err.message : 'Could not read dropped files';
|
const items = e.dataTransfer.items;
|
||||||
console.error('Upload error:', err);
|
if (items) {
|
||||||
showToast(message, 'error');
|
for (const item of Array.from(items)) {
|
||||||
setUploadError(message, 0, targetPath);
|
if (item.kind === 'file') {
|
||||||
setDropTarget(null);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[dropTarget, setUploadError, showToast, uploadFiles],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleItemDragOver = useCallback((e: DragEvent, itemPath: string) => {
|
if (files.length === 0) {
|
||||||
|
setOperationLoading(false);
|
||||||
|
setDropTarget(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDropTarget(itemPath);
|
setDropTarget(itemPath);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleItemDrop = useCallback((e: DragEvent, itemPath: string) => {
|
const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDropTarget(itemPath);
|
setDropTarget(itemPath);
|
||||||
@@ -440,9 +194,7 @@ export const useFileTreeUpload = ({
|
|||||||
isDragOver,
|
isDragOver,
|
||||||
dropTarget,
|
dropTarget,
|
||||||
operationLoading,
|
operationLoading,
|
||||||
uploadProgress,
|
|
||||||
treeRef,
|
treeRef,
|
||||||
handleFileSelect,
|
|
||||||
handleDragEnter,
|
handleDragEnter,
|
||||||
handleDragOver,
|
handleDragOver,
|
||||||
handleDragLeave,
|
handleDragLeave,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
|
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from '../../../lib/utils';
|
||||||
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
|
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
|
||||||
import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
|
import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
|
||||||
@@ -14,12 +13,10 @@ import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
|
|||||||
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
|
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
|
||||||
import { Project } from '../../../types/app';
|
import { Project } from '../../../types/app';
|
||||||
import { ScrollArea, Input } from '../../../shared/view/ui';
|
import { ScrollArea, Input } from '../../../shared/view/ui';
|
||||||
|
|
||||||
import FileTreeBody from './FileTreeBody';
|
import FileTreeBody from './FileTreeBody';
|
||||||
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
|
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
|
||||||
import FileTreeHeader from './FileTreeHeader';
|
import FileTreeHeader from './FileTreeHeader';
|
||||||
import FileTreeLoadingState from './FileTreeLoadingState';
|
import FileTreeLoadingState from './FileTreeLoadingState';
|
||||||
import FileTreeUploadProgress from './FileTreeUploadProgress';
|
|
||||||
import ImageViewer from './ImageViewer';
|
import ImageViewer from './ImageViewer';
|
||||||
|
|
||||||
|
|
||||||
@@ -69,7 +66,6 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
onRefresh: refreshFiles,
|
onRefresh: refreshFiles,
|
||||||
showToast,
|
showToast,
|
||||||
});
|
});
|
||||||
const operationLoading = operations.operationLoading || upload.operationLoading;
|
|
||||||
|
|
||||||
// Focus input when creating new item
|
// Focus input when creating new item
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -150,19 +146,14 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
onViewModeChange={changeViewMode}
|
onViewModeChange={changeViewMode}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchQueryChange={setSearchQuery}
|
onSearchQueryChange={setSearchQuery}
|
||||||
onUploadFiles={upload.handleFileSelect}
|
|
||||||
onNewFile={() => operations.handleStartCreate('', 'file')}
|
onNewFile={() => operations.handleStartCreate('', 'file')}
|
||||||
onNewFolder={() => operations.handleStartCreate('', 'directory')}
|
onNewFolder={() => operations.handleStartCreate('', 'directory')}
|
||||||
onRefresh={refreshFiles}
|
onRefresh={refreshFiles}
|
||||||
onCollapseAll={collapseAll}
|
onCollapseAll={collapseAll}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
operationLoading={operationLoading}
|
operationLoading={operations.operationLoading}
|
||||||
isUploading={upload.uploadProgress?.status === 'uploading'}
|
|
||||||
uploadProgress={upload.uploadProgress?.progress ?? null}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FileTreeUploadProgress upload={upload.uploadProgress} />
|
|
||||||
|
|
||||||
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
|
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
|
||||||
|
|
||||||
<ScrollArea className="flex-1 px-2 py-1">
|
<ScrollArea className="flex-1 px-2 py-1">
|
||||||
@@ -193,7 +184,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
}, 100);
|
}, 100);
|
||||||
}}
|
}}
|
||||||
className="h-6 flex-1 text-sm"
|
className="h-6 flex-1 text-sm"
|
||||||
disabled={operationLoading}
|
disabled={operations.operationLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -222,7 +213,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
handleConfirmRename={operations.handleConfirmRename}
|
handleConfirmRename={operations.handleConfirmRename}
|
||||||
handleCancelRename={operations.handleCancelRename}
|
handleCancelRename={operations.handleCancelRename}
|
||||||
renameInputRef={renameInputRef}
|
renameInputRef={renameInputRef}
|
||||||
operationLoading={operationLoading}
|
operationLoading={operations.operationLoading}
|
||||||
/>
|
/>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
@@ -260,17 +251,17 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={operations.handleCancelDelete}
|
onClick={operations.handleCancelDelete}
|
||||||
disabled={operationLoading}
|
disabled={operations.operationLoading}
|
||||||
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
{t('common.cancel', 'Cancel')}
|
{t('common.cancel', 'Cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={operations.handleConfirmDelete}
|
onClick={operations.handleConfirmDelete}
|
||||||
disabled={operationLoading}
|
disabled={operations.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"
|
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"
|
||||||
>
|
>
|
||||||
{operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
{operations.operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{t('fileTree.delete.confirm', 'Delete')}
|
{t('fileTree.delete.confirm', 'Delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { useRef } from 'react';
|
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button, Input } from '../../../shared/view/ui';
|
import { Button, Input } from '../../../shared/view/ui';
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from '../../../lib/utils';
|
||||||
import { MAX_FILE_UPLOAD_SIZE_LABEL } from '../constants/constants';
|
|
||||||
import type { FileTreeViewMode } from '../types/types';
|
import type { FileTreeViewMode } from '../types/types';
|
||||||
|
|
||||||
type FileTreeHeaderProps = {
|
type FileTreeHeaderProps = {
|
||||||
@@ -16,14 +12,11 @@ type FileTreeHeaderProps = {
|
|||||||
// Toolbar actions
|
// Toolbar actions
|
||||||
onNewFile?: () => void;
|
onNewFile?: () => void;
|
||||||
onNewFolder?: () => void;
|
onNewFolder?: () => void;
|
||||||
onUploadFiles?: (files: FileList) => void;
|
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onCollapseAll?: () => void;
|
onCollapseAll?: () => void;
|
||||||
// Loading state
|
// Loading state
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
operationLoading?: boolean;
|
operationLoading?: boolean;
|
||||||
isUploading?: boolean;
|
|
||||||
uploadProgress?: number | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FileTreeHeader({
|
export default function FileTreeHeader({
|
||||||
@@ -33,24 +26,12 @@ export default function FileTreeHeader({
|
|||||||
onSearchQueryChange,
|
onSearchQueryChange,
|
||||||
onNewFile,
|
onNewFile,
|
||||||
onNewFolder,
|
onNewFolder,
|
||||||
onUploadFiles,
|
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onCollapseAll,
|
onCollapseAll,
|
||||||
loading,
|
loading,
|
||||||
operationLoading,
|
operationLoading,
|
||||||
isUploading,
|
|
||||||
uploadProgress,
|
|
||||||
}: FileTreeHeaderProps) {
|
}: FileTreeHeaderProps) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div className="space-y-2 border-b border-border px-3 pb-2 pt-3">
|
<div className="space-y-2 border-b border-border px-3 pb-2 pt-3">
|
||||||
@@ -59,50 +40,6 @@ export default function FileTreeHeader({
|
|||||||
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
|
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
{/* Action buttons */}
|
{/* 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 && (
|
{onNewFile && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
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,13 +1,10 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
import type { AppTab, Project, ProjectSession } from '../../../types/app';
|
import type { AppTab, Project, ProjectSession } from '../../../types/app';
|
||||||
import type {
|
|
||||||
MarkSessionIdle,
|
|
||||||
MarkSessionProcessing,
|
|
||||||
SessionActivityMap,
|
|
||||||
} from '../../../hooks/useSessionProtection';
|
|
||||||
import type { SessionNavigationOptions } from '../../chat/types/types';
|
import type { SessionNavigationOptions } from '../../chat/types/types';
|
||||||
|
|
||||||
|
export type SessionLifecycleHandler = (sessionId?: string | null) => void;
|
||||||
|
|
||||||
export type TaskMasterTask = {
|
export type TaskMasterTask = {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -49,9 +46,11 @@ export type MainContentProps = {
|
|||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onInputFocusChange: (focused: boolean) => void;
|
onInputFocusChange: (focused: boolean) => void;
|
||||||
onSessionProcessing: MarkSessionProcessing;
|
onSessionActive: SessionLifecycleHandler;
|
||||||
onSessionIdle: MarkSessionIdle;
|
onSessionInactive: SessionLifecycleHandler;
|
||||||
processingSessions: SessionActivityMap;
|
onSessionProcessing: SessionLifecycleHandler;
|
||||||
|
onSessionNotProcessing: SessionLifecycleHandler;
|
||||||
|
processingSessions: Set<string>;
|
||||||
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||||
onShowSettings: () => void;
|
onShowSettings: () => void;
|
||||||
externalMessageUpdate: number;
|
externalMessageUpdate: number;
|
||||||
|
|||||||
@@ -42,8 +42,10 @@ function MainContent({
|
|||||||
onMenuClick,
|
onMenuClick,
|
||||||
isLoading,
|
isLoading,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
|
onSessionActive,
|
||||||
|
onSessionInactive,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionIdle,
|
onSessionNotProcessing,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
@@ -129,8 +131,10 @@ function MainContent({
|
|||||||
latestMessage={latestMessage}
|
latestMessage={latestMessage}
|
||||||
onFileOpen={handleFileOpen}
|
onFileOpen={handleFileOpen}
|
||||||
onInputFocusChange={onInputFocusChange}
|
onInputFocusChange={onInputFocusChange}
|
||||||
|
onSessionActive={onSessionActive}
|
||||||
|
onSessionInactive={onSessionInactive}
|
||||||
onSessionProcessing={onSessionProcessing}
|
onSessionProcessing={onSessionProcessing}
|
||||||
onSessionIdle={onSessionIdle}
|
onSessionNotProcessing={onSessionNotProcessing}
|
||||||
processingSessions={processingSessions}
|
processingSessions={processingSessions}
|
||||||
onNavigateToSession={onNavigateToSession}
|
onNavigateToSession={onNavigateToSession}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import DOMPurify from 'dompurify';
|
|
||||||
|
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -12,48 +10,6 @@ type Props = {
|
|||||||
// Module-level cache so repeated renders don't re-fetch
|
// Module-level cache so repeated renders don't re-fetch
|
||||||
const svgCache = new Map<string, string>();
|
const svgCache = new Map<string, string>();
|
||||||
|
|
||||||
const FORBIDDEN_SVG_TAGS = [
|
|
||||||
'script',
|
|
||||||
'foreignObject',
|
|
||||||
'iframe',
|
|
||||||
'object',
|
|
||||||
'embed',
|
|
||||||
'link',
|
|
||||||
'meta',
|
|
||||||
'style',
|
|
||||||
'animate',
|
|
||||||
'set',
|
|
||||||
'animateTransform',
|
|
||||||
'animateMotion',
|
|
||||||
];
|
|
||||||
|
|
||||||
const FORBIDDEN_SVG_ATTRS = [
|
|
||||||
'href',
|
|
||||||
'xlink:href',
|
|
||||||
'src',
|
|
||||||
'style',
|
|
||||||
];
|
|
||||||
|
|
||||||
function sanitizeSvg(svgText: string): string | null {
|
|
||||||
const sanitized = DOMPurify.sanitize(svgText, {
|
|
||||||
USE_PROFILES: { svg: true, svgFilters: true },
|
|
||||||
FORBID_TAGS: FORBIDDEN_SVG_TAGS,
|
|
||||||
FORBID_ATTR: FORBIDDEN_SVG_ATTRS,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!sanitized) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const doc = new DOMParser().parseFromString(sanitized, 'image/svg+xml');
|
|
||||||
const root = doc.documentElement;
|
|
||||||
if (!root || root.nodeName.toLowerCase() !== 'svg') return null;
|
|
||||||
if (doc.querySelector('parsererror')) return null;
|
|
||||||
return sanitized;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
||||||
const url = iconFile
|
const url = iconFile
|
||||||
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
|
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
|
||||||
@@ -68,11 +24,9 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
|||||||
return r.text();
|
return r.text();
|
||||||
})
|
})
|
||||||
.then((text) => {
|
.then((text) => {
|
||||||
if (!text) return;
|
if (text && text.trimStart().startsWith('<svg')) {
|
||||||
const sanitized = sanitizeSvg(text);
|
svgCache.set(url, text);
|
||||||
if (sanitized) {
|
setSvg(text);
|
||||||
svgCache.set(url, sanitized);
|
|
||||||
setSvg(sanitized);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -81,6 +35,10 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
|||||||
if (!svg) return <span className={className} />;
|
if (!svg) return <span className={className} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={className} dangerouslySetInnerHTML={{ __html: svg }} />
|
<span
|
||||||
|
className={className}
|
||||||
|
// SVG is fetched from the user's own installed plugin — same trust level as the plugin code itself
|
||||||
|
dangerouslySetInnerHTML={{ __html: svg }}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +1,12 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
|
||||||
Activity,
|
|
||||||
BarChart3,
|
|
||||||
BookOpen,
|
|
||||||
Clock,
|
|
||||||
Download,
|
|
||||||
ExternalLink,
|
|
||||||
GitBranch,
|
|
||||||
Loader2,
|
|
||||||
RefreshCw,
|
|
||||||
ServerCrash,
|
|
||||||
ShieldAlert,
|
|
||||||
Terminal,
|
|
||||||
Trash2,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import { usePlugins } from '../../../contexts/PluginsContext';
|
import { usePlugins } from '../../../contexts/PluginsContext';
|
||||||
import type { Plugin } from '../../../contexts/PluginsContext';
|
import type { Plugin } from '../../../contexts/PluginsContext';
|
||||||
|
|
||||||
import PluginIcon from './PluginIcon';
|
import PluginIcon from './PluginIcon';
|
||||||
|
|
||||||
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
|
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
|
||||||
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
|
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;
|
|
||||||
translationKey: string;
|
|
||||||
repoUrl: string;
|
|
||||||
installedNames: string[];
|
|
||||||
icon: LucideIcon;
|
|
||||||
source: 'official' | 'unofficial';
|
|
||||||
};
|
|
||||||
|
|
||||||
const OFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
|
|
||||||
{
|
|
||||||
id: 'project-stats',
|
|
||||||
translationKey: 'starterPlugin',
|
|
||||||
repoUrl: STARTER_PLUGIN_URL,
|
|
||||||
installedNames: ['project-stats'],
|
|
||||||
icon: BarChart3,
|
|
||||||
source: 'official',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'web-terminal',
|
|
||||||
translationKey: 'terminalPlugin',
|
|
||||||
repoUrl: TERMINAL_PLUGIN_URL,
|
|
||||||
installedNames: ['web-terminal'],
|
|
||||||
icon: Terminal,
|
|
||||||
source: 'official',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
|
|
||||||
{
|
|
||||||
id: 'cloudcli-claude-watch',
|
|
||||||
translationKey: 'claudeWatchPlugin',
|
|
||||||
repoUrl: CLAUDE_WATCH_PLUGIN_URL,
|
|
||||||
installedNames: ['cloudcli-claude-watch'],
|
|
||||||
icon: Activity,
|
|
||||||
source: 'unofficial',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'workspace-scheduled-prompts',
|
|
||||||
translationKey: 'scheduledPromptPlugin',
|
|
||||||
repoUrl: SCHEDULED_PROMPT_PLUGIN_URL,
|
|
||||||
installedNames: ['workspace-scheduled-prompts'],
|
|
||||||
icon: Clock,
|
|
||||||
source: 'unofficial',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'prism',
|
|
||||||
translationKey: 'prismCloudCLI',
|
|
||||||
repoUrl: PRISM_CLOUDCLI_PLUGIN_URL,
|
|
||||||
installedNames: ['prism'],
|
|
||||||
icon: Activity,
|
|
||||||
source: 'unofficial'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
function repoSlug(repoUrl: string) {
|
|
||||||
return repoUrl.replace(/^https?:\/\/(www\.)?github\.com\//, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRepoUrl(repoUrl: string | null) {
|
|
||||||
return repoUrl?.replace(/\.git$/, '').replace(/\/$/, '').toLowerCase() ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pluginMatchesRecommendation(plugin: Plugin, recommendation: PluginRecommendation) {
|
|
||||||
return (
|
|
||||||
recommendation.installedNames.includes(plugin.name)
|
|
||||||
|| normalizeRepoUrl(plugin.repoUrl) === normalizeRepoUrl(recommendation.repoUrl)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */
|
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */
|
||||||
function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {
|
function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {
|
||||||
@@ -298,95 +208,117 @@ function PluginCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Recommendation Section ────────────────────────────────────────────── */
|
/* ─── Starter Plugin Card ───────────────────────────────────────────────── */
|
||||||
function RecommendationSection({
|
function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
{title}
|
|
||||||
</h4>
|
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground/70">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Plugin Recommendation Card ────────────────────────────────────────── */
|
|
||||||
function PluginRecommendationCard({
|
|
||||||
recommendation,
|
|
||||||
onInstall,
|
|
||||||
disabled,
|
|
||||||
installing,
|
|
||||||
}: {
|
|
||||||
recommendation: PluginRecommendation;
|
|
||||||
onInstall: () => void;
|
|
||||||
disabled: boolean;
|
|
||||||
installing: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const Icon = recommendation.icon;
|
|
||||||
const isOfficial = recommendation.source === 'official';
|
|
||||||
const accentClass = isOfficial ? 'bg-blue-500/30' : 'bg-amber-500/40';
|
|
||||||
const hoverClass = isOfficial ? 'hover:border-blue-400 dark:hover:border-blue-500' : 'hover:border-amber-400 dark:hover:border-amber-500';
|
|
||||||
const iconClass = isOfficial ? 'text-blue-500' : 'text-amber-500';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 ${hoverClass}`}>
|
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
|
||||||
<div className={`w-[3px] flex-shrink-0 ${accentClass}`} />
|
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
|
||||||
<div className="min-w-0 flex-1 p-4">
|
<div className="min-w-0 flex-1 p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
<div className={`h-5 w-5 flex-shrink-0 ${iconClass}`}>
|
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
|
||||||
<Icon className="h-5 w-5" />
|
<BarChart3 className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm font-semibold leading-none text-foreground">
|
<span className="text-sm font-semibold leading-none text-foreground">
|
||||||
{t(`pluginSettings.${recommendation.translationKey}.name`)}
|
{t('pluginSettings.starterPlugin.name')}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
|
||||||
|
{t('pluginSettings.starterPlugin.badge')}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
{t('pluginSettings.tab')}
|
{t('pluginSettings.tab')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm leading-snug text-muted-foreground">
|
<p className="mt-1 text-sm leading-snug text-muted-foreground">
|
||||||
{t(`pluginSettings.${recommendation.translationKey}.description`)}
|
{t('pluginSettings.starterPlugin.description')}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href={recommendation.repoUrl}
|
href={STARTER_PLUGIN_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
<GitBranch className="h-3 w-3" />
|
<GitBranch className="h-3 w-3" />
|
||||||
{repoSlug(recommendation.repoUrl)}
|
cloudcli-ai/cloudcli-plugin-starter
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onInstall}
|
onClick={onInstall}
|
||||||
disabled={disabled}
|
disabled={installing}
|
||||||
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-50"
|
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{installing ? (
|
{installing ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
{installing ? t('pluginSettings.installing') : t(`pluginSettings.${recommendation.translationKey}.install`)}
|
{installing ? t('pluginSettings.installing') : t('pluginSettings.starterPlugin.install')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Terminal Plugin Card ──────────────────────────────────────────────── */
|
||||||
|
function TerminalPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
|
||||||
|
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
|
||||||
|
<div className="min-w-0 flex-1 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<path d="M7 8l4 4-4 4"/>
|
||||||
|
<line x1="13" y1="16" x2="17" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold leading-none text-foreground">
|
||||||
|
{t('pluginSettings.terminalPlugin.name')}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
|
||||||
|
{t('pluginSettings.terminalPlugin.badge')}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{t('pluginSettings.tab')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm leading-snug text-muted-foreground">
|
||||||
|
{t('pluginSettings.terminalPlugin.description')}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={TERMINAL_PLUGIN_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<GitBranch className="h-3 w-3" />
|
||||||
|
cloudcli-ai/cloudcli-plugin-terminal
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onInstall}
|
||||||
|
disabled={installing}
|
||||||
|
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{installing ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{installing ? t('pluginSettings.installing') : t('pluginSettings.terminalPlugin.install')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -402,7 +334,8 @@ export default function PluginSettingsTab() {
|
|||||||
|
|
||||||
const [gitUrl, setGitUrl] = useState('');
|
const [gitUrl, setGitUrl] = useState('');
|
||||||
const [installing, setInstalling] = useState(false);
|
const [installing, setInstalling] = useState(false);
|
||||||
const [installingRecommendation, setInstallingRecommendation] = useState<string | null>(null);
|
const [installingStarter, setInstallingStarter] = useState(false);
|
||||||
|
const [installingTerminal, setInstallingTerminal] = useState(false);
|
||||||
const [installError, setInstallError] = useState<string | null>(null);
|
const [installError, setInstallError] = useState<string | null>(null);
|
||||||
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
|
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
|
||||||
const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
|
const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
|
||||||
@@ -431,18 +364,24 @@ export default function PluginSettingsTab() {
|
|||||||
setInstalling(false);
|
setInstalling(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInstallRecommendation = async (recommendation: PluginRecommendation) => {
|
const handleInstallStarter = async () => {
|
||||||
if (installingRecommendation) return;
|
setInstallingStarter(true);
|
||||||
setInstallingRecommendation(recommendation.id);
|
|
||||||
setInstallError(null);
|
setInstallError(null);
|
||||||
try {
|
const result = await installPlugin(STARTER_PLUGIN_URL);
|
||||||
const result = await installPlugin(recommendation.repoUrl);
|
if (!result.success) {
|
||||||
if (!result.success) {
|
setInstallError(result.error || t('pluginSettings.installFailed'));
|
||||||
setInstallError(result.error || t('pluginSettings.installFailed'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setInstallingRecommendation(null);
|
|
||||||
}
|
}
|
||||||
|
setInstallingStarter(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstallTerminal = async () => {
|
||||||
|
setInstallingTerminal(true);
|
||||||
|
setInstallError(null);
|
||||||
|
const result = await installPlugin(TERMINAL_PLUGIN_URL);
|
||||||
|
if (!result.success) {
|
||||||
|
setInstallError(result.error || t('pluginSettings.installFailed'));
|
||||||
|
}
|
||||||
|
setInstallingTerminal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUninstall = async (name: string) => {
|
const handleUninstall = async (name: string) => {
|
||||||
@@ -459,50 +398,8 @@ export default function PluginSettingsTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isRecommendationInstalled = (recommendation: PluginRecommendation) => {
|
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
|
||||||
return plugins.some((plugin) => pluginMatchesRecommendation(plugin, recommendation));
|
const hasTerminalInstalled = plugins.some((p) => p.name === 'web-terminal');
|
||||||
};
|
|
||||||
|
|
||||||
const isOfficialPlugin = (plugin: Plugin) => {
|
|
||||||
return OFFICIAL_PLUGIN_RECOMMENDATIONS.some((recommendation) => (
|
|
||||||
pluginMatchesRecommendation(plugin, recommendation)
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const officialPlugins = plugins.filter(isOfficialPlugin);
|
|
||||||
const otherPlugins = plugins.filter((plugin) => !isOfficialPlugin(plugin));
|
|
||||||
const officialRecommendations = OFFICIAL_PLUGIN_RECOMMENDATIONS.filter(
|
|
||||||
(recommendation) => !isRecommendationInstalled(recommendation),
|
|
||||||
);
|
|
||||||
const unofficialRecommendations = UNOFFICIAL_PLUGIN_RECOMMENDATIONS.filter(
|
|
||||||
(recommendation) => !isRecommendationInstalled(recommendation),
|
|
||||||
);
|
|
||||||
const hasOfficialSection = officialPlugins.length > 0 || officialRecommendations.length > 0;
|
|
||||||
const hasOtherSection = otherPlugins.length > 0 || unofficialRecommendations.length > 0;
|
|
||||||
|
|
||||||
const renderPluginCard = (plugin: Plugin, index: number) => {
|
|
||||||
const handleToggle = async (enabled: boolean) => {
|
|
||||||
const r = await togglePlugin(plugin.name, enabled);
|
|
||||||
if (!r.success) {
|
|
||||||
setInstallError(r.error || t('pluginSettings.toggleFailed'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PluginCard
|
|
||||||
key={plugin.name}
|
|
||||||
plugin={plugin}
|
|
||||||
index={index}
|
|
||||||
onToggle={(enabled) => void handleToggle(enabled)}
|
|
||||||
onUpdate={() => void handleUpdate(plugin.name)}
|
|
||||||
onUninstall={() => void handleUninstall(plugin.name)}
|
|
||||||
updating={updatingPlugins.has(plugin.name)}
|
|
||||||
confirmingUninstall={confirmUninstall === plugin.name}
|
|
||||||
onCancelUninstall={() => setConfirmUninstall(null)}
|
|
||||||
updateError={updateErrors[plugin.name] ?? null}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -559,49 +456,51 @@ export default function PluginSettingsTab() {
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Plugin sections */}
|
{/* Official plugin suggestions — above the list */}
|
||||||
|
{!loading && (!hasStarterInstalled || !hasTerminalInstalled) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{!hasStarterInstalled && (
|
||||||
|
<StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} />
|
||||||
|
)}
|
||||||
|
{!hasTerminalInstalled && (
|
||||||
|
<TerminalPluginCard onInstall={handleInstallTerminal} installing={installingTerminal} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plugin List */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
{t('pluginSettings.scanningPlugins')}
|
{t('pluginSettings.scanningPlugins')}
|
||||||
</div>
|
</div>
|
||||||
|
) : plugins.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">{t('pluginSettings.noPluginsInstalled')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-2">
|
||||||
{hasOfficialSection && (
|
{plugins.map((plugin, index) => {
|
||||||
<RecommendationSection
|
const handleToggle = async (enabled: boolean) => {
|
||||||
title={t('pluginSettings.sections.officialTitle')}
|
const r = await togglePlugin(plugin.name, enabled);
|
||||||
description={t('pluginSettings.sections.officialDescription')}
|
if (!r.success) {
|
||||||
>
|
setInstallError(r.error || t('pluginSettings.toggleFailed'));
|
||||||
{officialPlugins.map((plugin, index) => renderPluginCard(plugin, index))}
|
}
|
||||||
{officialRecommendations.map((recommendation) => (
|
};
|
||||||
<PluginRecommendationCard
|
|
||||||
key={recommendation.id}
|
|
||||||
recommendation={recommendation}
|
|
||||||
onInstall={() => void handleInstallRecommendation(recommendation)}
|
|
||||||
disabled={!!installingRecommendation}
|
|
||||||
installing={installingRecommendation === recommendation.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</RecommendationSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasOtherSection && (
|
return (
|
||||||
<RecommendationSection
|
<PluginCard
|
||||||
title={t('pluginSettings.sections.unofficialTitle')}
|
key={plugin.name}
|
||||||
description={t('pluginSettings.sections.unofficialDescription')}
|
plugin={plugin}
|
||||||
>
|
index={index}
|
||||||
{otherPlugins.map((plugin, index) => renderPluginCard(plugin, officialPlugins.length + index))}
|
onToggle={(enabled) => void handleToggle(enabled)}
|
||||||
{unofficialRecommendations.map((recommendation) => (
|
onUpdate={() => void handleUpdate(plugin.name)}
|
||||||
<PluginRecommendationCard
|
onUninstall={() => void handleUninstall(plugin.name)}
|
||||||
key={recommendation.id}
|
updating={updatingPlugins.has(plugin.name)}
|
||||||
recommendation={recommendation}
|
confirmingUninstall={confirmUninstall === plugin.name}
|
||||||
onInstall={() => void handleInstallRecommendation(recommendation)}
|
onCancelUninstall={() => setConfirmUninstall(null)}
|
||||||
disabled={!!installingRecommendation}
|
updateError={updateErrors[plugin.name] ?? null}
|
||||||
installing={installingRecommendation === recommendation.id}
|
/>
|
||||||
/>
|
);
|
||||||
))}
|
})}
|
||||||
</RecommendationSection>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -70,39 +70,34 @@ export function useProviderAuthStatus(
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const checkProviderAuthStatus = useCallback(async (provider: LLMProvider): Promise<ProviderAuthStatus> => {
|
const checkProviderAuthStatus = useCallback(async (provider: LLMProvider) => {
|
||||||
setProviderLoading(provider);
|
setProviderLoading(provider);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(PROVIDER_AUTH_STATUS_ENDPOINTS[provider]);
|
const response = await authenticatedFetch(PROVIDER_AUTH_STATUS_ENDPOINTS[provider]);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const status: ProviderAuthStatus = {
|
setProviderStatus(provider, {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
email: null,
|
email: null,
|
||||||
method: null,
|
method: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: FALLBACK_STATUS_ERROR,
|
error: FALLBACK_STATUS_ERROR,
|
||||||
};
|
});
|
||||||
setProviderStatus(provider, status);
|
return;
|
||||||
return status;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await response.json()) as ProviderAuthStatusApiResponse;
|
const payload = (await response.json()) as ProviderAuthStatusApiResponse;
|
||||||
const status = toProviderAuthStatus(payload.data);
|
setProviderStatus(provider, toProviderAuthStatus(payload.data));
|
||||||
setProviderStatus(provider, status);
|
|
||||||
return status;
|
|
||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
console.error(`Error checking ${provider} auth status:`, caughtError);
|
console.error(`Error checking ${provider} auth status:`, caughtError);
|
||||||
const status: ProviderAuthStatus = {
|
setProviderStatus(provider, {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
email: null,
|
email: null,
|
||||||
method: null,
|
method: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: toErrorMessage(caughtError),
|
error: toErrorMessage(caughtError),
|
||||||
};
|
});
|
||||||
setProviderStatus(provider, status);
|
|
||||||
return status;
|
|
||||||
}
|
}
|
||||||
}, [setProviderLoading, setProviderStatus]);
|
}, [setProviderLoading, setProviderStatus]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
import { setNotificationSoundEnabled } from '../../../utils/notificationSound';
|
|
||||||
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
|
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CODE_EDITOR_SETTINGS,
|
DEFAULT_CODE_EDITOR_SETTINGS,
|
||||||
@@ -109,7 +107,6 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: true,
|
inApp: true,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
sound: true,
|
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
actionRequired: true,
|
actionRequired: true,
|
||||||
@@ -118,25 +115,6 @@ 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) {
|
export function useSettingsController({ isOpen, initialTab }: UseSettingsControllerArgs) {
|
||||||
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
||||||
const closeTimerRef = useRef<number | null>(null);
|
const closeTimerRef = useRef<number | null>(null);
|
||||||
@@ -208,7 +186,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
if (notificationResponse.ok) {
|
if (notificationResponse.ok) {
|
||||||
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
|
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
|
||||||
if (notificationData.success && notificationData.preferences) {
|
if (notificationData.success && notificationData.preferences) {
|
||||||
setNotificationPreferences(normalizeNotificationPreferences(notificationData.preferences));
|
setNotificationPreferences(notificationData.preferences);
|
||||||
} else {
|
} else {
|
||||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||||
}
|
}
|
||||||
@@ -235,19 +213,12 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLoginComplete = useCallback((exitCode: number) => {
|
const handleLoginComplete = useCallback((exitCode: number) => {
|
||||||
if (!loginProvider) {
|
if (exitCode !== 0 || !loginProvider) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
setSaveStatus('success');
|
||||||
const authStatus = await checkProviderAuthStatus(loginProvider);
|
void checkProviderAuthStatus(loginProvider);
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
|
||||||
console.warn(`Login process exited with code ${exitCode}; refreshing auth status before setting save status.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaveStatus(authStatus.authenticated ? 'success' : 'error');
|
|
||||||
})();
|
|
||||||
}, [checkProviderAuthStatus, loginProvider]);
|
}, [checkProviderAuthStatus, loginProvider]);
|
||||||
|
|
||||||
const saveSettings = useCallback(async () => {
|
const saveSettings = useCallback(async () => {
|
||||||
@@ -323,10 +294,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
void refreshProviderAuthStatuses();
|
void refreshProviderAuthStatuses();
|
||||||
}, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
|
}, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setNotificationSoundEnabled(notificationPreferences.channels.sound);
|
|
||||||
}, [notificationPreferences.channels.sound]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
|
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
|
||||||
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
import type { LLMProvider } from '../../../types/app';
|
import type { LLMProvider } from '../../../types/app';
|
||||||
import type { ProviderAuthStatus } from '../../provider-auth/types';
|
import type { ProviderAuthStatus } from '../../provider-auth/types';
|
||||||
|
|
||||||
@@ -30,7 +29,6 @@ export type NotificationPreferencesState = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: boolean;
|
inApp: boolean;
|
||||||
webPush: boolean;
|
webPush: boolean;
|
||||||
sound: boolean;
|
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
actionRequired: boolean;
|
actionRequired: boolean;
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { Bell, BellOff, BellRing, Loader2, Play, Volume2 } from 'lucide-react';
|
import { Bell, BellOff, BellRing, Loader2 } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button } from '../../../../shared/view/ui';
|
|
||||||
import { playChatCompletionSound } from '../../../../utils/notificationSound';
|
|
||||||
import type { NotificationPreferencesState } from '../../types/types';
|
import type { NotificationPreferencesState } from '../../types/types';
|
||||||
|
|
||||||
type NotificationsSettingsTabProps = {
|
type NotificationsSettingsTabProps = {
|
||||||
@@ -85,54 +82,6 @@ export default function NotificationsSettingsTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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>
|
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import type { MutableRefObject } from 'react';
|
import type { MutableRefObject } from 'react';
|
||||||
import type { FitAddon } from '@xterm/addon-fit';
|
import type { FitAddon } from '@xterm/addon-fit';
|
||||||
import type { Terminal } from '@xterm/xterm';
|
import type { Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
import type { Project, ProjectSession } from '../../../types/app';
|
import type { Project, ProjectSession } from '../../../types/app';
|
||||||
import { TERMINAL_INIT_DELAY_MS } from '../constants/constants';
|
import { TERMINAL_INIT_DELAY_MS } from '../constants/constants';
|
||||||
import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket';
|
import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket';
|
||||||
@@ -32,8 +31,8 @@ type UseShellConnectionResult = {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
closeSocket: () => void;
|
closeSocket: () => void;
|
||||||
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
connectToShell: () => void;
|
||||||
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
disconnectFromShell: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useShellConnection({
|
export function useShellConnection({
|
||||||
@@ -55,8 +54,6 @@ export function useShellConnection({
|
|||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
const connectingRef = useRef(false);
|
const connectingRef = useRef(false);
|
||||||
const forceRestartOnInitRef = useRef(false);
|
|
||||||
const suppressAutoConnectRef = useRef(false);
|
|
||||||
|
|
||||||
const handleProcessCompletion = useCallback(
|
const handleProcessCompletion = useCallback(
|
||||||
(output: string) => {
|
(output: string) => {
|
||||||
@@ -144,8 +141,6 @@ export function useShellConnection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentFitAddon.fit();
|
currentFitAddon.fit();
|
||||||
const forceRestart = forceRestartOnInitRef.current;
|
|
||||||
forceRestartOnInitRef.current = false;
|
|
||||||
|
|
||||||
sendSocketMessage(socket, {
|
sendSocketMessage(socket, {
|
||||||
type: 'init',
|
type: 'init',
|
||||||
@@ -157,7 +152,6 @@ export function useShellConnection({
|
|||||||
rows: currentTerminal.rows,
|
rows: currentTerminal.rows,
|
||||||
initialCommand: initialCommandRef.current,
|
initialCommand: initialCommandRef.current,
|
||||||
isPlainShell: isPlainShellRef.current,
|
isPlainShell: isPlainShellRef.current,
|
||||||
forceRestart,
|
|
||||||
});
|
});
|
||||||
}, TERMINAL_INIT_DELAY_MS);
|
}, TERMINAL_INIT_DELAY_MS);
|
||||||
};
|
};
|
||||||
@@ -183,7 +177,6 @@ export function useShellConnection({
|
|||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
connectingRef.current = false;
|
connectingRef.current = false;
|
||||||
forceRestartOnInitRef.current = false;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -202,40 +195,27 @@ export function useShellConnection({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectToShell = useCallback((options?: { forceRestart?: boolean }) => {
|
const connectToShell = useCallback(() => {
|
||||||
if (!isInitialized || isConnected || isConnecting || connectingRef.current) {
|
if (!isInitialized || isConnected || isConnecting || connectingRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
forceRestartOnInitRef.current = Boolean(options?.forceRestart);
|
|
||||||
suppressAutoConnectRef.current = false;
|
|
||||||
connectingRef.current = true;
|
connectingRef.current = true;
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
connectWebSocket(true);
|
connectWebSocket(true);
|
||||||
}, [connectWebSocket, isConnected, isConnecting, isInitialized]);
|
}, [connectWebSocket, isConnected, isConnecting, isInitialized]);
|
||||||
|
|
||||||
const disconnectFromShell = useCallback((options?: { suppressAutoConnect?: boolean }) => {
|
const disconnectFromShell = useCallback(() => {
|
||||||
if (options?.suppressAutoConnect) {
|
|
||||||
suppressAutoConnectRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
closeSocket();
|
closeSocket();
|
||||||
clearTerminalScreen();
|
clearTerminalScreen();
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
connectingRef.current = false;
|
connectingRef.current = false;
|
||||||
forceRestartOnInitRef.current = false;
|
|
||||||
setAuthUrl('');
|
setAuthUrl('');
|
||||||
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
|
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (!autoConnect || !isInitialized || isConnecting || isConnected) {
|
||||||
!autoConnect ||
|
|
||||||
suppressAutoConnectRef.current ||
|
|
||||||
!isInitialized ||
|
|
||||||
isConnecting ||
|
|
||||||
isConnected
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { MutableRefObject, RefObject } from 'react';
|
import type { MutableRefObject, RefObject } from 'react';
|
||||||
import type { FitAddon } from '@xterm/addon-fit';
|
import type { FitAddon } from '@xterm/addon-fit';
|
||||||
import type { Terminal } from '@xterm/xterm';
|
import type { Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
import type { Project, ProjectSession } from '../../../types/app';
|
import type { Project, ProjectSession } from '../../../types/app';
|
||||||
|
|
||||||
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
|
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
|
||||||
@@ -16,7 +15,6 @@ export type ShellInitMessage = {
|
|||||||
rows: number;
|
rows: number;
|
||||||
initialCommand: string | null | undefined;
|
initialCommand: string | null | undefined;
|
||||||
isPlainShell: boolean;
|
isPlainShell: boolean;
|
||||||
forceRestart?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ShellResizeMessage = {
|
export type ShellResizeMessage = {
|
||||||
@@ -71,8 +69,8 @@ export type UseShellRuntimeResult = {
|
|||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
authUrl: string;
|
authUrl: string;
|
||||||
authUrlVersion: number;
|
authUrlVersion: number;
|
||||||
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
connectToShell: () => void;
|
||||||
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
disconnectFromShell: () => void;
|
||||||
openAuthUrlInBrowser: (url?: string) => boolean;
|
openAuthUrlInBrowser: (url?: string) => boolean;
|
||||||
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
import type { Project, ProjectSession } from '../../../types/app';
|
import type { Project, ProjectSession } from '../../../types/app';
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
import { useShellRuntime } from '../hooks/useShellRuntime';
|
import { useShellRuntime } from '../hooks/useShellRuntime';
|
||||||
import { sendSocketMessage } from '../utils/socket';
|
import { sendSocketMessage } from '../utils/socket';
|
||||||
import { getSessionDisplayName } from '../utils/auth';
|
import { getSessionDisplayName } from '../utils/auth';
|
||||||
|
|
||||||
import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
|
import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
|
||||||
import ShellEmptyState from './subcomponents/ShellEmptyState';
|
import ShellEmptyState from './subcomponents/ShellEmptyState';
|
||||||
import ShellHeader from './subcomponents/ShellHeader';
|
import ShellHeader from './subcomponents/ShellHeader';
|
||||||
@@ -48,8 +46,6 @@ export default function Shell({
|
|||||||
const [isRestarting, setIsRestarting] = useState(false);
|
const [isRestarting, setIsRestarting] = useState(false);
|
||||||
const [cliPromptOptions, setCliPromptOptions] = useState<CliPromptOption[] | null>(null);
|
const [cliPromptOptions, setCliPromptOptions] = useState<CliPromptOption[] | null>(null);
|
||||||
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | 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 onOutputRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -144,7 +140,6 @@ export default function Shell({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current);
|
if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current);
|
||||||
if (restartTimerRef.current) clearTimeout(restartTimerRef.current);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -195,42 +190,12 @@ export default function Shell({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleRestartShell = useCallback(() => {
|
const handleRestartShell = useCallback(() => {
|
||||||
restartAfterInitRef.current = true;
|
|
||||||
setIsRestarting(true);
|
setIsRestarting(true);
|
||||||
if (restartTimerRef.current) {
|
window.setTimeout(() => {
|
||||||
clearTimeout(restartTimerRef.current);
|
|
||||||
}
|
|
||||||
restartTimerRef.current = setTimeout(() => {
|
|
||||||
setIsRestarting(false);
|
setIsRestarting(false);
|
||||||
restartTimerRef.current = null;
|
|
||||||
}, SHELL_RESTART_DELAY_MS);
|
}, 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) {
|
if (!selectedProject) {
|
||||||
return (
|
return (
|
||||||
<ShellEmptyState
|
<ShellEmptyState
|
||||||
@@ -289,7 +254,7 @@ export default function Shell({
|
|||||||
isRestarting={isRestarting}
|
isRestarting={isRestarting}
|
||||||
hasSession={Boolean(selectedSession)}
|
hasSession={Boolean(selectedSession)}
|
||||||
sessionDisplayNameShort={sessionDisplayNameShort}
|
sessionDisplayNameShort={sessionDisplayNameShort}
|
||||||
onDisconnect={handleDisconnectShell}
|
onDisconnect={disconnectFromShell}
|
||||||
onRestart={handleRestartShell}
|
onRestart={handleRestartShell}
|
||||||
statusNewSessionText={t('shell.status.newSession')}
|
statusNewSessionText={t('shell.status.newSession')}
|
||||||
statusInitializingText={t('shell.status.initializing')}
|
statusInitializingText={t('shell.status.initializing')}
|
||||||
@@ -298,7 +263,7 @@ export default function Shell({
|
|||||||
disconnectTitle={t('shell.actions.disconnectTitle')}
|
disconnectTitle={t('shell.actions.disconnectTitle')}
|
||||||
restartLabel={t('shell.actions.restart')}
|
restartLabel={t('shell.actions.restart')}
|
||||||
restartTitle={t('shell.actions.restartTitle')}
|
restartTitle={t('shell.actions.restartTitle')}
|
||||||
disableRestart={isRestarting || !isInitialized}
|
disableRestart={isRestarting || isConnected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative flex-1 overflow-hidden p-2">
|
<div className="relative flex-1 overflow-hidden p-2">
|
||||||
@@ -316,7 +281,7 @@ export default function Shell({
|
|||||||
connectLabel={t('shell.actions.connect')}
|
connectLabel={t('shell.actions.connect')}
|
||||||
connectTitle={t('shell.actions.connectTitle')}
|
connectTitle={t('shell.actions.connectTitle')}
|
||||||
connectingLabel={t('shell.connecting')}
|
connectingLabel={t('shell.connecting')}
|
||||||
onConnect={handleRestartShell}
|
onConnect={connectToShell}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Loader2, RotateCcw } from 'lucide-react';
|
|
||||||
|
|
||||||
type ShellConnectionOverlayProps = {
|
type ShellConnectionOverlayProps = {
|
||||||
mode: 'loading' | 'connect' | 'connecting';
|
mode: 'loading' | 'connect' | 'connecting';
|
||||||
description: string;
|
description: string;
|
||||||
@@ -21,42 +19,40 @@ export default function ShellConnectionOverlay({
|
|||||||
}: ShellConnectionOverlayProps) {
|
}: ShellConnectionOverlayProps) {
|
||||||
if (mode === 'loading') {
|
if (mode === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90">
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
|
||||||
<div className="inline-flex items-center gap-2 text-sm font-medium text-gray-100">
|
<div className="text-white">{loadingLabel}</div>
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-blue-300" aria-hidden="true" />
|
|
||||||
<span>{loadingLabel}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'connect') {
|
if (mode === 'connect') {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6">
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||||
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center">
|
<div className="w-full max-w-sm text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={onConnect}
|
onClick={onConnect}
|
||||||
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"
|
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"
|
||||||
title={connectTitle}
|
title={connectTitle}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<span className="min-w-0 truncate">{connectLabel}</span>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
<span>{connectLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
<p className="max-w-md break-words px-2 text-sm leading-6 text-gray-300">{description}</p>
|
<p className="mt-3 px-2 text-sm text-gray-400">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6">
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||||
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center">
|
<div className="w-full max-w-sm text-center">
|
||||||
<div className="flex items-center justify-center gap-3 text-yellow-300">
|
<div className="flex items-center justify-center space-x-3 text-yellow-400">
|
||||||
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
|
||||||
<span className="text-base font-medium">{connectingLabel}</span>
|
<span className="text-base font-medium">{connectingLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="max-w-md break-words px-2 text-sm leading-6 text-gray-300">{description}</p>
|
<p className="mt-3 px-2 text-sm text-gray-400">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { RotateCcw, X } from 'lucide-react';
|
|
||||||
|
|
||||||
type ShellHeaderProps = {
|
type ShellHeaderProps = {
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
@@ -52,27 +50,34 @@ export default function ShellHeader({
|
|||||||
{isRestarting && <span className="text-xs text-blue-400">{statusRestartingText}</span>}
|
{isRestarting && <span className="text-xs text-blue-400">{statusRestartingText}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center space-x-3">
|
||||||
{isConnected && (
|
{isConnected && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={onDisconnect}
|
onClick={onDisconnect}
|
||||||
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"
|
className="flex items-center space-x-1 rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700"
|
||||||
title={disconnectTitle}
|
title={disconnectTitle}
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
<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>
|
||||||
<span>{disconnectLabel}</span>
|
<span>{disconnectLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={onRestart}
|
onClick={onRestart}
|
||||||
disabled={disableRestart}
|
disabled={disableRestart}
|
||||||
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"
|
className="flex items-center space-x-1 text-xs text-gray-400 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
title={restartTitle}
|
title={restartTitle}
|
||||||
>
|
>
|
||||||
<RotateCcw className={`h-3.5 w-3.5 ${isRestarting ? 'animate-spin' : ''}`} aria-hidden="true" />
|
<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>
|
||||||
<span>{restartLabel}</span>
|
<span>{restartLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Check, ChevronDown, ChevronRight, Edit3, Star, Trash2, X } from 'lucide-react';
|
import { Check, ChevronDown, ChevronRight, Edit3, Folder, FolderOpen, Star, Trash2, X } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
import { Button } from '../../../../shared/view/ui';
|
import { Button } from '../../../../shared/view/ui';
|
||||||
@@ -131,28 +131,18 @@ export default function SidebarProjectItem({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<button
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-8 h-8 rounded-lg flex items-center justify-center active:scale-90 transition-all duration-150 border',
|
'w-8 h-8 rounded-lg flex items-center justify-center transition-colors',
|
||||||
isStarred
|
isExpanded ? 'bg-primary/10' : 'bg-muted',
|
||||||
? 'bg-yellow-500/10 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800'
|
|
||||||
: 'bg-gray-500/10 dark:bg-gray-900/30 border-gray-200 dark:border-gray-800',
|
|
||||||
)}
|
)}
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
toggleStarProject();
|
|
||||||
}}
|
|
||||||
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
|
|
||||||
>
|
>
|
||||||
<Star
|
{isExpanded ? (
|
||||||
className={cn(
|
<FolderOpen className="h-4 w-4 text-primary" />
|
||||||
'w-4 h-4 transition-colors',
|
) : (
|
||||||
isStarred
|
<Folder className="h-4 w-4 text-muted-foreground" />
|
||||||
? 'text-yellow-600 dark:text-yellow-400 fill-current'
|
)}
|
||||||
: 'text-gray-600 dark:text-gray-400',
|
</div>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
@@ -222,6 +212,29 @@ export default function SidebarProjectItem({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-lg flex items-center justify-center active:scale-90 transition-all duration-150 border',
|
||||||
|
isStarred
|
||||||
|
? 'bg-yellow-500/10 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800'
|
||||||
|
: 'bg-gray-500/10 dark:bg-gray-900/30 border-gray-200 dark:border-gray-800',
|
||||||
|
)}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleStarProject();
|
||||||
|
}}
|
||||||
|
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={cn(
|
||||||
|
'w-4 h-4 transition-colors',
|
||||||
|
isStarred
|
||||||
|
? 'text-yellow-600 dark:text-yellow-400 fill-current'
|
||||||
|
: 'text-gray-600 dark:text-gray-400',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-red-200 bg-red-500/10 active:scale-90 dark:border-red-800 dark:bg-red-900/30"
|
className="flex h-8 w-8 items-center justify-center rounded-lg border border-red-200 bg-red-500/10 active:scale-90 dark:border-red-800 dark:bg-red-900/30"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
@@ -268,28 +281,11 @@ export default function SidebarProjectItem({
|
|||||||
onClick={selectAndToggleProject}
|
onClick={selectAndToggleProject}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<div
|
{isExpanded ? (
|
||||||
className={cn(
|
<FolderOpen className="h-4 w-4 flex-shrink-0 text-primary" />
|
||||||
'w-6 h-6 flex items-center justify-center rounded cursor-pointer transition-all duration-200',
|
) : (
|
||||||
isStarred
|
<Folder className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||||
? 'hover:bg-yellow-50 dark:hover:bg-yellow-900/20'
|
)}
|
||||||
: 'opacity-40 hover:opacity-100 hover:bg-accent',
|
|
||||||
)}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
toggleStarProject();
|
|
||||||
}}
|
|
||||||
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
className={cn(
|
|
||||||
'w-3 h-3 transition-colors',
|
|
||||||
isStarred
|
|
||||||
? 'text-yellow-600 dark:text-yellow-400 fill-current'
|
|
||||||
: 'text-muted-foreground',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 text-left">
|
<div className="min-w-0 flex-1 text-left">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -356,6 +352,26 @@ export default function SidebarProjectItem({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 flex items-center justify-center rounded cursor-pointer touch:opacity-100',
|
||||||
|
isStarred ? 'hover:bg-yellow-50 dark:hover:bg-yellow-900/20 opacity-100' : 'hover:bg-accent',
|
||||||
|
)}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleStarProject();
|
||||||
|
}}
|
||||||
|
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={cn(
|
||||||
|
'w-3 h-3 transition-colors',
|
||||||
|
isStarred
|
||||||
|
? 'text-yellow-600 dark:text-yellow-400 fill-current'
|
||||||
|
: 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="touch:opacity-100 flex h-6 w-6 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-accent group-hover:opacity-100"
|
className="touch:opacity-100 flex h-6 w-6 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-accent group-hover:opacity-100"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { Check, Edit2, Trash2, X } from 'lucide-react';
|
import { Check, Edit2, Trash2, X } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
import { Badge, Button, Tooltip } from '../../../../shared/view/ui';
|
import { Badge, Button } from '../../../../shared/view/ui';
|
||||||
import { cn } from '../../../../lib/utils';
|
import { cn } from '../../../../lib/utils';
|
||||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||||
import type { SessionWithProvider } from '../../types/types';
|
import type { SessionWithProvider } from '../../types/types';
|
||||||
@@ -77,28 +76,7 @@ export default function SidebarSessionItem({
|
|||||||
}: SidebarSessionItemProps) {
|
}: SidebarSessionItemProps) {
|
||||||
const sessionView = createSessionViewModel(session, currentTime, t);
|
const sessionView = createSessionViewModel(session, currentTime, t);
|
||||||
const isSelected = selectedSession?.id === session.id;
|
const isSelected = selectedSession?.id === session.id;
|
||||||
const isEditing = editingSession === session.id;
|
|
||||||
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
|
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
|
||||||
const editingContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// The rename panel sits inside a group-hover opacity wrapper, so leaving the row
|
|
||||||
// would visually hide it. While editing, dismiss only when the user clicks outside
|
|
||||||
// the panel (matches Escape / cancel-button behaviour).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isEditing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePointerDown = (event: MouseEvent) => {
|
|
||||||
const container = editingContainerRef.current;
|
|
||||||
if (container && !container.contains(event.target as Node)) {
|
|
||||||
onCancelEditingSession();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handlePointerDown);
|
|
||||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
|
||||||
}, [isEditing, onCancelEditingSession]);
|
|
||||||
|
|
||||||
// Sessions are owned by a project identified by `projectId` (DB primary key)
|
// Sessions are owned by a project identified by `projectId` (DB primary key)
|
||||||
// after the projectName → projectId migration.
|
// after the projectName → projectId migration.
|
||||||
@@ -119,13 +97,7 @@ export default function SidebarSessionItem({
|
|||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
{sessionView.isActive && (
|
{sessionView.isActive && (
|
||||||
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
|
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
|
||||||
<Tooltip content={t('tooltips.activeSessionIndicator')} position="right">
|
<div className="h-2 w-2 animate-pulse rounded-full bg-green-500" />
|
||||||
<div
|
|
||||||
role="status"
|
|
||||||
aria-label={t('tooltips.activeSessionIndicator')}
|
|
||||||
className="h-2 w-2 animate-pulse rounded-full bg-green-500"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -196,12 +168,7 @@ export default function SidebarSessionItem({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||||
{compactSessionAge && (
|
{compactSessionAge && (
|
||||||
<span
|
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200 group-hover:opacity-0">
|
||||||
className={cn(
|
|
||||||
'ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200',
|
|
||||||
isEditing ? 'opacity-0' : 'group-hover:opacity-0',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{compactSessionAge}
|
{compactSessionAge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -213,14 +180,8 @@ export default function SidebarSessionItem({
|
|||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div
|
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center gap-1 opacity-0 transition-all duration-200 group-hover:opacity-100">
|
||||||
ref={editingContainerRef}
|
{editingSession === session.id ? (
|
||||||
className={cn(
|
|
||||||
'absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center gap-1 transition-all duration-200',
|
|
||||||
isEditing ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isEditing ? (
|
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { authenticatedFetch } from "../../../utils/api";
|
import { authenticatedFetch } from "../../../utils/api";
|
||||||
import { ReleaseInfo } from "../../../types/sharedTypes";
|
import { ReleaseInfo } from "../../../types/sharedTypes";
|
||||||
@@ -156,10 +154,8 @@ export function VersionUpgradeModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-64 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-600 dark:bg-gray-700/50">
|
<div className="max-h-64 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-600 dark:bg-gray-700/50">
|
||||||
<div className="prose prose-sm max-w-none text-sm text-gray-700 dark:prose-invert dark:text-gray-300">
|
<div className="prose prose-sm max-w-none whitespace-pre-wrap text-sm text-gray-700 dark:prose-invert dark:text-gray-300">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={changelogComponents}>
|
{cleanChangelog(releaseInfo.body)}
|
||||||
{cleanChangelog(releaseInfo.body)}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,14 +236,6 @@ export function VersionUpgradeModal({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changelogComponents = {
|
|
||||||
a: ({ href, children }: { href?: string; children?: ReactNode }) => (
|
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline dark:text-blue-400">
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clean up changelog by removing GitHub-specific metadata
|
// Clean up changelog by removing GitHub-specific metadata
|
||||||
const cleanChangelog = (body: string) => {
|
const cleanChangelog = (body: string) => {
|
||||||
if (!body) return '';
|
if (!body) return '';
|
||||||
|
|||||||
@@ -36,12 +36,8 @@ const useWebSocketProviderState = (): WebSocketContextType => {
|
|||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// The cleanup below sets unmountedRef = true. Without this reset, every
|
|
||||||
// re-run of the effect (e.g. on token refresh) would short-circuit connect()
|
|
||||||
// at its unmounted guard and leave the socket permanently disconnected.
|
|
||||||
unmountedRef.current = false;
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unmountedRef.current = true;
|
unmountedRef.current = true;
|
||||||
if (reconnectTimeoutRef.current) {
|
if (reconnectTimeoutRef.current) {
|
||||||
|
|||||||
@@ -12,14 +12,12 @@ import type {
|
|||||||
ProjectsUpdatedMessage,
|
ProjectsUpdatedMessage,
|
||||||
} from '../types/app';
|
} from '../types/app';
|
||||||
|
|
||||||
import type { SessionActivityMap } from './useSessionProtection';
|
|
||||||
|
|
||||||
type UseProjectsStateArgs = {
|
type UseProjectsStateArgs = {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
navigate: NavigateFunction;
|
navigate: NavigateFunction;
|
||||||
latestMessage: AppSocketMessage | null;
|
latestMessage: AppSocketMessage | null;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
activeSessions: SessionActivityMap;
|
activeSessions: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FetchProjectsOptions = {
|
type FetchProjectsOptions = {
|
||||||
|
|||||||
@@ -1,103 +1,55 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
/**
|
|
||||||
* Map key for a request that is in flight before the provider has announced
|
|
||||||
* its real session id (a brand-new conversation). `session_created` migrates
|
|
||||||
* the entry to the concrete session id.
|
|
||||||
*/
|
|
||||||
export const PENDING_SESSION_ID = '__pending_session__';
|
|
||||||
|
|
||||||
export interface SessionActivity {
|
|
||||||
/** Provider-supplied status line; null renders the default activity label. */
|
|
||||||
statusText: string | null;
|
|
||||||
canInterrupt: boolean;
|
|
||||||
/**
|
|
||||||
* When this request was first marked as processing (client clock). Drives
|
|
||||||
* the elapsed-time display and the stale `session-status` reply guard.
|
|
||||||
*/
|
|
||||||
startedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionActivityMap = ReadonlyMap<string, SessionActivity>;
|
|
||||||
|
|
||||||
export type MarkSessionProcessing = (
|
|
||||||
sessionId?: string | null,
|
|
||||||
activity?: { statusText?: string | null; canInterrupt?: boolean },
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
export type MarkSessionIdle = (
|
|
||||||
sessionId?: string | null,
|
|
||||||
opts?: { ifStartedBefore?: number },
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single source of truth for which sessions are actively processing a
|
|
||||||
* request. Everything the chat UI shows (activity indicator, abort
|
|
||||||
* availability, status text) is derived from this map; terminal events
|
|
||||||
* (`complete`, `error`, abort, an authoritative idle status reply) delete the
|
|
||||||
* entry atomically. The map also drives session protection: project refreshes
|
|
||||||
* are suppressed for sessions that have an entry here.
|
|
||||||
*/
|
|
||||||
export function useSessionProtection() {
|
export function useSessionProtection() {
|
||||||
const [processingSessions, setProcessingSessions] = useState<Map<string, SessionActivity>>(
|
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
|
||||||
new Map(),
|
const [processingSessions, setProcessingSessions] = useState<Set<string>>(new Set());
|
||||||
);
|
|
||||||
|
|
||||||
const markSessionProcessing = useCallback<MarkSessionProcessing>((sessionId, activity) => {
|
const markSessionAsActive = useCallback((sessionId?: string | null) => {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProcessingSessions((prev) => {
|
setActiveSessions((prev) => new Set([...prev, sessionId]));
|
||||||
const existing = prev.get(sessionId);
|
}, []);
|
||||||
const next: SessionActivity = {
|
|
||||||
statusText:
|
|
||||||
activity?.statusText !== undefined ? activity.statusText : existing?.statusText ?? null,
|
|
||||||
canInterrupt: activity?.canInterrupt ?? existing?.canInterrupt ?? true,
|
|
||||||
startedAt: existing?.startedAt ?? Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
const markSessionAsInactive = useCallback((sessionId?: string | null) => {
|
||||||
existing
|
if (!sessionId) {
|
||||||
&& existing.statusText === next.statusText
|
return;
|
||||||
&& existing.canInterrupt === next.canInterrupt
|
}
|
||||||
) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = new Map(prev);
|
setActiveSessions((prev) => {
|
||||||
updated.set(sessionId, next);
|
const next = new Set(prev);
|
||||||
return updated;
|
next.delete(sessionId);
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const markSessionIdle = useCallback<MarkSessionIdle>((sessionId, opts) => {
|
const markSessionAsProcessing = useCallback((sessionId?: string | null) => {
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessingSessions((prev) => new Set([...prev, sessionId]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markSessionAsNotProcessing = useCallback((sessionId?: string | null) => {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProcessingSessions((prev) => {
|
setProcessingSessions((prev) => {
|
||||||
const existing = prev.get(sessionId);
|
const next = new Set(prev);
|
||||||
if (!existing) {
|
next.delete(sessionId);
|
||||||
return prev;
|
return next;
|
||||||
}
|
|
||||||
|
|
||||||
// Guard against stale `check-session-status` replies: if a new request
|
|
||||||
// started after the check was sent, the idle reply describes the older
|
|
||||||
// request and must not clear the newer one.
|
|
||||||
if (opts?.ifStartedBefore !== undefined && existing.startedAt >= opts.ifStartedBefore) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = new Map(prev);
|
|
||||||
updated.delete(sessionId);
|
|
||||||
return updated;
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
activeSessions,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
markSessionProcessing,
|
markSessionAsActive,
|
||||||
markSessionIdle,
|
markSessionAsInactive,
|
||||||
|
markSessionAsProcessing,
|
||||||
|
markSessionAsNotProcessing,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,15 +84,6 @@ import itCodeEditor from './locales/it/codeEditor.json';
|
|||||||
// eslint-disable-next-line import-x/order
|
// eslint-disable-next-line import-x/order
|
||||||
import itTasks from './locales/it/tasks.json';
|
import itTasks from './locales/it/tasks.json';
|
||||||
|
|
||||||
import zhTWCommon from './locales/zh-TW/common.json';
|
|
||||||
import zhTWSettings from './locales/zh-TW/settings.json';
|
|
||||||
import zhTWAuth from './locales/zh-TW/auth.json';
|
|
||||||
import zhTWSidebar from './locales/zh-TW/sidebar.json';
|
|
||||||
import zhTWChat from './locales/zh-TW/chat.json';
|
|
||||||
import zhTWCodeEditor from './locales/zh-TW/codeEditor.json';
|
|
||||||
// eslint-disable-next-line import-x/order
|
|
||||||
import zhTWTasks from './locales/zh-TW/tasks.json';
|
|
||||||
|
|
||||||
// Import supported languages configuration
|
// Import supported languages configuration
|
||||||
import { languages } from './languages.js';
|
import { languages } from './languages.js';
|
||||||
|
|
||||||
@@ -187,15 +178,6 @@ i18n
|
|||||||
codeEditor: itCodeEditor,
|
codeEditor: itCodeEditor,
|
||||||
tasks: itTasks,
|
tasks: itTasks,
|
||||||
},
|
},
|
||||||
'zh-TW': {
|
|
||||||
common: zhTWCommon,
|
|
||||||
settings: zhTWSettings,
|
|
||||||
auth: zhTWAuth,
|
|
||||||
sidebar: zhTWSidebar,
|
|
||||||
chat: zhTWChat,
|
|
||||||
codeEditor: zhTWCodeEditor,
|
|
||||||
tasks: zhTWTasks,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Default language
|
// Default language
|
||||||
|
|||||||
@@ -24,11 +24,6 @@ export const languages = [
|
|||||||
label: 'Simplified Chinese',
|
label: 'Simplified Chinese',
|
||||||
nativeName: '简体中文',
|
nativeName: '简体中文',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: 'zh-TW',
|
|
||||||
label: 'Traditional Chinese',
|
|
||||||
nativeName: '繁體中文',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: 'ja',
|
value: 'ja',
|
||||||
label: 'Japanese',
|
label: 'Japanese',
|
||||||
|
|||||||
@@ -138,6 +138,42 @@
|
|||||||
"clearInput": "Eingabe leeren",
|
"clearInput": "Eingabe leeren",
|
||||||
"scrollToBottom": "Nach unten scrollen"
|
"scrollToBottom": "Nach unten scrollen"
|
||||||
},
|
},
|
||||||
|
"thinkingMode": {
|
||||||
|
"selector": {
|
||||||
|
"title": "Denkmodus",
|
||||||
|
"description": "Erweitertes Denken gibt Claude mehr Zeit, Alternativen zu evaluieren",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"tip": "Höhere Denkmodi brauchen mehr Zeit, liefern aber eine gründlichere Analyse"
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"none": {
|
||||||
|
"name": "Standard",
|
||||||
|
"description": "Reguläre Claude-Antwort",
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"think": {
|
||||||
|
"name": "Denken",
|
||||||
|
"description": "Grundlegendes erweitertes Denken",
|
||||||
|
"prefix": "think"
|
||||||
|
},
|
||||||
|
"thinkHard": {
|
||||||
|
"name": "Intensiv denken",
|
||||||
|
"description": "Gründlichere Auswertung",
|
||||||
|
"prefix": "think hard"
|
||||||
|
},
|
||||||
|
"thinkHarder": {
|
||||||
|
"name": "Sehr intensiv denken",
|
||||||
|
"description": "Tiefgehende Analyse mit Alternativen",
|
||||||
|
"prefix": "think harder"
|
||||||
|
},
|
||||||
|
"ultrathink": {
|
||||||
|
"name": "Ultradenken",
|
||||||
|
"description": "Maximales Denkbudget",
|
||||||
|
"prefix": "ultrathink"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttonTitle": "Denkmodus: {{mode}}"
|
||||||
|
},
|
||||||
"providerSelection": {
|
"providerSelection": {
|
||||||
"title": "KI-Assistent wählen",
|
"title": "KI-Assistent wählen",
|
||||||
"description": "Anbieter auswählen, um eine neue Unterhaltung zu starten",
|
"description": "Anbieter auswählen, um eine neue Unterhaltung zu starten",
|
||||||
|
|||||||
@@ -94,35 +94,9 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & Token",
|
"apiTokens": "API & Token",
|
||||||
"tasks": "Aufgaben",
|
"tasks": "Aufgaben",
|
||||||
"notifications": "Benachrichtigungen",
|
|
||||||
"plugins": "Plugins",
|
"plugins": "Plugins",
|
||||||
"about": "Info"
|
"about": "Info"
|
||||||
},
|
},
|
||||||
"notifications": {
|
|
||||||
"title": "Benachrichtigungen",
|
|
||||||
"description": "Lege fest, welche Benachrichtigungen du erhältst.",
|
|
||||||
"webPush": {
|
|
||||||
"title": "Web-Push-Benachrichtigungen",
|
|
||||||
"enable": "Push-Benachrichtigungen aktivieren",
|
|
||||||
"disable": "Push-Benachrichtigungen deaktivieren",
|
|
||||||
"enabled": "Push-Benachrichtigungen sind aktiviert",
|
|
||||||
"loading": "Wird aktualisiert...",
|
|
||||||
"unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
|
|
||||||
"denied": "Push-Benachrichtigungen sind blockiert. Bitte erlaube sie in den Browsereinstellungen."
|
|
||||||
},
|
|
||||||
"sound": {
|
|
||||||
"title": "Ton",
|
|
||||||
"description": "Spielt einen kurzen Ton ab, wenn ein Chat-Lauf abgeschlossen ist.",
|
|
||||||
"enabled": "Aktiviert",
|
|
||||||
"test": "Ton testen"
|
|
||||||
},
|
|
||||||
"events": {
|
|
||||||
"title": "Ereignistypen",
|
|
||||||
"actionRequired": "Aktion erforderlich",
|
|
||||||
"stop": "Lauf gestoppt",
|
|
||||||
"error": "Lauf fehlgeschlagen"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
"label": "Darkmode",
|
"label": "Darkmode",
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
"removeFromFavorites": "Aus Favoriten entfernen",
|
"removeFromFavorites": "Aus Favoriten entfernen",
|
||||||
"editSessionName": "Sitzungsname manuell bearbeiten",
|
"editSessionName": "Sitzungsname manuell bearbeiten",
|
||||||
"deleteSession": "Diese Sitzung dauerhaft löschen",
|
"deleteSession": "Diese Sitzung dauerhaft löschen",
|
||||||
"activeSessionIndicator": "Kürzlich aktive Sitzung (letzte 10 Minuten)",
|
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"clearSearch": "Suche leeren",
|
"clearSearch": "Suche leeren",
|
||||||
|
|||||||
@@ -139,6 +139,42 @@
|
|||||||
"clearInput": "Clear input",
|
"clearInput": "Clear input",
|
||||||
"scrollToBottom": "Scroll to bottom"
|
"scrollToBottom": "Scroll to bottom"
|
||||||
},
|
},
|
||||||
|
"thinkingMode": {
|
||||||
|
"selector": {
|
||||||
|
"title": "Thinking Mode",
|
||||||
|
"description": "Extended thinking gives Claude more time to evaluate alternatives",
|
||||||
|
"active": "Active",
|
||||||
|
"tip": "Higher thinking modes take more time but provide more thorough analysis"
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"none": {
|
||||||
|
"name": "Standard",
|
||||||
|
"description": "Regular Claude response",
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"think": {
|
||||||
|
"name": "Think",
|
||||||
|
"description": "Basic extended thinking",
|
||||||
|
"prefix": "think"
|
||||||
|
},
|
||||||
|
"thinkHard": {
|
||||||
|
"name": "Think Hard",
|
||||||
|
"description": "More thorough evaluation",
|
||||||
|
"prefix": "think hard"
|
||||||
|
},
|
||||||
|
"thinkHarder": {
|
||||||
|
"name": "Think Harder",
|
||||||
|
"description": "Deep analysis with alternatives",
|
||||||
|
"prefix": "think harder"
|
||||||
|
},
|
||||||
|
"ultrathink": {
|
||||||
|
"name": "Ultrathink",
|
||||||
|
"description": "Maximum thinking budget",
|
||||||
|
"prefix": "ultrathink"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttonTitle": "Thinking mode: {{mode}}"
|
||||||
|
},
|
||||||
"providerSelection": {
|
"providerSelection": {
|
||||||
"title": "Choose Your AI Assistant",
|
"title": "Choose Your AI Assistant",
|
||||||
"description": "Select a provider to start a new conversation",
|
"description": "Select a provider to start a new conversation",
|
||||||
@@ -193,7 +229,7 @@
|
|||||||
"disconnect": "Disconnect",
|
"disconnect": "Disconnect",
|
||||||
"disconnectTitle": "Disconnect from shell",
|
"disconnectTitle": "Disconnect from shell",
|
||||||
"restart": "Restart",
|
"restart": "Restart",
|
||||||
"restartTitle": "Restart Shell",
|
"restartTitle": "Restart Shell (disconnect first)",
|
||||||
"connect": "Continue in Shell",
|
"connect": "Continue in Shell",
|
||||||
"connectTitle": "Connect to shell"
|
"connectTitle": "Connect to shell"
|
||||||
},
|
},
|
||||||
@@ -224,7 +260,6 @@
|
|||||||
"label": "{{time}} elapsed",
|
"label": "{{time}} elapsed",
|
||||||
"startingNow": "Starting now"
|
"startingNow": "Starting now"
|
||||||
},
|
},
|
||||||
"stop": "Stop",
|
|
||||||
"controls": {
|
"controls": {
|
||||||
"stopGeneration": "Stop Generation",
|
"stopGeneration": "Stop Generation",
|
||||||
"pressEscToStop": "Press Esc anytime to stop"
|
"pressEscToStop": "Press Esc anytime to stop"
|
||||||
|
|||||||
@@ -110,12 +110,6 @@
|
|||||||
"unsupported": "Push notifications are not supported in this browser.",
|
"unsupported": "Push notifications are not supported in this browser.",
|
||||||
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
||||||
},
|
},
|
||||||
"sound": {
|
|
||||||
"title": "Sound",
|
|
||||||
"description": "Play a short tone when a chat run finishes.",
|
|
||||||
"enabled": "Enabled",
|
|
||||||
"test": "Test sound"
|
|
||||||
},
|
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Event Types",
|
"title": "Event Types",
|
||||||
"actionRequired": "Action required",
|
"actionRequired": "Action required",
|
||||||
@@ -478,12 +472,6 @@
|
|||||||
"starterPluginLabel": "Starter Plugin",
|
"starterPluginLabel": "Starter Plugin",
|
||||||
"starter": "Starter",
|
"starter": "Starter",
|
||||||
"docs": "Docs",
|
"docs": "Docs",
|
||||||
"sections": {
|
|
||||||
"officialTitle": "Official Plugins",
|
|
||||||
"officialDescription": "Maintained by the CloudCLI team and ready for direct install.",
|
|
||||||
"unofficialTitle": "Other Plugins",
|
|
||||||
"unofficialDescription": "Unofficial plugins and integrations from other users. Review the source before installing."
|
|
||||||
},
|
|
||||||
"starterPlugin": {
|
"starterPlugin": {
|
||||||
"name": "Project Stats",
|
"name": "Project Stats",
|
||||||
"badge": "starter",
|
"badge": "starter",
|
||||||
@@ -496,24 +484,6 @@
|
|||||||
"description": "Integrated terminal with full shell access directly within the interface.",
|
"description": "Integrated terminal with full shell access directly within the interface.",
|
||||||
"install": "Install"
|
"install": "Install"
|
||||||
},
|
},
|
||||||
"scheduledPromptPlugin": {
|
|
||||||
"name": "Scheduled Prompts",
|
|
||||||
"badge": "unofficial",
|
|
||||||
"description": "Schedule workspace prompts, review run history, and manage recurring local tasks.",
|
|
||||||
"install": "Install"
|
|
||||||
},
|
|
||||||
"claudeWatchPlugin": {
|
|
||||||
"name": "Claude Watch",
|
|
||||||
"badge": "unofficial",
|
|
||||||
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
|
|
||||||
"install": "Install"
|
|
||||||
},
|
|
||||||
"prismCloudCLI": {
|
|
||||||
"name": "PRISM CloudCLI",
|
|
||||||
"badge": "unofficial",
|
|
||||||
"description": "Session intelligence for Claude Code, inside CloudCLI. See why your sessions are burning tokens without leaving the browser.",
|
|
||||||
"install": "Install"
|
|
||||||
},
|
|
||||||
"morePlugins": "More",
|
"morePlugins": "More",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
"removeFromFavorites": "Remove from favorites",
|
"removeFromFavorites": "Remove from favorites",
|
||||||
"editSessionName": "Manually edit session name",
|
"editSessionName": "Manually edit session name",
|
||||||
"deleteSession": "Delete this session permanently",
|
"deleteSession": "Delete this session permanently",
|
||||||
"activeSessionIndicator": "Recently active session (last 10 minutes)",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"clearSearch": "Clear search",
|
"clearSearch": "Clear search",
|
||||||
|
|||||||
@@ -138,6 +138,42 @@
|
|||||||
"clearInput": "Cancella input",
|
"clearInput": "Cancella input",
|
||||||
"scrollToBottom": "Scorri in basso"
|
"scrollToBottom": "Scorri in basso"
|
||||||
},
|
},
|
||||||
|
"thinkingMode": {
|
||||||
|
"selector": {
|
||||||
|
"title": "Modalità ragionamento",
|
||||||
|
"description": "Il ragionamento esteso dà a Claude più tempo per valutare le alternative",
|
||||||
|
"active": "Attivo",
|
||||||
|
"tip": "Modalità di ragionamento più elevate richiedono più tempo ma forniscono un'analisi più approfondita"
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"none": {
|
||||||
|
"name": "Standard",
|
||||||
|
"description": "Risposta Claude normale",
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"think": {
|
||||||
|
"name": "Pensa",
|
||||||
|
"description": "Ragionamento esteso base",
|
||||||
|
"prefix": "think"
|
||||||
|
},
|
||||||
|
"thinkHard": {
|
||||||
|
"name": "Pensa di più",
|
||||||
|
"description": "Valutazione più approfondita",
|
||||||
|
"prefix": "think hard"
|
||||||
|
},
|
||||||
|
"thinkHarder": {
|
||||||
|
"name": "Pensa ancora",
|
||||||
|
"description": "Analisi profonda con alternative",
|
||||||
|
"prefix": "think harder"
|
||||||
|
},
|
||||||
|
"ultrathink": {
|
||||||
|
"name": "Ultrapensiero",
|
||||||
|
"description": "Budget massimo di ragionamento",
|
||||||
|
"prefix": "ultrathink"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttonTitle": "Modalità ragionamento: {{mode}}"
|
||||||
|
},
|
||||||
"providerSelection": {
|
"providerSelection": {
|
||||||
"title": "Scegli il tuo assistente AI",
|
"title": "Scegli il tuo assistente AI",
|
||||||
"description": "Seleziona un provider per iniziare una nuova conversazione",
|
"description": "Seleziona un provider per iniziare una nuova conversazione",
|
||||||
|
|||||||
@@ -110,12 +110,6 @@
|
|||||||
"unsupported": "Le notifiche push non sono supportate in questo browser.",
|
"unsupported": "Le notifiche push non sono supportate in questo browser.",
|
||||||
"denied": "Le notifiche push sono bloccate. Abilitale nelle impostazioni del browser."
|
"denied": "Le notifiche push sono bloccate. Abilitale nelle impostazioni del browser."
|
||||||
},
|
},
|
||||||
"sound": {
|
|
||||||
"title": "Suono",
|
|
||||||
"description": "Riproduci un breve tono quando termina un'esecuzione della chat.",
|
|
||||||
"enabled": "Attivato",
|
|
||||||
"test": "Prova suono"
|
|
||||||
},
|
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Tipi di evento",
|
"title": "Tipi di evento",
|
||||||
"actionRequired": "Azione richiesta",
|
"actionRequired": "Azione richiesta",
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
"removeFromFavorites": "Rimuovi dai preferiti",
|
"removeFromFavorites": "Rimuovi dai preferiti",
|
||||||
"editSessionName": "Modifica manualmente il nome della sessione",
|
"editSessionName": "Modifica manualmente il nome della sessione",
|
||||||
"deleteSession": "Elimina questa sessione permanentemente",
|
"deleteSession": "Elimina questa sessione permanentemente",
|
||||||
"activeSessionIndicator": "Sessione attiva di recente (ultimi 10 minuti)",
|
|
||||||
"save": "Salva",
|
"save": "Salva",
|
||||||
"cancel": "Annulla",
|
"cancel": "Annulla",
|
||||||
"clearSearch": "Cancella ricerca",
|
"clearSearch": "Cancella ricerca",
|
||||||
|
|||||||
@@ -117,6 +117,42 @@
|
|||||||
"clickToChangeMode": "クリックで権限モードを変更(または入力欄でTab)",
|
"clickToChangeMode": "クリックで権限モードを変更(または入力欄でTab)",
|
||||||
"showAllCommands": "すべてのコマンドを表示"
|
"showAllCommands": "すべてのコマンドを表示"
|
||||||
},
|
},
|
||||||
|
"thinkingMode": {
|
||||||
|
"selector": {
|
||||||
|
"title": "思考モード",
|
||||||
|
"description": "拡張思考によりClaudeがより多くの選択肢を検討できます",
|
||||||
|
"active": "有効",
|
||||||
|
"tip": "高い思考モードは時間がかかりますが、より深い分析が得られます"
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"none": {
|
||||||
|
"name": "標準",
|
||||||
|
"description": "通常のClaudeの応答",
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"think": {
|
||||||
|
"name": "Think",
|
||||||
|
"description": "基本的な拡張思考",
|
||||||
|
"prefix": "think"
|
||||||
|
},
|
||||||
|
"thinkHard": {
|
||||||
|
"name": "Think Hard",
|
||||||
|
"description": "より深い検討",
|
||||||
|
"prefix": "think hard"
|
||||||
|
},
|
||||||
|
"thinkHarder": {
|
||||||
|
"name": "Think Harder",
|
||||||
|
"description": "代替案を含む深い分析",
|
||||||
|
"prefix": "think harder"
|
||||||
|
},
|
||||||
|
"ultrathink": {
|
||||||
|
"name": "Ultrathink",
|
||||||
|
"description": "最大限の思考予算",
|
||||||
|
"prefix": "ultrathink"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttonTitle": "思考モード: {{mode}}"
|
||||||
|
},
|
||||||
"providerSelection": {
|
"providerSelection": {
|
||||||
"title": "AIアシスタントを選択",
|
"title": "AIアシスタントを選択",
|
||||||
"description": "新しい会話を始めるプロバイダーを選択してください",
|
"description": "新しい会話を始めるプロバイダーを選択してください",
|
||||||
|
|||||||
@@ -110,12 +110,6 @@
|
|||||||
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
||||||
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
||||||
},
|
},
|
||||||
"sound": {
|
|
||||||
"title": "サウンド",
|
|
||||||
"description": "チャット実行が完了したときに短い音を再生します。",
|
|
||||||
"enabled": "有効",
|
|
||||||
"test": "サウンドをテスト"
|
|
||||||
},
|
|
||||||
"events": {
|
"events": {
|
||||||
"title": "イベント種別",
|
"title": "イベント種別",
|
||||||
"actionRequired": "対応が必要",
|
"actionRequired": "対応が必要",
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
"removeFromFavorites": "お気に入りから削除",
|
"removeFromFavorites": "お気に入りから削除",
|
||||||
"editSessionName": "セッション名を手動で編集",
|
"editSessionName": "セッション名を手動で編集",
|
||||||
"deleteSession": "このセッションを完全に削除",
|
"deleteSession": "このセッションを完全に削除",
|
||||||
"activeSessionIndicator": "最近アクティブなセッション(過去10分以内)",
|
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"openCommandPalette": "コマンドパレットを開く"
|
"openCommandPalette": "コマンドパレットを開く"
|
||||||
|
|||||||
@@ -120,6 +120,42 @@
|
|||||||
"clearInput": "입력 지우기",
|
"clearInput": "입력 지우기",
|
||||||
"scrollToBottom": "맨 아래로 스크롤"
|
"scrollToBottom": "맨 아래로 스크롤"
|
||||||
},
|
},
|
||||||
|
"thinkingMode": {
|
||||||
|
"selector": {
|
||||||
|
"title": "Thinking 모드",
|
||||||
|
"description": "확장된 thinking은 Claude에게 대안을 평가할 시간을 더 줍니다",
|
||||||
|
"active": "활성",
|
||||||
|
"tip": "높은 thinking 모드는 시간이 더 걸리지만 더 철저한 분석을 제공합니다"
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"none": {
|
||||||
|
"name": "Standard",
|
||||||
|
"description": "일반 Claude 응답",
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"think": {
|
||||||
|
"name": "Think",
|
||||||
|
"description": "기본 확장 thinking",
|
||||||
|
"prefix": "think"
|
||||||
|
},
|
||||||
|
"thinkHard": {
|
||||||
|
"name": "Think Hard",
|
||||||
|
"description": "더 철저한 평가",
|
||||||
|
"prefix": "think hard"
|
||||||
|
},
|
||||||
|
"thinkHarder": {
|
||||||
|
"name": "Think Harder",
|
||||||
|
"description": "대안을 포함한 심층 분석",
|
||||||
|
"prefix": "think harder"
|
||||||
|
},
|
||||||
|
"ultrathink": {
|
||||||
|
"name": "Ultrathink",
|
||||||
|
"description": "최대 thinking 예산",
|
||||||
|
"prefix": "ultrathink"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttonTitle": "Thinking 모드: {{mode}}"
|
||||||
|
},
|
||||||
"providerSelection": {
|
"providerSelection": {
|
||||||
"title": "AI 어시스턴트 선택",
|
"title": "AI 어시스턴트 선택",
|
||||||
"description": "새 대화를 시작할 프로바이더를 선택하세요",
|
"description": "새 대화를 시작할 프로바이더를 선택하세요",
|
||||||
|
|||||||
@@ -110,12 +110,6 @@
|
|||||||
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
||||||
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
||||||
},
|
},
|
||||||
"sound": {
|
|
||||||
"title": "소리",
|
|
||||||
"description": "채팅 실행이 완료되면 짧은 알림음을 재생합니다.",
|
|
||||||
"enabled": "사용",
|
|
||||||
"test": "소리 테스트"
|
|
||||||
},
|
|
||||||
"events": {
|
"events": {
|
||||||
"title": "이벤트 유형",
|
"title": "이벤트 유형",
|
||||||
"actionRequired": "작업 필요",
|
"actionRequired": "작업 필요",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user