mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-18 22:57:31 +08:00
Compare commits
51 Commits
chore/add-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a12ca8eed3 | ||
|
|
e88539170e | ||
|
|
c03ddb25fe | ||
|
|
d7a38a567a | ||
|
|
fec91d3deb | ||
|
|
c6c153e7f2 | ||
|
|
4758ccf36e | ||
|
|
e23e6af06a | ||
|
|
56b2e14059 | ||
|
|
39b0473e38 | ||
|
|
f319d2cf8d | ||
|
|
9fb2d91b26 | ||
|
|
9cb2afd67e | ||
|
|
d0adddbbda | ||
|
|
2abb45636b | ||
|
|
677d330981 | ||
|
|
1b336e9aa9 | ||
|
|
7bed675ad5 | ||
|
|
5b9adbbdee | ||
|
|
416a737d76 | ||
|
|
3bbb42c233 | ||
|
|
123ae31020 | ||
|
|
89f05247ed | ||
|
|
00e526b6e9 | ||
|
|
591b18e9e3 | ||
|
|
881e72d4a0 | ||
|
|
f5eac2ec12 | ||
|
|
3d948217ef | ||
|
|
86f64797b0 | ||
|
|
21b0f14e7a | ||
|
|
f12af8a61b | ||
|
|
afc717e69e | ||
|
|
f549bd99e7 | ||
|
|
bc34085af9 | ||
|
|
6a53c31e90 | ||
|
|
92de0ed613 | ||
|
|
b6a45b3183 | ||
|
|
ce327b6fa9 | ||
|
|
276639099b | ||
|
|
f4f88318c2 | ||
|
|
029d159592 | ||
|
|
7c9ec8fa12 | ||
|
|
1b4d4b7278 | ||
|
|
b1a0afe9e0 | ||
|
|
88eb2009bb | ||
|
|
602e6ad4ac | ||
|
|
4a2453fe32 | ||
|
|
23210bc40e | ||
|
|
beae8c6513 | ||
|
|
33a4e72ca4 | ||
|
|
d70dc077bf |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -134,6 +134,7 @@ tasks/
|
|||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
!src/i18n/locales/en/tasks.json
|
!src/i18n/locales/en/tasks.json
|
||||||
|
!src/i18n/locales/fr/tasks.json
|
||||||
!src/i18n/locales/ja/tasks.json
|
!src/i18n/locales/ja/tasks.json
|
||||||
!src/i18n/locales/ru/tasks.json
|
!src/i18n/locales/ru/tasks.json
|
||||||
!src/i18n/locales/de/tasks.json
|
!src/i18n/locales/de/tasks.json
|
||||||
|
|||||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -3,6 +3,33 @@
|
|||||||
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)
|
## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08)
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
10
README.de.md
10
README.de.md
@@ -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 in [`public/modelConstants.js`](public/modelConstants.js))
|
- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle zur Laufzeit über `GET /api/providers/:provider/models`)
|
||||||
|
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
@@ -164,6 +164,14 @@ CloudCLI verfügt über ein Plugin-System, mit dem benutzerdefinierte Tabs mit e
|
|||||||
| Plugin | Beschreibung |
|
| Plugin | Beschreibung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts |
|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts |
|
||||||
|
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Vollwertiges xterm.js-Terminal mit Multi-Tab-Unterstützung |
|
||||||
|
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Überwacht lange laufende Claude-Code-Sitzungen auf Hänger und stellt Prozesssteuerungen bereit |
|
||||||
|
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Erstellt arbeitsbereichsbezogene geplante Prompts und führt sie über eine lokale CLI wie Codex, Claude Code oder Gemini CLI aus |
|
||||||
|
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Sitzungsintelligenz für Claude Code in CloudCLI, inklusive Sichtbarkeit des Token-Verbrauchs |
|
||||||
|
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktive Claude-Code-Sitzungen anzeigen, verwalten und beenden |
|
||||||
|
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | API-Kosten anhand von Modellpreisen und Token-Nutzung berechnen, mit Unterstützung für Preisvorlagen |
|
||||||
|
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task-Queue-Dashboard zum Anzeigen, Filtern und Starten von Agent-Aufgaben |
|
||||||
|
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-Board für GitHub Issues mit bidirektionaler TaskMaster-Synchronisierung und automatischer Installation des /github-task CLI-Skills |
|
||||||
|
|
||||||
### Eigenes Plugin erstellen
|
### Eigenes Plugin erstellen
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,14 @@ CloudCLI にはプラグインシステムがあり、独自のフロントエ
|
|||||||
| プラグイン | 説明 |
|
| プラグイン | 説明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 |
|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 |
|
||||||
|
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 複数タブに対応した本格的な xterm.js ターミナル |
|
||||||
|
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 長時間実行中の Claude Code セッションのハングを監視し、プロセス操作を提供 |
|
||||||
|
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | ワークスペース単位のスケジュール済みプロンプトを作成し、Codex、Claude Code、Gemini CLI などのローカル CLI で実行 |
|
||||||
|
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 内で Claude Code のセッション分析を行い、トークン消費の可視化も提供 |
|
||||||
|
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | アクティブな Claude Code セッションを表示、管理、終了 |
|
||||||
|
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | モデル価格とトークン使用量から API コストを計算し、モデル価格プリセットにも対応 |
|
||||||
|
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | エージェントタスクを表示、フィルタリング、起動するためのタスクキューダッシュボード |
|
||||||
|
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues 用の Kanban ボード。TaskMaster との双方向同期と /github-task CLI スキルの自動インストールに対応 |
|
||||||
|
|
||||||
### 自作する
|
### 自作する
|
||||||
|
|
||||||
|
|||||||
10
README.ko.md
10
README.ko.md
@@ -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 모델 계열에서 작동 (`public/modelConstants.js`에서 전체 지원 모델 확인)
|
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`GET /api/providers/:provider/models` API에서 전체 지원 모델 확인)
|
||||||
|
|
||||||
## 빠른 시작
|
## 빠른 시작
|
||||||
|
|
||||||
@@ -158,6 +158,14 @@ CloudCLI는 커스텀 탭과 선택적 Node.js 백엔드가 포함된 플러그
|
|||||||
| 플러그인 | 설명 |
|
| 플러그인 | 설명 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 |
|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 |
|
||||||
|
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 다중 탭을 지원하는 전체 xterm.js 터미널 |
|
||||||
|
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 장시간 실행 중인 Claude Code 세션의 중단 상태를 감시하고 프로세스 제어를 제공 |
|
||||||
|
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 워크스페이스 범위 예약 프롬프트를 만들고 Codex, Claude Code, Gemini CLI 같은 로컬 CLI로 실행 |
|
||||||
|
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 안에서 Claude Code 세션 인텔리전스와 토큰 소모 가시성을 제공 |
|
||||||
|
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 활성 Claude Code 세션을 보고, 관리하고, 종료 |
|
||||||
|
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 모델 가격과 토큰 사용량으로 API 비용을 계산하고 모델 가격 프리셋을 지원 |
|
||||||
|
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 에이전트 작업을 보고, 필터링하고, 실행하는 작업 큐 대시보드 |
|
||||||
|
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues용 Kanban 보드. TaskMaster 양방향 동기화와 /github-task CLI 스킬 자동 설치 지원 |
|
||||||
|
|
||||||
### 직접 만들기
|
### 직접 만들기
|
||||||
|
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -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 (see [`public/modelConstants.js`](public/modelConstants.js) for the full list of supported models)
|
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
|
||||||
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -163,8 +163,15 @@ CloudCLI has a plugin system that lets you add custom tabs with their own fronte
|
|||||||
| Plugin | Description |
|
| Plugin | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |
|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |
|
||||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support|
|
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support |
|
||||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI|
|
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Watches long-running Claude Code sessions for hangs and exposes process controls |
|
||||||
|
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI |
|
||||||
|
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Session intelligence for Claude Code inside CloudCLI, including token burn visibility |
|
||||||
|
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | View, manage, and kill active Claude Code sessions |
|
||||||
|
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Calculate API costs from model prices and token usage, with preset model pricing support |
|
||||||
|
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task queue dashboard to view, filter, and launch agent tasks |
|
||||||
|
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban board for GitHub Issues with bidirectional TaskMaster sync and /github-task CLI skill auto-install |
|
||||||
|
|
||||||
### Build Your Own
|
### Build Your Own
|
||||||
|
|
||||||
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server.
|
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server.
|
||||||
|
|||||||
10
README.ru.md
10
README.ru.md
@@ -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 (см. [`public/modelConstants.js`](public/modelConstants.js) для полного списка поддерживаемых моделей)
|
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (полный список поддерживаемых моделей доступен через `GET /api/providers/:provider/models`)
|
||||||
|
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
@@ -164,6 +164,14 @@ CloudCLI UI — это open source UI-слой, на котором постро
|
|||||||
| Плагин | Описание |
|
| Плагин | Описание |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта |
|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта |
|
||||||
|
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Полноценный терминал xterm.js с поддержкой нескольких вкладок |
|
||||||
|
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Отслеживает зависания долгих сессий Claude Code и предоставляет управление процессами |
|
||||||
|
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Создаёт запланированные промпты для рабочей области и запускает их через локальную CLI, например Codex, Claude Code или Gemini CLI |
|
||||||
|
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Аналитика сессий Claude Code внутри CloudCLI, включая видимость расхода токенов |
|
||||||
|
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Просмотр, управление и завершение активных сессий Claude Code |
|
||||||
|
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Расчёт стоимости API по ценам моделей и использованию токенов, с поддержкой пресетов цен |
|
||||||
|
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Дашборд очереди задач для просмотра, фильтрации и запуска агентских задач |
|
||||||
|
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-доска для GitHub Issues с двусторонней синхронизацией TaskMaster и автоустановкой CLI-навыка /github-task |
|
||||||
|
|
||||||
### Создать свой
|
### Создать свой
|
||||||
|
|
||||||
|
|||||||
@@ -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 [`public/modelConstants.js`](public/modelConstants.js) dosyasına bak)
|
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için `GET /api/providers/:provider/models` API'sine bak)
|
||||||
|
|
||||||
|
|
||||||
## Hızlı Başlangıç
|
## Hızlı Başlangıç
|
||||||
@@ -164,6 +164,13 @@ CloudCLI, kendi frontend UI'sı ve isteğe bağlı Node.js arka ucu olan özel s
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Mevcut projen için dosya sayıları, kod satırları, dosya türü dağılımı, en büyük dosyalar ve son değiştirilen dosyaları gösterir |
|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Mevcut projen için dosya sayıları, kod satırları, dosya türü dağılımı, en büyük dosyalar ve son değiştirilen dosyaları gösterir |
|
||||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Çoklu sekme destekli tam xterm.js terminali |
|
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Çoklu sekme destekli tam xterm.js terminali |
|
||||||
|
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Uzun süren Claude Code oturumlarını takılmalara karşı izler ve süreç kontrolleri sunar |
|
||||||
|
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Çalışma alanı kapsamlı zamanlanmış prompt'lar oluşturur ve bunları Codex, Claude Code veya Gemini CLI gibi yerel CLI'larla çalıştırır |
|
||||||
|
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI içinde Claude Code oturum zekası ve token tüketimi görünürlüğü sağlar |
|
||||||
|
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktif Claude Code oturumlarını görüntülemeni, yönetmeni ve sonlandırmanı sağlar |
|
||||||
|
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Model fiyatları ve token kullanımından API maliyetlerini hesaplar; model fiyatı hazır ayarlarını destekler |
|
||||||
|
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Ajan görevlerini görüntülemek, filtrelemek ve başlatmak için görev kuyruğu paneli |
|
||||||
|
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues için Kanban panosu; çift yönlü TaskMaster senkronizasyonu ve /github-task CLI becerisi otomatik kurulumu içerir |
|
||||||
|
|
||||||
### Kendi Eklentini Yaz
|
### Kendi Eklentini Yaz
|
||||||
|
|
||||||
|
|||||||
@@ -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 模型家族(完整支持列表见 [`public/modelConstants.js`](public/modelConstants.js))
|
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表可通过 `GET /api/providers/:provider/models` 接口获取)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -158,6 +158,14 @@ CloudCLI 配备插件系统,允许你添加带自定义前端 UI 和可选 Nod
|
|||||||
| 插件 | 描述 |
|
| 插件 | 描述 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 |
|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 |
|
||||||
|
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支持多标签页的完整 xterm.js 终端 |
|
||||||
|
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 监控长时间运行的 Claude Code 会话是否卡住,并提供进程控制 |
|
||||||
|
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 创建工作区范围的定时提示词,并通过 Codex、Claude Code 或 Gemini CLI 等本地 CLI 执行 |
|
||||||
|
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 会话智能分析,包括 token 消耗可视化 |
|
||||||
|
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 查看、管理并终止活动的 Claude Code 会话 |
|
||||||
|
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根据模型价格和 token 用量计算 API 成本,并支持模型价格预设 |
|
||||||
|
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用于查看、筛选和启动代理任务的任务队列仪表板 |
|
||||||
|
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用于 GitHub Issues 的看板,支持 TaskMaster 双向同步和 /github-task CLI 技能自动安装 |
|
||||||
|
|
||||||
### 自行构建
|
### 自行构建
|
||||||
|
|
||||||
|
|||||||
@@ -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 模型家族(完整支援列表見 [`shared/modelConstants.js`](shared/modelConstants.js))
|
- **模型相容性** — 支援 Claude、GPT、Gemini 模型家族(完整支援列表可透過 `GET /api/providers/:provider/models` 介面取得)
|
||||||
|
|
||||||
## 快速開始
|
## 快速開始
|
||||||
|
|
||||||
@@ -158,6 +158,14 @@ CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Nod
|
|||||||
| 外掛 | 描述 |
|
| 外掛 | 描述 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 |
|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 |
|
||||||
|
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支援多分頁的完整 xterm.js 終端機 |
|
||||||
|
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 監控長時間執行的 Claude Code 工作階段是否卡住,並提供程序控制 |
|
||||||
|
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 建立工作區範圍的排程提示詞,並透過 Codex、Claude Code 或 Gemini CLI 等本機 CLI 執行 |
|
||||||
|
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 工作階段智慧分析,包括 token 消耗可視化 |
|
||||||
|
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 檢視、管理並終止作用中的 Claude Code 工作階段 |
|
||||||
|
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根據模型價格與 token 用量計算 API 成本,並支援模型價格預設 |
|
||||||
|
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用於檢視、篩選和啟動代理任務的任務佇列儀表板 |
|
||||||
|
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用於 GitHub Issues 的看板,支援 TaskMaster 雙向同步和 /github-task CLI 技能自動安裝 |
|
||||||
|
|
||||||
### 自行建構
|
### 自行建構
|
||||||
|
|
||||||
|
|||||||
2996
package-lock.json
generated
2996
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
59
package.json
59
package.json
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.33.2",
|
"version": "1.34.0",
|
||||||
|
"productName": "CloudCLI",
|
||||||
"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",
|
||||||
@@ -8,10 +9,10 @@
|
|||||||
"cloudcli": "dist-server/server/cli.js"
|
"cloudcli": "dist-server/server/cli.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
"electron/",
|
||||||
"server/",
|
"server/",
|
||||||
"shared/",
|
"shared/",
|
||||||
"public/api-docs.html",
|
"public/api-docs.html",
|
||||||
"public/modelConstants.js",
|
|
||||||
"dist/",
|
"dist/",
|
||||||
"dist-server/",
|
"dist-server/",
|
||||||
"scripts/",
|
"scripts/",
|
||||||
@@ -31,6 +32,10 @@
|
|||||||
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
|
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
|
||||||
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
|
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
|
||||||
"client": "vite",
|
"client": "vite",
|
||||||
|
"desktop": "electron electron/main.js",
|
||||||
|
"desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
|
||||||
|
"desktop:pack": "npm run build && electron-builder --dir",
|
||||||
|
"desktop:dist:mac": "npm run build && electron-builder --mac dmg zip",
|
||||||
"build": "npm run build:client && npm run build:server",
|
"build": "npm run build:client && npm run build:server",
|
||||||
"build:client": "vite build",
|
"build:client": "vite build",
|
||||||
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
|
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
|
||||||
@@ -46,6 +51,53 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"update:platform": "./update-platform.sh"
|
"update:platform": "./update-platform.sh"
|
||||||
},
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "ai.cloudcli.desktop",
|
||||||
|
"productName": "CloudCLI",
|
||||||
|
"artifactName": "CloudCLI-${version}-${arch}.${ext}",
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"extraMetadata": {
|
||||||
|
"main": "electron/main.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"electron/",
|
||||||
|
"public/",
|
||||||
|
"dist/",
|
||||||
|
"dist-server/",
|
||||||
|
"shared/",
|
||||||
|
"server/",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"protocols": [
|
||||||
|
{
|
||||||
|
"name": "CloudCLI",
|
||||||
|
"schemes": [
|
||||||
|
"cloudcli"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mac": {
|
||||||
|
"category": "public.app-category.developer-tools",
|
||||||
|
"target": [
|
||||||
|
"dmg",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"extendInfo": {
|
||||||
|
"CFBundleName": "CloudCLI",
|
||||||
|
"CFBundleDisplayName": "CloudCLI",
|
||||||
|
"CFBundleURLTypes": [
|
||||||
|
{
|
||||||
|
"CFBundleURLName": "CloudCLI",
|
||||||
|
"CFBundleURLSchemes": [
|
||||||
|
"cloudcli"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude code",
|
"claude code",
|
||||||
"claude-code",
|
"claude-code",
|
||||||
@@ -142,6 +194,9 @@
|
|||||||
"auto-changelog": "^2.5.0",
|
"auto-changelog": "^2.5.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
|
"electron": "^38.0.0",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
"eslint": "^9.39.3",
|
"eslint": "^9.39.3",
|
||||||
"eslint-import-resolver-typescript": "^4.4.4",
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
"eslint-plugin-boundaries": "^6.0.2",
|
"eslint-plugin-boundaries": "^6.0.2",
|
||||||
|
|||||||
@@ -820,31 +820,49 @@ data: {"type":"done"}</code></pre>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<script>
|
||||||
// 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;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dynamically populate model documentation
|
// Populate model documentation from the live provider API
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
const PROVIDER_ORDER = [
|
||||||
const modelCell = document.getElementById('model-options-cell');
|
{ id: 'claude', name: 'Anthropic' },
|
||||||
if (modelCell) {
|
{ id: 'codex', name: 'OpenAI' },
|
||||||
const providerModels = PROVIDERS.map(provider => {
|
{ id: 'gemini', name: 'Google' },
|
||||||
const models = provider.models.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
{ id: 'cursor', name: 'Cursor' },
|
||||||
return `<strong>${provider.name}:</strong> ${models} (default: <code>${provider.models.DEFAULT}</code>)`;
|
{ id: 'opencode', name: 'OpenCode' },
|
||||||
}).join('<br><br>');
|
];
|
||||||
|
|
||||||
modelCell.innerHTML = `
|
async function populateModels() {
|
||||||
Model identifier for the AI provider:<br><br>
|
const modelCell = document.getElementById('model-options-cell');
|
||||||
${providerModels}
|
if (!modelCell) return;
|
||||||
`;
|
|
||||||
}
|
const token = localStorage.getItem('auth-token');
|
||||||
});
|
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
PROVIDER_ORDER.map(({ id }) =>
|
||||||
|
fetch(`/api/providers/${id}/models`, { headers }).then(r => r.json())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const providerModels = results.map((result, i) => {
|
||||||
|
const { name } = PROVIDER_ORDER[i];
|
||||||
|
if (result.status === 'rejected' || !result.value?.data?.models) {
|
||||||
|
return `<strong>${name}:</strong> <em>unavailable</em>`;
|
||||||
|
}
|
||||||
|
const { OPTIONS, DEFAULT } = result.value.data.models;
|
||||||
|
const models = OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
||||||
|
return `<strong>${name}:</strong> ${models} (default: <code>${DEFAULT}</code>)`;
|
||||||
|
}).join('<br><br>');
|
||||||
|
|
||||||
|
modelCell.innerHTML = `Model identifier for the AI provider:<br><br>${providerModels}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', populateModels);
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
window.showTab = function(tabName) {
|
window.showTab = function(tabName) {
|
||||||
|
|||||||
@@ -1,848 +0,0 @@
|
|||||||
/**
|
|
||||||
* Documentation Model Definitions
|
|
||||||
* Used by README links and the public API docs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Claude (Anthropic) Models
|
|
||||||
*/
|
|
||||||
export const CLAUDE_MODELS = {
|
|
||||||
OPTIONS: [
|
|
||||||
{
|
|
||||||
value: "default",
|
|
||||||
label: "Default (recommended)",
|
|
||||||
description:
|
|
||||||
"Use the default model (currently Opus 4.8 (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: "opus[1m]",
|
|
||||||
label: "Opus 4.8 (1M context)",
|
|
||||||
description:
|
|
||||||
"Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "haiku",
|
|
||||||
label: "Haiku",
|
|
||||||
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 (see [`public/modelConstants.js`](https://github.com/siteboon/claudecodeui/blob/main/public/modelConstants.js) for the full list of supported models)
|
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
|
||||||
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|||||||
384
server/browser-use-mcp.ts
Normal file
384
server/browser-use-mcp.ts
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import './load-env.js';
|
||||||
|
|
||||||
|
type JsonRpcRequest = {
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
id?: string | number | null;
|
||||||
|
method: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToolDefinition = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const textResponse = (text: string) => ({
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const jsonResponse = (value: unknown) => textResponse(JSON.stringify(value, null, 2));
|
||||||
|
|
||||||
|
const readString = (value: unknown, name: string): string => {
|
||||||
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
|
throw new Error(`${name} is required.`);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const readOptionalString = (value: unknown): string | undefined =>
|
||||||
|
typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
|
|
||||||
|
const readNumber = (value: unknown): number | undefined =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
|
|
||||||
|
const apiUrl = (process.env.CLOUDCLI_BROWSER_USE_API_URL || 'http://127.0.0.1:3001/api/browser-use-mcp').replace(/\/$/, '');
|
||||||
|
const apiToken = process.env.CLOUDCLI_BROWSER_USE_MCP_TOKEN || '';
|
||||||
|
const API_TIMEOUT_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_API_TIMEOUT_MS || '60000', 10);
|
||||||
|
|
||||||
|
async function callBrowserUseApi(toolName: string, input: Record<string, unknown>) {
|
||||||
|
if (!apiToken) {
|
||||||
|
throw new Error('CLOUDCLI_BROWSER_USE_MCP_TOKEN is not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${apiUrl}/tools/${encodeURIComponent(toolName)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
const data = await response.json() as { success?: boolean; data?: unknown; error?: string };
|
||||||
|
if (!response.ok || data.success === false) {
|
||||||
|
throw new Error(data.error || `Browser API request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionIdSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: 'string', description: 'Browser session id.' },
|
||||||
|
},
|
||||||
|
required: ['sessionId'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const tools: ToolDefinition[] = [
|
||||||
|
{
|
||||||
|
name: 'browser_create_session',
|
||||||
|
description: 'Create a temporary Browser session that the agent can control. Optionally provide a background profileName to reuse cookies and storage.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
profileName: { type: 'string', description: 'Optional background profile name for persistent browser storage.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'browser_list_sessions',
|
||||||
|
description: 'List Browser sessions currently available to agents.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'browser_snapshot',
|
||||||
|
description: 'Capture current page metadata, screenshot data URL, and visible body text for a Browser session.',
|
||||||
|
inputSchema: sessionIdSchema,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
description: 'Capture the latest screenshot for a Browser session.',
|
||||||
|
inputSchema: sessionIdSchema,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'browser_navigate',
|
||||||
|
description: 'Navigate a Browser session to an HTTP or HTTPS URL.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: 'string' },
|
||||||
|
url: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['sessionId', 'url'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'browser_click',
|
||||||
|
description: 'Click an element by CSS selector, visible text, or x/y coordinates.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: 'string' },
|
||||||
|
selector: { type: 'string' },
|
||||||
|
text: { type: 'string' },
|
||||||
|
x: { type: 'number' },
|
||||||
|
y: { type: 'number' },
|
||||||
|
},
|
||||||
|
required: ['sessionId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'browser_type',
|
||||||
|
description: 'Type text into the focused page or fill a CSS selector. Set submit to press Enter after typing.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: 'string' },
|
||||||
|
selector: { type: 'string' },
|
||||||
|
text: { type: 'string' },
|
||||||
|
submit: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
required: ['sessionId', 'text'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'browser_fill_form',
|
||||||
|
description: 'Fill multiple form fields using CSS selectors.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: 'string' },
|
||||||
|
fields: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
selector: { type: 'string' },
|
||||||
|
value: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['selector', 'value'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['sessionId', 'fields'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'browser_press_key',
|
||||||
|
description: 'Press a keyboard key, for example Enter, Escape, Tab, or Control+A.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: 'string' },
|
||||||
|
key: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['sessionId', 'key'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'browser_select_option',
|
||||||
|
description: 'Select option values in a select element found by CSS selector.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: 'string' },
|
||||||
|
selector: { type: 'string' },
|
||||||
|
values: { type: 'array', items: { type: 'string' } },
|
||||||
|
},
|
||||||
|
required: ['sessionId', 'selector', 'values'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'browser_wait_for',
|
||||||
|
description: 'Wait for visible text, a URL pattern, or a short timeout.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: 'string' },
|
||||||
|
text: { type: 'string' },
|
||||||
|
url: { type: 'string' },
|
||||||
|
timeoutMs: { type: 'number' },
|
||||||
|
},
|
||||||
|
required: ['sessionId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'browser_tabs',
|
||||||
|
description: 'List, open, select, or close tabs in a Browser session.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: 'string' },
|
||||||
|
action: { type: 'string', enum: ['list', 'new', 'select', 'close'] },
|
||||||
|
index: { type: 'number' },
|
||||||
|
url: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['sessionId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'browser_close_session',
|
||||||
|
description: 'Stop a Browser session controlled by agents.',
|
||||||
|
inputSchema: sessionIdSchema,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function callTool(name: string, args: Record<string, unknown>) {
|
||||||
|
switch (name) {
|
||||||
|
case 'browser_create_session':
|
||||||
|
return jsonResponse(await callBrowserUseApi(name, {
|
||||||
|
profileName: readOptionalString(args.profileName),
|
||||||
|
}));
|
||||||
|
case 'browser_list_sessions':
|
||||||
|
return jsonResponse(await callBrowserUseApi(name, {}));
|
||||||
|
case 'browser_snapshot':
|
||||||
|
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
|
||||||
|
case 'browser_take_screenshot': {
|
||||||
|
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
|
||||||
|
}
|
||||||
|
case 'browser_navigate':
|
||||||
|
return jsonResponse(await callBrowserUseApi(name, {
|
||||||
|
sessionId: readString(args.sessionId, 'sessionId'),
|
||||||
|
url: readString(args.url, 'url'),
|
||||||
|
}));
|
||||||
|
case 'browser_click':
|
||||||
|
return jsonResponse(await callBrowserUseApi(name, {
|
||||||
|
sessionId: readString(args.sessionId, 'sessionId'),
|
||||||
|
selector: readOptionalString(args.selector),
|
||||||
|
text: readOptionalString(args.text),
|
||||||
|
x: readNumber(args.x),
|
||||||
|
y: readNumber(args.y),
|
||||||
|
}));
|
||||||
|
case 'browser_type':
|
||||||
|
return jsonResponse(await callBrowserUseApi(name, {
|
||||||
|
sessionId: readString(args.sessionId, 'sessionId'),
|
||||||
|
selector: readOptionalString(args.selector),
|
||||||
|
text: readString(args.text, 'text'),
|
||||||
|
submit: args.submit === true,
|
||||||
|
}));
|
||||||
|
case 'browser_fill_form': {
|
||||||
|
const fields = Array.isArray(args.fields)
|
||||||
|
? args.fields.map((field) => {
|
||||||
|
const record = field as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
selector: readString(record.selector, 'field.selector'),
|
||||||
|
value: readString(record.value, 'field.value'),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
return jsonResponse(await callBrowserUseApi(name, {
|
||||||
|
sessionId: readString(args.sessionId, 'sessionId'),
|
||||||
|
fields,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
case 'browser_press_key':
|
||||||
|
return jsonResponse(await callBrowserUseApi(name, {
|
||||||
|
sessionId: readString(args.sessionId, 'sessionId'),
|
||||||
|
key: readString(args.key, 'key'),
|
||||||
|
}));
|
||||||
|
case 'browser_select_option':
|
||||||
|
return jsonResponse(await callBrowserUseApi(name, {
|
||||||
|
sessionId: readString(args.sessionId, 'sessionId'),
|
||||||
|
selector: readString(args.selector, 'selector'),
|
||||||
|
values: Array.isArray(args.values) ? args.values.filter((value): value is string => typeof value === 'string') : [],
|
||||||
|
}));
|
||||||
|
case 'browser_wait_for':
|
||||||
|
return jsonResponse(await callBrowserUseApi(name, {
|
||||||
|
sessionId: readString(args.sessionId, 'sessionId'),
|
||||||
|
text: readOptionalString(args.text),
|
||||||
|
url: readOptionalString(args.url),
|
||||||
|
timeoutMs: readNumber(args.timeoutMs),
|
||||||
|
}));
|
||||||
|
case 'browser_tabs':
|
||||||
|
return jsonResponse(await callBrowserUseApi(name, {
|
||||||
|
sessionId: readString(args.sessionId, 'sessionId'),
|
||||||
|
action: args.action === 'new' || args.action === 'select' || args.action === 'close' || args.action === 'list'
|
||||||
|
? args.action
|
||||||
|
: undefined,
|
||||||
|
index: readNumber(args.index),
|
||||||
|
url: readOptionalString(args.url),
|
||||||
|
}));
|
||||||
|
case 'browser_close_session':
|
||||||
|
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown tool: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMessage(message: JsonRpcRequest) {
|
||||||
|
if (message.method === 'initialize') {
|
||||||
|
return {
|
||||||
|
protocolVersion: '2024-11-05',
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
serverInfo: { name: 'cloudcli-browser', version: '1.0.0' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.method === 'tools/list') {
|
||||||
|
return { tools };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.method === 'tools/call') {
|
||||||
|
const params = message.params || {};
|
||||||
|
const name = readString(params.name, 'name');
|
||||||
|
const args = (params.arguments && typeof params.arguments === 'object'
|
||||||
|
? params.arguments
|
||||||
|
: {}) as Record<string, unknown>;
|
||||||
|
return callTool(name, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.method.startsWith('notifications/')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported method: ${message.method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMessage(message: Record<string, unknown>) {
|
||||||
|
// MCP stdio transport uses newline-delimited JSON (one JSON-RPC message per line,
|
||||||
|
// no embedded newlines). This is NOT the LSP Content-Length framing.
|
||||||
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendResult(id: string | number | null | undefined, result: unknown) {
|
||||||
|
if (id === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeMessage({ jsonrpc: '2.0', id, result });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendError(id: string | number | null | undefined, error: unknown) {
|
||||||
|
if (id === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeMessage({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
error: {
|
||||||
|
code: -32000,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
process.stdin.on('data', (chunk) => {
|
||||||
|
buffer += chunk.toString('utf8');
|
||||||
|
let newlineIndex: number;
|
||||||
|
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
||||||
|
const rawMessage = buffer.slice(0, newlineIndex).trim();
|
||||||
|
buffer = buffer.slice(newlineIndex + 1);
|
||||||
|
if (!rawMessage) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
let request: JsonRpcRequest;
|
||||||
|
try {
|
||||||
|
request = JSON.parse(rawMessage) as JsonRpcRequest;
|
||||||
|
} catch (error) {
|
||||||
|
sendError(null, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await handleMessage(request);
|
||||||
|
sendResult(request.id, result);
|
||||||
|
} catch (error) {
|
||||||
|
sendError(request.id, error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -28,10 +28,14 @@ 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 { createNormalizedMessage } from './shared/utils.js';
|
import { createCompleteMessage, 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;
|
||||||
|
|
||||||
@@ -204,7 +208,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]
|
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
|
||||||
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
|
||||||
|
|
||||||
@@ -731,14 +735,18 @@ 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 completion event
|
// Send the terminal completion event — skipped for aborted runs, whose
|
||||||
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
|
// terminal `complete` (aborted: true) was already sent by abort-session.
|
||||||
|
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
|
||||||
|
if (!wasAborted) {
|
||||||
|
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
|
||||||
|
}
|
||||||
notifyRunStopped({
|
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: 'completed'
|
stopReason: wasAborted ? 'aborted' : 'completed'
|
||||||
});
|
});
|
||||||
// Complete
|
// Complete
|
||||||
|
|
||||||
@@ -753,14 +761,22 @@ 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
|
// Send error to WebSocket, then the terminal complete
|
||||||
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',
|
||||||
@@ -787,6 +803,10 @@ 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();
|
||||||
|
|
||||||
@@ -802,6 +822,8 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* (no args) - Start the server (default)
|
* (no args) - Start the server (default)
|
||||||
* start - Start the server
|
* start - Start the server
|
||||||
* sandbox - Manage Docker sandbox environments
|
* sandbox - Manage Docker sandbox environments
|
||||||
|
* browser-use-mcp - Run Browser MCP stdio server
|
||||||
* status - Show configuration and data locations
|
* status - Show configuration and data locations
|
||||||
* help - Show help information
|
* help - Show help information
|
||||||
* version - Show version information
|
* version - Show version information
|
||||||
@@ -154,12 +155,13 @@ Usage:
|
|||||||
cloudcli [command] [options]
|
cloudcli [command] [options]
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
start Start the CloudCLI server (default)
|
start Start the CloudCLI server (default)
|
||||||
sandbox Manage Docker sandbox environments
|
sandbox Manage Docker sandbox environments
|
||||||
status Show configuration and data locations
|
browser-use-mcp Run the Browser MCP stdio server
|
||||||
update Update to the latest version
|
status Show configuration and data locations
|
||||||
help Show this help information
|
update Update to the latest version
|
||||||
version Show version information
|
help Show this help information
|
||||||
|
version Show version information
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-p, --port <port> Set server port (default: 3001)
|
-p, --port <port> Set server port (default: 3001)
|
||||||
@@ -605,6 +607,10 @@ async function startServer() {
|
|||||||
await import('./index.js');
|
await import('./index.js');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startBrowserUseMcp() {
|
||||||
|
await import('./browser-use-mcp.js');
|
||||||
|
}
|
||||||
|
|
||||||
// Parse CLI arguments
|
// Parse CLI arguments
|
||||||
function parseArgs(args) {
|
function parseArgs(args) {
|
||||||
const parsed = { command: 'start', options: {} };
|
const parsed = { command: 'start', options: {} };
|
||||||
@@ -658,6 +664,9 @@ async function main() {
|
|||||||
case 'sandbox':
|
case 'sandbox':
|
||||||
await sandboxCommand(remainingArgs || []);
|
await sandboxCommand(remainingArgs || []);
|
||||||
break;
|
break;
|
||||||
|
case 'browser-use-mcp':
|
||||||
|
await startBrowserUseMcp();
|
||||||
|
break;
|
||||||
case 'status':
|
case 'status':
|
||||||
case 'info':
|
case 'info':
|
||||||
showStatus();
|
showStatus();
|
||||||
|
|||||||
@@ -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 { createNormalizedMessage } from './shared/utils.js';
|
import { createCompleteMessage, 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,6 +34,10 @@ 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 || {
|
||||||
@@ -197,15 +201,15 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'result': {
|
case 'result': {
|
||||||
// Session complete — send stream end + lifecycle complete with result payload
|
// Session complete — terminal lifecycle event for this run
|
||||||
const resultText = typeof response.result === 'string' ? response.result : '';
|
if (!completeSent) {
|
||||||
ws.send(createNormalizedMessage({
|
completeSent = true;
|
||||||
kind: 'complete',
|
ws.send(createCompleteMessage({
|
||||||
exitCode: response.subtype === 'success' ? 0 : 1,
|
provider: 'cursor',
|
||||||
resultText,
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
isError: response.subtype !== 'success',
|
exitCode: response.subtype === 'success' ? 0 : 1,
|
||||||
sessionId: capturedSessionId || sessionId, provider: 'cursor',
|
}));
|
||||||
}));
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +275,12 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' }));
|
// Terminal complete — unless the `result` line already sent it, or the
|
||||||
|
// run was aborted (abort-session sent the aborted complete).
|
||||||
|
if (!completeSent && !cursorProcess.aborted) {
|
||||||
|
completeSent = true;
|
||||||
|
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: finalSessionId, exitCode: code }));
|
||||||
|
}
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
notifyTerminalState({ code });
|
notifyTerminalState({ code });
|
||||||
@@ -297,6 +306,10 @@ 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));
|
||||||
@@ -314,6 +327,9 @@ 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 { createNormalizedMessage } from './shared/utils.js';
|
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
// 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,6 +129,9 @@ 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 || {
|
||||||
@@ -486,7 +489,12 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
|
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' }));
|
// Terminal complete — skipped for aborted runs (abort-session
|
||||||
|
// already sent the aborted complete on this run's behalf).
|
||||||
|
if (!completeSent && !geminiProcess.aborted) {
|
||||||
|
completeSent = true;
|
||||||
|
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: finalSessionId, exitCode: code }));
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up temporary image files if any
|
// Clean up temporary image files if any
|
||||||
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
|
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
|
||||||
@@ -566,6 +574,10 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
|
|
||||||
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
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);
|
||||||
@@ -590,6 +602,9 @@ 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)) {
|
||||||
|
|||||||
118
server/index.js
118
server/index.js
@@ -22,35 +22,24 @@ import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
|
|||||||
import {
|
import {
|
||||||
queryClaudeSDK,
|
queryClaudeSDK,
|
||||||
abortClaudeSDKSession,
|
abortClaudeSDKSession,
|
||||||
isClaudeSDKSessionActive,
|
|
||||||
getActiveClaudeSDKSessions,
|
|
||||||
resolveToolApproval,
|
resolveToolApproval,
|
||||||
getPendingApprovalsForSession,
|
getPendingApprovalsForSession,
|
||||||
reconnectSessionWriter,
|
|
||||||
} from './claude-sdk.js';
|
} from './claude-sdk.js';
|
||||||
import {
|
import {
|
||||||
spawnCursor,
|
spawnCursor,
|
||||||
abortCursorSession,
|
abortCursorSession,
|
||||||
isCursorSessionActive,
|
|
||||||
getActiveCursorSessions,
|
|
||||||
} from './cursor-cli.js';
|
} from './cursor-cli.js';
|
||||||
import {
|
import {
|
||||||
queryCodex,
|
queryCodex,
|
||||||
abortCodexSession,
|
abortCodexSession,
|
||||||
isCodexSessionActive,
|
|
||||||
getActiveCodexSessions,
|
|
||||||
} from './openai-codex.js';
|
} from './openai-codex.js';
|
||||||
import {
|
import {
|
||||||
spawnGemini,
|
spawnGemini,
|
||||||
abortGeminiSession,
|
abortGeminiSession,
|
||||||
isGeminiSessionActive,
|
|
||||||
getActiveGeminiSessions,
|
|
||||||
} from './gemini-cli.js';
|
} from './gemini-cli.js';
|
||||||
import {
|
import {
|
||||||
spawnOpenCode,
|
spawnOpenCode,
|
||||||
abortOpenCodeSession,
|
abortOpenCodeSession,
|
||||||
isOpenCodeSessionActive,
|
|
||||||
getActiveOpenCodeSessions,
|
|
||||||
} from './opencode-cli.js';
|
} from './opencode-cli.js';
|
||||||
import sessionManager from './sessionManager.js';
|
import sessionManager from './sessionManager.js';
|
||||||
import {
|
import {
|
||||||
@@ -72,6 +61,9 @@ import userRoutes from './routes/user.js';
|
|||||||
import geminiRoutes from './routes/gemini.js';
|
import geminiRoutes from './routes/gemini.js';
|
||||||
import pluginsRoutes from './routes/plugins.js';
|
import pluginsRoutes from './routes/plugins.js';
|
||||||
import providerRoutes from './modules/providers/provider.routes.js';
|
import providerRoutes from './modules/providers/provider.routes.js';
|
||||||
|
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
|
||||||
|
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
|
||||||
|
import { browserUseService } from './modules/browser-use/browser-use.service.js';
|
||||||
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
||||||
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
|
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
|
||||||
import { configureWebPush } from './services/vapid-keys.js';
|
import { configureWebPush } from './services/vapid-keys.js';
|
||||||
@@ -105,32 +97,35 @@ const wss = createWebSocketServer(server, {
|
|||||||
authenticateWebSocket,
|
authenticateWebSocket,
|
||||||
},
|
},
|
||||||
chat: {
|
chat: {
|
||||||
queryClaudeSDK,
|
spawnFns: {
|
||||||
spawnCursor,
|
claude: queryClaudeSDK,
|
||||||
queryCodex,
|
cursor: spawnCursor,
|
||||||
spawnGemini,
|
codex: queryCodex,
|
||||||
spawnOpenCode,
|
gemini: spawnGemini,
|
||||||
abortClaudeSDKSession,
|
opencode: spawnOpenCode,
|
||||||
abortCursorSession,
|
},
|
||||||
abortCodexSession,
|
abortFns: {
|
||||||
abortGeminiSession,
|
claude: abortClaudeSDKSession,
|
||||||
abortOpenCodeSession,
|
cursor: abortCursorSession,
|
||||||
|
codex: abortCodexSession,
|
||||||
|
gemini: abortGeminiSession,
|
||||||
|
opencode: abortOpenCodeSession,
|
||||||
|
},
|
||||||
resolveToolApproval,
|
resolveToolApproval,
|
||||||
isClaudeSDKSessionActive,
|
|
||||||
isCursorSessionActive,
|
|
||||||
isCodexSessionActive,
|
|
||||||
isGeminiSessionActive,
|
|
||||||
isOpenCodeSessionActive,
|
|
||||||
reconnectSessionWriter,
|
|
||||||
getPendingApprovalsForSession,
|
getPendingApprovalsForSession,
|
||||||
getActiveClaudeSDKSessions,
|
|
||||||
getActiveCursorSessions,
|
|
||||||
getActiveCodexSessions,
|
|
||||||
getActiveGeminiSessions,
|
|
||||||
getActiveOpenCodeSessions,
|
|
||||||
},
|
},
|
||||||
shell: {
|
shell: {
|
||||||
getSessionById: (sessionId) => sessionManager.getSession(sessionId),
|
resolveProviderSessionId: (sessionId, provider) => {
|
||||||
|
const dbSession = sessionsDb.getSessionById(sessionId);
|
||||||
|
const legacyGeminiSession =
|
||||||
|
provider === 'gemini' ? sessionManager.getSession(sessionId) : null;
|
||||||
|
|
||||||
|
if (dbSession) {
|
||||||
|
return dbSession.provider_session_id ?? legacyGeminiSession?.cliSessionId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return legacyGeminiSession?.cliSessionId;
|
||||||
|
},
|
||||||
stripAnsiSequences,
|
stripAnsiSequences,
|
||||||
normalizeDetectedUrl,
|
normalizeDetectedUrl,
|
||||||
extractUrlsFromText,
|
extractUrlsFromText,
|
||||||
@@ -201,6 +196,12 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
|
|||||||
// Plugins API Routes (protected)
|
// Plugins API Routes (protected)
|
||||||
app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
||||||
|
|
||||||
|
// Browser MCP bridge API (local token protected)
|
||||||
|
app.use('/api/browser-use-mcp', browserUseMcpRoutes);
|
||||||
|
|
||||||
|
// Browser API Routes (protected)
|
||||||
|
app.use('/api/browser-use', authenticateToken, browserUseRoutes);
|
||||||
|
|
||||||
// Unified provider MCP routes (protected)
|
// Unified provider MCP routes (protected)
|
||||||
app.use('/api/providers', authenticateToken, providerRoutes);
|
app.use('/api/providers', authenticateToken, providerRoutes);
|
||||||
|
|
||||||
@@ -1143,7 +1144,6 @@ app.post('/api/projects/:projectId/upload-images', authenticateToken, async (req
|
|||||||
app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { projectId, sessionId } = req.params;
|
const { projectId, sessionId } = req.params;
|
||||||
const { provider = 'claude' } = req.query;
|
|
||||||
const homeDir = os.homedir();
|
const homeDir = os.homedir();
|
||||||
|
|
||||||
// Allow only safe characters in sessionId
|
// Allow only safe characters in sessionId
|
||||||
@@ -1152,6 +1152,18 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provider artifacts on disk (JSONL file names, OpenCode sqlite rows)
|
||||||
|
// are keyed by the provider-native session id, while the caller sends
|
||||||
|
// the app-facing id. Resolve provider and id mapping from the indexed
|
||||||
|
// session row so the frontend does not choose provider-specific paths.
|
||||||
|
const sessionRow = sessionsDb.getSessionById(safeSessionId);
|
||||||
|
if (!sessionRow) {
|
||||||
|
return res.status(404).json({ error: 'Session not found', sessionId: safeSessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = sessionRow.provider || 'claude';
|
||||||
|
const providerNativeSessionId = sessionRow?.provider_session_id || safeSessionId;
|
||||||
|
|
||||||
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
||||||
if (provider === 'cursor') {
|
if (provider === 'cursor') {
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -1252,7 +1264,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
tokens_cache_write AS cacheWriteTokens
|
tokens_cache_write AS cacheWriteTokens
|
||||||
FROM session
|
FROM session
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).get(safeSessionId);
|
`).get(providerNativeSessionId);
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return res.status(404).json({ error: 'OpenCode session not found', sessionId: safeSessionId });
|
return res.status(404).json({ error: 'OpenCode session not found', sessionId: safeSessionId });
|
||||||
@@ -1293,7 +1305,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
const found = await findSessionFile(fullPath);
|
const found = await findSessionFile(fullPath);
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
} else if (entry.name.includes(providerNativeSessionId) && entry.name.endsWith('.jsonl')) {
|
||||||
return fullPath;
|
return fullPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1377,12 +1389,19 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
|
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||||
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
||||||
|
|
||||||
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
// Prefer the indexed transcript path (already produced by the trusted
|
||||||
|
// session synchronizer); fall back to the conventional location
|
||||||
|
// derived from the provider-native session id.
|
||||||
|
let jsonlPath = sessionRow?.jsonl_path;
|
||||||
|
if (!jsonlPath) {
|
||||||
|
jsonlPath = path.join(projectDir, `${providerNativeSessionId}.jsonl`);
|
||||||
|
|
||||||
// Constrain to projectDir
|
// Constrain the constructed path to projectDir (the id is
|
||||||
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
// caller-influenced in this fallback branch).
|
||||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
||||||
return res.status(400).json({ error: 'Invalid path' });
|
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid path' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and parse the JSONL file
|
// Read and parse the JSONL file
|
||||||
@@ -1694,12 +1713,21 @@ async function startServer() {
|
|||||||
|
|
||||||
await closeSessionsWatcher();
|
await closeSessionsWatcher();
|
||||||
// Clean up plugin processes on shutdown
|
// Clean up plugin processes on shutdown
|
||||||
const shutdownPlugins = async () => {
|
const shutdownRuntimeServices = async () => {
|
||||||
await stopAllPlugins();
|
try {
|
||||||
|
await browserUseService.stopAllSessions();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Browser] Error stopping sessions during shutdown:', err?.message || err);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await stopAllPlugins();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Plugins] Error stopping plugins during shutdown:', err?.message || err);
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
process.on('SIGTERM', () => void shutdownPlugins());
|
process.on('SIGTERM', () => void shutdownRuntimeServices());
|
||||||
process.on('SIGINT', () => void shutdownPlugins());
|
process.on('SIGINT', () => void shutdownRuntimeServices());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ERROR] Failed to start server:', error);
|
console.error('[ERROR] Failed to start server:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
120
server/modules/browser-use/browser-use-mcp.routes.ts
Normal file
120
server/modules/browser-use/browser-use-mcp.routes.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function readBearerToken(header: unknown): string | null {
|
||||||
|
if (typeof header !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = /^Bearer\s+(\S.*)$/i.exec(header.trim());
|
||||||
|
return match?.[1]?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
const expected = browserUseService.getMcpToken();
|
||||||
|
const token = readBearerToken(req.headers.authorization) || String(req.headers['x-browser-use-mcp-token'] || '');
|
||||||
|
if (!token || token !== expected) {
|
||||||
|
res.status(401).json({ success: false, error: 'Invalid Browser MCP token.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/tools/:toolName', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const input = (req.body && typeof req.body === 'object' ? req.body : {}) as Record<string, unknown>;
|
||||||
|
const sessionId = typeof input.sessionId === 'string' ? input.sessionId : '';
|
||||||
|
const toolName = req.params.toolName;
|
||||||
|
let result: unknown;
|
||||||
|
|
||||||
|
switch (toolName) {
|
||||||
|
case 'browser_create_session':
|
||||||
|
result = await browserUseService.createAgentSession({
|
||||||
|
profileName: typeof input.profileName === 'string' ? input.profileName : null,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'browser_list_sessions':
|
||||||
|
result = await browserUseService.listAgentSessions();
|
||||||
|
break;
|
||||||
|
case 'browser_snapshot':
|
||||||
|
case 'browser_take_screenshot':
|
||||||
|
result = await browserUseService.agentSnapshot(sessionId);
|
||||||
|
break;
|
||||||
|
case 'browser_navigate':
|
||||||
|
result = await browserUseService.agentNavigate(sessionId, String(input.url || ''));
|
||||||
|
break;
|
||||||
|
case 'browser_click':
|
||||||
|
result = await browserUseService.agentClick(sessionId, {
|
||||||
|
selector: typeof input.selector === 'string' ? input.selector : undefined,
|
||||||
|
text: typeof input.text === 'string' ? input.text : undefined,
|
||||||
|
x: typeof input.x === 'number' ? input.x : undefined,
|
||||||
|
y: typeof input.y === 'number' ? input.y : undefined,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'browser_type':
|
||||||
|
result = await browserUseService.agentType(sessionId, {
|
||||||
|
selector: typeof input.selector === 'string' ? input.selector : undefined,
|
||||||
|
text: String(input.text || ''),
|
||||||
|
submit: input.submit === true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'browser_fill_form':
|
||||||
|
result = await browserUseService.agentFillForm(
|
||||||
|
sessionId,
|
||||||
|
Array.isArray(input.fields)
|
||||||
|
? input.fields.map((field) => {
|
||||||
|
const record = field as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
selector: String(record.selector || ''),
|
||||||
|
value: String(record.value || ''),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'browser_press_key':
|
||||||
|
result = await browserUseService.agentPressKey(sessionId, String(input.key || ''));
|
||||||
|
break;
|
||||||
|
case 'browser_select_option':
|
||||||
|
result = await browserUseService.agentSelectOption(
|
||||||
|
sessionId,
|
||||||
|
String(input.selector || ''),
|
||||||
|
Array.isArray(input.values) ? input.values.filter((value): value is string => typeof value === 'string') : [],
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'browser_wait_for':
|
||||||
|
result = await browserUseService.agentWaitFor(sessionId, {
|
||||||
|
text: typeof input.text === 'string' ? input.text : undefined,
|
||||||
|
url: typeof input.url === 'string' ? input.url : undefined,
|
||||||
|
timeoutMs: typeof input.timeoutMs === 'number' ? input.timeoutMs : undefined,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'browser_tabs':
|
||||||
|
result = await browserUseService.agentTabs(sessionId, {
|
||||||
|
action: input.action === 'new' || input.action === 'select' || input.action === 'close' || input.action === 'list'
|
||||||
|
? input.action
|
||||||
|
: undefined,
|
||||||
|
index: typeof input.index === 'number' ? input.index : undefined,
|
||||||
|
url: typeof input.url === 'string' ? input.url : undefined,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'browser_close_session':
|
||||||
|
result = await browserUseService.agentStopSession(sessionId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
res.status(404).json({ success: false, error: `Unknown Browser MCP tool "${toolName}".` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Browser MCP tool failed.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
96
server/modules/browser-use/browser-use.routes.ts
Normal file
96
server/modules/browser-use/browser-use.routes.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function readParam(value: string | string[] | undefined): string {
|
||||||
|
return Array.isArray(value) ? value[0] || '' : value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/status', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, data: await browserUseService.getStatus() });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to load Browser status.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/settings', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, data: { settings: await browserUseService.getSettings() } });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to load Browser settings.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/settings', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = await browserUseService.updateSettings(req.body || {});
|
||||||
|
res.json({ success: true, data: { settings } });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to save Browser settings.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/runtime/install', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await browserUseService.installRuntime();
|
||||||
|
res.status(result.success ? 200 : 500).json({
|
||||||
|
success: result.success,
|
||||||
|
data: result,
|
||||||
|
error: result.success ? undefined : result.message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to install Browser runtime.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/sessions', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, data: { sessions: await browserUseService.listSessions() } });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to list browser sessions.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/sessions/:sessionId/stop', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await browserUseService.stopSession(readParam(req.params.sessionId));
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to stop browser session.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/sessions/:sessionId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await browserUseService.deleteSession(readParam(req.params.sessionId));
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete browser session.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
836
server/modules/browser-use/browser-use.service.ts
Normal file
836
server/modules/browser-use/browser-use.service.ts
Normal file
@@ -0,0 +1,836 @@
|
|||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { appConfigDb } from '@/modules/database/index.js';
|
||||||
|
import { providerMcpService } from '@/modules/providers/index.js';
|
||||||
|
import { getModuleDir } from '@/utils/runtime-paths.js';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const __dirname = getModuleDir(import.meta.url);
|
||||||
|
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
|
||||||
|
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
|
||||||
|
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
|
||||||
|
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
|
||||||
|
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
|
||||||
|
|
||||||
|
type BrowserUseRuntime = 'cloud' | 'local';
|
||||||
|
type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
|
||||||
|
|
||||||
|
type BrowserUseSession = {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
createdBy: 'agent';
|
||||||
|
runtime: BrowserUseRuntime;
|
||||||
|
status: BrowserUseSessionStatus;
|
||||||
|
url: string | null;
|
||||||
|
title: string | null;
|
||||||
|
screenshotDataUrl: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lastAction: string | null;
|
||||||
|
message: string | null;
|
||||||
|
profileName: string | null;
|
||||||
|
viewport: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null;
|
||||||
|
cursor: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
actor: 'agent';
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
|
||||||
|
|
||||||
|
type RuntimeHandle = {
|
||||||
|
browser?: any;
|
||||||
|
context?: any;
|
||||||
|
page?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowserUseSettings = {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuntimeReadiness = {
|
||||||
|
playwright: any | null;
|
||||||
|
playwrightInstalled: boolean;
|
||||||
|
chromiumInstalled: boolean;
|
||||||
|
chromiumExecutablePath: string | null;
|
||||||
|
installInProgress: boolean;
|
||||||
|
installMessage: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;
|
||||||
|
|
||||||
|
const sessions = new Map<string, BrowserUseSession>();
|
||||||
|
const handles = new Map<string, RuntimeHandle>();
|
||||||
|
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
|
||||||
|
let lastInstallMessage: string | null = null;
|
||||||
|
let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null;
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: BrowserUseSettings = {
|
||||||
|
enabled: false,
|
||||||
|
};
|
||||||
|
const AGENT_OWNER_ID = 'agent';
|
||||||
|
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
|
||||||
|
const MCP_SERVER_NAME = 'cloudcli-browser';
|
||||||
|
const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
|
||||||
|
const RUNTIME_READINESS_CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
function getRuntime(): BrowserUseRuntime {
|
||||||
|
return IS_PLATFORM ? 'cloud' : 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSettings(): BrowserUseSettings {
|
||||||
|
try {
|
||||||
|
const raw = appConfigDb.get(BROWSER_USE_SETTINGS_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
|
||||||
|
return {
|
||||||
|
enabled: parsed.enabled === true,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn('[Browser] Failed to read settings:', error?.message || error);
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
|
||||||
|
const normalized = {
|
||||||
|
enabled: settings.enabled === true,
|
||||||
|
};
|
||||||
|
|
||||||
|
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateMcpToken(): string {
|
||||||
|
const existing = appConfigDb.get(BROWSER_USE_MCP_TOKEN_KEY);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const token = randomBytes(32).toString('hex');
|
||||||
|
appConfigDb.set(BROWSER_USE_MCP_TOKEN_KEY, token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
|
||||||
|
if (!settings.enabled) {
|
||||||
|
return 'Browser is disabled in settings.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!readiness.playwrightInstalled) {
|
||||||
|
return 'Install Playwright and Chromium to use browser sessions.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!readiness.chromiumInstalled) {
|
||||||
|
return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return readiness.installMessage || 'Browser runtime is not ready.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlaywright(): any | null {
|
||||||
|
try {
|
||||||
|
return require('playwright');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMcpCommand(): { command: string; args: string[] } {
|
||||||
|
const serverDir = path.resolve(__dirname, '..', '..');
|
||||||
|
const mcpScriptPath = path.join(serverDir, 'browser-use-mcp.js');
|
||||||
|
if (fs.existsSync(mcpScriptPath)) {
|
||||||
|
return {
|
||||||
|
command: process.execPath,
|
||||||
|
args: [mcpScriptPath],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: 'cloudcli',
|
||||||
|
args: ['browser-use-mcp'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMcpApiUrl(): string {
|
||||||
|
const port = process.env.SERVER_PORT || process.env.PORT || '3001';
|
||||||
|
return `http://127.0.0.1:${port}/api/browser-use-mcp`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMcpServerFromAllProviders(name: string) {
|
||||||
|
const results = await providerMcpService.removeMcpServerFromAllProviders({
|
||||||
|
name,
|
||||||
|
scope: 'user',
|
||||||
|
});
|
||||||
|
return results.map((result) => ({ ...result, name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProfileName(profileName?: string | null): string | null {
|
||||||
|
const normalized = String(profileName || '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProfilePath(profileName: string): string {
|
||||||
|
const safeName = profileName
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 80) || 'default';
|
||||||
|
return path.join(PROFILE_ROOT, safeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeRuntime(): RuntimeProbe {
|
||||||
|
const playwright = getPlaywright();
|
||||||
|
const readiness: RuntimeProbe = {
|
||||||
|
playwright,
|
||||||
|
playwrightInstalled: Boolean(playwright),
|
||||||
|
chromiumInstalled: false,
|
||||||
|
chromiumExecutablePath: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!playwright) {
|
||||||
|
return readiness;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const executablePath = playwright.chromium.executablePath();
|
||||||
|
readiness.chromiumExecutablePath = executablePath;
|
||||||
|
readiness.chromiumInstalled = Boolean(executablePath && fs.existsSync(executablePath));
|
||||||
|
} catch {
|
||||||
|
readiness.chromiumInstalled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return readiness;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuntimeReadiness(options: { force?: boolean } = {}): RuntimeReadiness {
|
||||||
|
const now = Date.now();
|
||||||
|
const cachedProbe = runtimeProbeCache;
|
||||||
|
const canUseCache = !options.force
|
||||||
|
&& !installPromise
|
||||||
|
&& cachedProbe
|
||||||
|
&& now - cachedProbe.updatedAt < RUNTIME_READINESS_CACHE_TTL_MS;
|
||||||
|
const probe = canUseCache ? cachedProbe.value : probeRuntime();
|
||||||
|
|
||||||
|
if (!canUseCache && !installPromise) {
|
||||||
|
runtimeProbeCache = { value: probe, updatedAt: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...probe,
|
||||||
|
installInProgress: Boolean(installPromise),
|
||||||
|
installMessage: lastInstallMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
|
||||||
|
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
function runCommand(command: string, args: string[]): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: process.env,
|
||||||
|
shell: false,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
const output: string[] = [];
|
||||||
|
let settled = false;
|
||||||
|
const finish = (fn: () => void) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
finish(() => reject(new Error(
|
||||||
|
`${command} ${args.join(' ')} timed out after ${INSTALL_COMMAND_TIMEOUT_MS}ms.`,
|
||||||
|
)));
|
||||||
|
}, INSTALL_COMMAND_TIMEOUT_MS);
|
||||||
|
timer.unref?.();
|
||||||
|
|
||||||
|
child.stdout.on('data', (chunk) => output.push(String(chunk)));
|
||||||
|
child.stderr.on('data', (chunk) => output.push(String(chunk)));
|
||||||
|
child.on('error', (error) => finish(() => reject(error)));
|
||||||
|
child.on('close', (code) => finish(() => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error(output.join('').trim() || `${command} ${args.join(' ')} exited with code ${code}`));
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInstallError(error: unknown): string {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message.includes('sudo') && message.includes('password')) {
|
||||||
|
return 'Installing Chromium system dependencies requires administrator privileges. Run `npx playwright install-deps chromium` on the machine where CloudCLI runs, then try again.';
|
||||||
|
}
|
||||||
|
return message || 'Failed to install Browser runtime.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installRuntime(): Promise<{ success: boolean; message: string }> {
|
||||||
|
if (installPromise) {
|
||||||
|
return installPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||||
|
runtimeProbeCache = null;
|
||||||
|
installPromise = (async () => {
|
||||||
|
try {
|
||||||
|
lastInstallMessage = 'Installing Playwright package...';
|
||||||
|
await runCommand(npmCommand, ['install', '--no-save', '--no-package-lock', 'playwright']);
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
lastInstallMessage = 'Installing Chromium system dependencies...';
|
||||||
|
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install-deps', 'chromium']);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastInstallMessage = 'Installing Chromium runtime...';
|
||||||
|
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install', 'chromium']);
|
||||||
|
|
||||||
|
lastInstallMessage = 'Browser runtime installed.';
|
||||||
|
return { success: true, message: lastInstallMessage };
|
||||||
|
} catch (error) {
|
||||||
|
lastInstallMessage = formatInstallError(error);
|
||||||
|
return { success: false, message: lastInstallMessage };
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await installPromise;
|
||||||
|
} finally {
|
||||||
|
installPromise = null;
|
||||||
|
runtimeProbeCache = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(rawUrl: string): string {
|
||||||
|
const trimmed = rawUrl.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('URL is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const withProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed)
|
||||||
|
? trimmed
|
||||||
|
: `https://${trimmed}`;
|
||||||
|
const parsed = new URL(withProtocol);
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
throw new Error('Only http and https URLs are supported.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicSession(session: BrowserUseSession): PublicBrowserUseSession {
|
||||||
|
const { ownerId: _ownerId, ...publicFields } = session;
|
||||||
|
return publicFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerSessions(ownerId: string): BrowserUseSession[] {
|
||||||
|
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeHandle(sessionId: string): Promise<void> {
|
||||||
|
const handle = handles.get(sessionId);
|
||||||
|
handles.delete(sessionId);
|
||||||
|
await handle?.context?.close?.().catch(() => undefined);
|
||||||
|
await handle?.browser?.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expireStaleSessions(now = Date.now()): Promise<void> {
|
||||||
|
await Promise.all([...sessions.values()].map(async (session) => {
|
||||||
|
if (session.status !== 'ready') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = Date.parse(session.updatedAt);
|
||||||
|
if (!Number.isFinite(updatedAt) || now - updatedAt <= SESSION_TTL_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await closeHandle(session.id);
|
||||||
|
session.status = 'stopped';
|
||||||
|
session.updatedAt = new Date(now).toISOString();
|
||||||
|
session.lastAction = 'expire';
|
||||||
|
session.message = 'Browser session expired after inactivity.';
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureSession(session: BrowserUseSession, page: any): Promise<void> {
|
||||||
|
const screenshot = await page.screenshot({ type: 'jpeg', quality: 72, fullPage: false });
|
||||||
|
session.screenshotDataUrl = `data:image/jpeg;base64,${Buffer.from(screenshot).toString('base64')}`;
|
||||||
|
session.title = await page.title().catch(() => null);
|
||||||
|
session.url = page.url() || session.url;
|
||||||
|
session.viewport = page.viewportSize?.() || session.viewport;
|
||||||
|
session.updatedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActionPoint(page: any, input: { selector?: string; text?: string; x?: number; y?: number }) {
|
||||||
|
if (typeof input.x === 'number' && typeof input.y === 'number') {
|
||||||
|
return { x: input.x, y: input.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
const locator = input.selector
|
||||||
|
? page.locator(input.selector).first()
|
||||||
|
: input.text
|
||||||
|
? page.getByText(input.text, { exact: false }).first()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!locator) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const box = await locator.boundingBox().catch(() => null);
|
||||||
|
if (!box) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.round(box.x + box.width / 2),
|
||||||
|
y: Math.round(box.y + box.height / 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const browserUseService = {
|
||||||
|
async getSettings() {
|
||||||
|
return readSettings();
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSettings(settings: Partial<BrowserUseSettings>) {
|
||||||
|
const current = readSettings();
|
||||||
|
const nextSettings = {
|
||||||
|
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = writeSettings(nextSettings);
|
||||||
|
if (next.enabled) {
|
||||||
|
await this.registerAgentMcp();
|
||||||
|
} else if (current.enabled) {
|
||||||
|
await this.unregisterAgentMcp();
|
||||||
|
await this.stopAllSessions();
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getStatus() {
|
||||||
|
const settings = readSettings();
|
||||||
|
const readiness = getRuntimeReadiness();
|
||||||
|
const available = settings.enabled && readiness.playwrightInstalled && readiness.chromiumInstalled;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: settings.enabled,
|
||||||
|
runtime: getRuntime(),
|
||||||
|
available,
|
||||||
|
playwrightInstalled: readiness.playwrightInstalled,
|
||||||
|
chromiumInstalled: readiness.chromiumInstalled,
|
||||||
|
installInProgress: readiness.installInProgress,
|
||||||
|
sessionCount: sessions.size,
|
||||||
|
message: available
|
||||||
|
? 'Browser runtime is available.'
|
||||||
|
: getSetupMessage(settings, readiness),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async registerAgentMcp() {
|
||||||
|
const { command, args } = getMcpCommand();
|
||||||
|
await Promise.all(LEGACY_MCP_SERVER_NAMES.map((name) => removeMcpServerFromAllProviders(name)));
|
||||||
|
const results = await providerMcpService.addMcpServerToAllProviders({
|
||||||
|
name: MCP_SERVER_NAME,
|
||||||
|
scope: 'user',
|
||||||
|
transport: 'stdio',
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
env: {
|
||||||
|
CLOUDCLI_BROWSER_USE_MCP_TOKEN: getOrCreateMcpToken(),
|
||||||
|
CLOUDCLI_BROWSER_USE_API_URL: getMcpApiUrl(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { name: MCP_SERVER_NAME, command, args, results };
|
||||||
|
},
|
||||||
|
|
||||||
|
getMcpToken() {
|
||||||
|
return getOrCreateMcpToken();
|
||||||
|
},
|
||||||
|
|
||||||
|
async unregisterAgentMcp() {
|
||||||
|
const results = (await Promise.all(
|
||||||
|
[MCP_SERVER_NAME, ...LEGACY_MCP_SERVER_NAMES].map((name) => removeMcpServerFromAllProviders(name)),
|
||||||
|
)).flat();
|
||||||
|
return { name: MCP_SERVER_NAME, results };
|
||||||
|
},
|
||||||
|
|
||||||
|
async installRuntime() {
|
||||||
|
const result = await installRuntime();
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
status: await this.getStatus(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async listSessions() {
|
||||||
|
await expireStaleSessions();
|
||||||
|
return [...sessions.values()]
|
||||||
|
.filter((session) => session.ownerId === AGENT_OWNER_ID)
|
||||||
|
.map(publicSession);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createAgentSession(options?: { profileName?: string | null }) {
|
||||||
|
const settings = readSettings();
|
||||||
|
if (!settings.enabled) {
|
||||||
|
throw new Error('Browser agent tools are disabled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await expireStaleSessions();
|
||||||
|
const profileName = normalizeProfileName(options?.profileName);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const session: BrowserUseSession = {
|
||||||
|
id: randomUUID(),
|
||||||
|
ownerId: AGENT_OWNER_ID,
|
||||||
|
createdBy: 'agent',
|
||||||
|
runtime: getRuntime(),
|
||||||
|
status: 'unavailable',
|
||||||
|
url: null,
|
||||||
|
title: null,
|
||||||
|
screenshotDataUrl: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
lastAction: 'create',
|
||||||
|
message: null,
|
||||||
|
profileName,
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
cursor: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeOwnerSessions = ownerSessions(AGENT_OWNER_ID).filter((item) => item.status === 'ready');
|
||||||
|
if (activeOwnerSessions.length >= MAX_SESSIONS_PER_OWNER) {
|
||||||
|
throw new Error(`Browser is limited to ${MAX_SESSIONS_PER_OWNER} active agent sessions.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readiness = getRuntimeReadiness();
|
||||||
|
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) {
|
||||||
|
session.message = getSetupMessage(settings, readiness);
|
||||||
|
sessions.set(session.id, session);
|
||||||
|
return publicSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
let browser: any | undefined;
|
||||||
|
let context: any | undefined;
|
||||||
|
let page: any;
|
||||||
|
const launchOptions = {
|
||||||
|
headless: true,
|
||||||
|
args: ['--disable-dev-shm-usage'],
|
||||||
|
};
|
||||||
|
const contextOptions = {
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
serviceWorkers: 'block',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (profileName) {
|
||||||
|
fs.mkdirSync(PROFILE_ROOT, { recursive: true });
|
||||||
|
context = await readiness.playwright.chromium.launchPersistentContext(getProfilePath(profileName), {
|
||||||
|
...launchOptions,
|
||||||
|
...contextOptions,
|
||||||
|
});
|
||||||
|
page = context.pages()[0] || await context.newPage();
|
||||||
|
} else {
|
||||||
|
browser = await readiness.playwright.chromium.launch(launchOptions);
|
||||||
|
context = await browser.newContext(contextOptions);
|
||||||
|
page = await context.newPage();
|
||||||
|
}
|
||||||
|
session.status = 'ready';
|
||||||
|
session.message = 'Browser session is ready.';
|
||||||
|
sessions.set(session.id, session);
|
||||||
|
handles.set(session.id, { browser, context, page });
|
||||||
|
await captureSession(session, page);
|
||||||
|
return publicSession(session);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listAgentSessions() {
|
||||||
|
const settings = readSettings();
|
||||||
|
if (!settings.enabled) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
await expireStaleSessions();
|
||||||
|
return [...sessions.values()]
|
||||||
|
.filter((session) => session.ownerId === AGENT_OWNER_ID)
|
||||||
|
.map(publicSession);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAgentSession(sessionId: string) {
|
||||||
|
const settings = readSettings();
|
||||||
|
if (!settings.enabled) {
|
||||||
|
throw new Error('Browser agent tools are disabled.');
|
||||||
|
}
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session || session.ownerId !== AGENT_OWNER_ID) {
|
||||||
|
throw new Error('Browser session not found.');
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
|
||||||
|
async agentNavigate(sessionId: string, rawUrl: string) {
|
||||||
|
await this.getAgentSession(sessionId);
|
||||||
|
await expireStaleSessions();
|
||||||
|
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session || session.ownerId !== AGENT_OWNER_ID) {
|
||||||
|
throw new Error('Browser session not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status !== 'ready') {
|
||||||
|
throw new Error(session.message || 'Browser session is not available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = handles.get(sessionId);
|
||||||
|
if (!handle?.page) {
|
||||||
|
throw new Error('Browser runtime handle is not available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = normalizeUrl(rawUrl);
|
||||||
|
await handle.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||||
|
session.lastAction = `navigate:${url}`;
|
||||||
|
session.cursor = null;
|
||||||
|
await captureSession(session, handle.page);
|
||||||
|
return publicSession(session);
|
||||||
|
},
|
||||||
|
|
||||||
|
async agentSnapshot(sessionId: string) {
|
||||||
|
const session = await this.getAgentSession(sessionId);
|
||||||
|
const handle = handles.get(sessionId);
|
||||||
|
if (!handle?.page) {
|
||||||
|
throw new Error('Browser runtime handle is not available.');
|
||||||
|
}
|
||||||
|
await captureSession(session, handle.page);
|
||||||
|
const text = await handle.page.locator('body').innerText({ timeout: 5_000 }).catch(() => '');
|
||||||
|
return {
|
||||||
|
session: publicSession(session),
|
||||||
|
text: text.slice(0, 30_000),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async agentClick(sessionId: string, input: { selector?: string; text?: string; x?: number; y?: number }) {
|
||||||
|
const session = await this.getAgentSession(sessionId);
|
||||||
|
const handle = handles.get(sessionId);
|
||||||
|
if (!handle?.page) {
|
||||||
|
throw new Error('Browser runtime handle is not available.');
|
||||||
|
}
|
||||||
|
const point = await getActionPoint(handle.page, input);
|
||||||
|
|
||||||
|
if (input.selector) {
|
||||||
|
await handle.page.locator(input.selector).first().click({ timeout: 10_000 });
|
||||||
|
} else if (input.text) {
|
||||||
|
await handle.page.getByText(input.text, { exact: false }).first().click({ timeout: 10_000 });
|
||||||
|
} else if (typeof input.x === 'number' && typeof input.y === 'number') {
|
||||||
|
await handle.page.mouse.click(input.x, input.y);
|
||||||
|
} else {
|
||||||
|
throw new Error('Provide selector, text, or x/y coordinates.');
|
||||||
|
}
|
||||||
|
|
||||||
|
session.lastAction = 'click';
|
||||||
|
session.cursor = point ? { ...point, actor: 'agent' } : null;
|
||||||
|
await captureSession(session, handle.page);
|
||||||
|
return publicSession(session);
|
||||||
|
},
|
||||||
|
|
||||||
|
async agentType(sessionId: string, input: { selector?: string; text: string; submit?: boolean }) {
|
||||||
|
const session = await this.getAgentSession(sessionId);
|
||||||
|
const handle = handles.get(sessionId);
|
||||||
|
if (!handle?.page) {
|
||||||
|
throw new Error('Browser runtime handle is not available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.selector) {
|
||||||
|
await handle.page.locator(input.selector).first().fill(input.text, { timeout: 10_000 });
|
||||||
|
session.cursor = await getActionPoint(handle.page, input).then((point) => (
|
||||||
|
point ? { ...point, actor: 'agent' as const } : null
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
await handle.page.keyboard.type(input.text);
|
||||||
|
}
|
||||||
|
if (input.submit) {
|
||||||
|
await handle.page.keyboard.press('Enter');
|
||||||
|
}
|
||||||
|
|
||||||
|
session.lastAction = 'type';
|
||||||
|
await captureSession(session, handle.page);
|
||||||
|
return publicSession(session);
|
||||||
|
},
|
||||||
|
|
||||||
|
async agentFillForm(sessionId: string, fields: Array<{ selector: string; value: string }>) {
|
||||||
|
const session = await this.getAgentSession(sessionId);
|
||||||
|
const handle = handles.get(sessionId);
|
||||||
|
if (!handle?.page) {
|
||||||
|
throw new Error('Browser runtime handle is not available.');
|
||||||
|
}
|
||||||
|
for (const field of fields) {
|
||||||
|
await handle.page.locator(field.selector).first().fill(field.value, { timeout: 10_000 });
|
||||||
|
}
|
||||||
|
session.lastAction = 'fill_form';
|
||||||
|
if (fields[0]) {
|
||||||
|
session.cursor = await getActionPoint(handle.page, { selector: fields[0].selector }).then((point) => (
|
||||||
|
point ? { ...point, actor: 'agent' as const } : null
|
||||||
|
));
|
||||||
|
}
|
||||||
|
await captureSession(session, handle.page);
|
||||||
|
return publicSession(session);
|
||||||
|
},
|
||||||
|
|
||||||
|
async agentPressKey(sessionId: string, key: string) {
|
||||||
|
const session = await this.getAgentSession(sessionId);
|
||||||
|
const handle = handles.get(sessionId);
|
||||||
|
if (!handle?.page) {
|
||||||
|
throw new Error('Browser runtime handle is not available.');
|
||||||
|
}
|
||||||
|
await handle.page.keyboard.press(key);
|
||||||
|
session.lastAction = `press_key:${key}`;
|
||||||
|
await captureSession(session, handle.page);
|
||||||
|
return publicSession(session);
|
||||||
|
},
|
||||||
|
|
||||||
|
async agentSelectOption(sessionId: string, selector: string, values: string[]) {
|
||||||
|
const session = await this.getAgentSession(sessionId);
|
||||||
|
const handle = handles.get(sessionId);
|
||||||
|
if (!handle?.page) {
|
||||||
|
throw new Error('Browser runtime handle is not available.');
|
||||||
|
}
|
||||||
|
await handle.page.locator(selector).first().selectOption(values, { timeout: 10_000 });
|
||||||
|
session.lastAction = 'select_option';
|
||||||
|
session.cursor = await getActionPoint(handle.page, { selector }).then((point) => (
|
||||||
|
point ? { ...point, actor: 'agent' as const } : null
|
||||||
|
));
|
||||||
|
await captureSession(session, handle.page);
|
||||||
|
return publicSession(session);
|
||||||
|
},
|
||||||
|
|
||||||
|
async agentWaitFor(sessionId: string, input: { text?: string; url?: string; timeoutMs?: number }) {
|
||||||
|
const session = await this.getAgentSession(sessionId);
|
||||||
|
const handle = handles.get(sessionId);
|
||||||
|
if (!handle?.page) {
|
||||||
|
throw new Error('Browser runtime handle is not available.');
|
||||||
|
}
|
||||||
|
const timeout = Math.max(250, Math.min(input.timeoutMs || 5_000, 30_000));
|
||||||
|
if (input.text) {
|
||||||
|
await handle.page.getByText(input.text, { exact: false }).first().waitFor({ timeout });
|
||||||
|
} else if (input.url) {
|
||||||
|
await handle.page.waitForURL(input.url, { timeout });
|
||||||
|
} else {
|
||||||
|
await handle.page.waitForTimeout(timeout);
|
||||||
|
}
|
||||||
|
session.lastAction = 'wait_for';
|
||||||
|
await captureSession(session, handle.page);
|
||||||
|
return publicSession(session);
|
||||||
|
},
|
||||||
|
|
||||||
|
async agentTabs(sessionId: string, input: { action?: 'list' | 'new' | 'select' | 'close'; index?: number; url?: string }) {
|
||||||
|
const session = await this.getAgentSession(sessionId);
|
||||||
|
const handle = handles.get(sessionId);
|
||||||
|
if (!handle?.context || !handle?.page) {
|
||||||
|
throw new Error('Browser runtime handle is not available.');
|
||||||
|
}
|
||||||
|
const action = input.action || 'list';
|
||||||
|
if (action === 'new') {
|
||||||
|
const page = await handle.context.newPage();
|
||||||
|
handles.set(sessionId, { ...handle, page });
|
||||||
|
if (input.url) {
|
||||||
|
await this.agentNavigate(sessionId, input.url);
|
||||||
|
}
|
||||||
|
} else if (action === 'select') {
|
||||||
|
const page = handle.context.pages()[input.index || 0];
|
||||||
|
if (!page) {
|
||||||
|
throw new Error('Tab not found.');
|
||||||
|
}
|
||||||
|
handles.set(sessionId, { ...handle, page });
|
||||||
|
} else if (action === 'close') {
|
||||||
|
const pages = handle.context.pages();
|
||||||
|
const page = pages[input.index ?? pages.indexOf(handle.page)];
|
||||||
|
if (!page) {
|
||||||
|
throw new Error('Tab not found.');
|
||||||
|
}
|
||||||
|
await page.close();
|
||||||
|
handles.set(sessionId, { ...handle, page: handle.context.pages()[0] || await handle.context.newPage() });
|
||||||
|
}
|
||||||
|
const updatedHandle = handles.get(sessionId);
|
||||||
|
await captureSession(session, updatedHandle?.page || handle.page);
|
||||||
|
return {
|
||||||
|
session: publicSession(session),
|
||||||
|
tabs: handle.context.pages().map((page: any, index: number) => ({
|
||||||
|
index,
|
||||||
|
url: page.url(),
|
||||||
|
active: page === (updatedHandle?.page || handle.page),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopSession(sessionId: string) {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session || session.ownerId !== AGENT_OWNER_ID) {
|
||||||
|
return { stopped: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await closeHandle(sessionId);
|
||||||
|
|
||||||
|
session.status = 'stopped';
|
||||||
|
session.updatedAt = new Date().toISOString();
|
||||||
|
session.lastAction = 'stop';
|
||||||
|
session.message = 'Browser session stopped. Create a new session to continue browsing.';
|
||||||
|
return { stopped: true, session: publicSession(session) };
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteSession(sessionId: string) {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session || session.ownerId !== AGENT_OWNER_ID) {
|
||||||
|
return { deleted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await closeHandle(sessionId);
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
return { deleted: true, sessionId };
|
||||||
|
},
|
||||||
|
|
||||||
|
async agentStopSession(sessionId: string) {
|
||||||
|
await this.getAgentSession(sessionId);
|
||||||
|
return this.stopSession(sessionId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopAllSessions() {
|
||||||
|
await Promise.all([...sessions.keys()].map(async (sessionId) => {
|
||||||
|
await closeHandle(sessionId);
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.status = 'stopped';
|
||||||
|
session.updatedAt = new Date().toISOString();
|
||||||
|
session.lastAction = 'shutdown';
|
||||||
|
session.message = 'Browser session stopped during server shutdown.';
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
process.once('beforeExit', () => {
|
||||||
|
void browserUseService.stopAllSessions();
|
||||||
|
});
|
||||||
10
server/modules/browser-use/tests/browser-use.service.test.ts
Normal file
10
server/modules/browser-use/tests/browser-use.service.test.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
|
||||||
|
|
||||||
|
test('browser monitor list starts empty without agent sessions', async () => {
|
||||||
|
const sessions = await browserUseService.listSessions();
|
||||||
|
|
||||||
|
assert.deepEqual(sessions, []);
|
||||||
|
});
|
||||||
@@ -382,6 +382,25 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the `provider_session_id` mapping column used by the session gateway.
|
||||||
|
*
|
||||||
|
* Rows that existed before this migration were always keyed directly by the
|
||||||
|
* provider-native session id, so backfilling `provider_session_id` with
|
||||||
|
* `session_id` keeps every legacy row resolvable through the new mapping.
|
||||||
|
*/
|
||||||
|
const addProviderSessionIdMapping = (db: Database): void => {
|
||||||
|
const sessionsTableInfo = getTableInfo(db, 'sessions');
|
||||||
|
const columnNames = sessionsTableInfo.map((column) => column.name);
|
||||||
|
|
||||||
|
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'provider_session_id', 'TEXT');
|
||||||
|
db.exec(`
|
||||||
|
UPDATE sessions
|
||||||
|
SET provider_session_id = session_id
|
||||||
|
WHERE provider_session_id IS NULL
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
const ensureProjectsForSessionPaths = (db: Database): void => {
|
const ensureProjectsForSessionPaths = (db: Database): void => {
|
||||||
if (!tableExists(db, 'sessions')) {
|
if (!tableExists(db, 'sessions')) {
|
||||||
return;
|
return;
|
||||||
@@ -428,9 +447,11 @@ export const runMigrations = (db: Database) => {
|
|||||||
migrateLegacyWorkspaceTableIntoProjects(db);
|
migrateLegacyWorkspaceTableIntoProjects(db);
|
||||||
rebuildSessionsTableWithProjectSchema(db);
|
rebuildSessionsTableWithProjectSchema(db);
|
||||||
migrateLegacySessionNames(db);
|
migrateLegacySessionNames(db);
|
||||||
|
addProviderSessionIdMapping(db);
|
||||||
ensureProjectsForSessionPaths(db);
|
ensureProjectsForSessionPaths(db);
|
||||||
|
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_provider_session_id ON sessions(provider_session_id)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: boolean;
|
inApp: boolean;
|
||||||
webPush: boolean;
|
webPush: boolean;
|
||||||
|
sound: boolean;
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
actionRequired: boolean;
|
actionRequired: boolean;
|
||||||
@@ -22,6 +23,7 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: false,
|
inApp: false,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
|
sound: true,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
actionRequired: true,
|
actionRequired: true,
|
||||||
@@ -37,6 +39,7 @@ 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,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { normalizeProjectPath } from '@/shared/utils.js';
|
|||||||
type SessionRow = {
|
type SessionRow = {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
provider_session_id: string | null;
|
||||||
project_path: string | null;
|
project_path: string | null;
|
||||||
jsonl_path: string | null;
|
jsonl_path: string | null;
|
||||||
custom_name: string | null;
|
custom_name: string | null;
|
||||||
@@ -13,15 +14,22 @@ type SessionRow = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SessionMetadataLookupRow = Pick<
|
const SESSION_ROW_COLUMNS =
|
||||||
SessionRow,
|
'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at';
|
||||||
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at'
|
|
||||||
>;
|
const SQLITE_UTC_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
||||||
|
|
||||||
function normalizeTimestamp(value?: string): string | null {
|
function normalizeTimestamp(value?: string): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
const parsed = new Date(value);
|
// SQLite CURRENT_TIMESTAMP is stored as UTC without a timezone suffix.
|
||||||
|
// Normalize it here so every session reader returns canonical ISO strings
|
||||||
|
// and the sidebar never interprets fresh rows as local-time "hours old".
|
||||||
|
const normalizedValue = SQLITE_UTC_TIMESTAMP_REGEX.test(value)
|
||||||
|
? `${value.replace(' ', 'T')}Z`
|
||||||
|
: value;
|
||||||
|
|
||||||
|
const parsed = new Date(normalizedValue);
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -29,14 +37,38 @@ function normalizeTimestamp(value?: string): string | null {
|
|||||||
return parsed.toISOString();
|
return parsed.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSessionRow<T extends SessionRow | null | undefined>(row: T): T {
|
||||||
|
if (!row) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
created_at: normalizeTimestamp(row.created_at) ?? row.created_at,
|
||||||
|
updated_at: normalizeTimestamp(row.updated_at) ?? row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSessionRows(rows: SessionRow[]): SessionRow[] {
|
||||||
|
return rows.map((row) => normalizeSessionRow(row) as SessionRow);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeProjectPathForProvider(provider: string, projectPath: string): string {
|
function normalizeProjectPathForProvider(provider: string, projectPath: string): string {
|
||||||
void provider;
|
void provider;
|
||||||
return normalizeProjectPath(projectPath);
|
return normalizeProjectPath(projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sessionsDb = {
|
export const sessionsDb = {
|
||||||
|
/**
|
||||||
|
* Upserts one session row discovered on disk by a provider synchronizer.
|
||||||
|
*
|
||||||
|
* The given id is the provider-native session id. Rows are keyed by
|
||||||
|
* `provider_session_id` so a session that was first created by the app
|
||||||
|
* (with an app-allocated `session_id`) is updated in place once its
|
||||||
|
* transcript shows up on disk, instead of producing a duplicate row.
|
||||||
|
*/
|
||||||
createSession(
|
createSession(
|
||||||
sessionId: string,
|
providerSessionId: string,
|
||||||
provider: string,
|
provider: string,
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
customName?: string,
|
customName?: string,
|
||||||
@@ -53,19 +85,54 @@ export const sessionsDb = {
|
|||||||
// since it's a foreign key in the sessions table.
|
// since it's a foreign key in the sessions table.
|
||||||
projectsDb.createProjectPath(normalizedProjectPath);
|
projectsDb.createProjectPath(normalizedProjectPath);
|
||||||
|
|
||||||
|
const existing = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id FROM sessions
|
||||||
|
WHERE provider_session_id = ? AND provider = ?
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(providerSessionId, provider) as { session_id: string } | undefined;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE sessions SET
|
||||||
|
provider = ?,
|
||||||
|
updated_at = COALESCE(?, CURRENT_TIMESTAMP),
|
||||||
|
project_path = ?,
|
||||||
|
jsonl_path = ?,
|
||||||
|
isArchived = 0,
|
||||||
|
custom_name = COALESCE(?, custom_name)
|
||||||
|
WHERE session_id = ?`
|
||||||
|
).run(
|
||||||
|
provider,
|
||||||
|
updatedAtValue,
|
||||||
|
normalizedProjectPath,
|
||||||
|
jsonlPath ?? null,
|
||||||
|
customName ?? null,
|
||||||
|
existing.session_id
|
||||||
|
);
|
||||||
|
|
||||||
|
return existing.session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessions created outside the app (directly via the provider CLI) are
|
||||||
|
// keyed by the provider-native id for both columns. The ON CONFLICT path
|
||||||
|
// covers legacy rows that predate the provider_session_id mapping.
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
|
`INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
|
VALUES (?, ?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
|
||||||
ON CONFLICT(session_id) DO UPDATE SET
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
provider = excluded.provider,
|
provider = excluded.provider,
|
||||||
|
provider_session_id = excluded.provider_session_id,
|
||||||
updated_at = excluded.updated_at,
|
updated_at = excluded.updated_at,
|
||||||
project_path = excluded.project_path,
|
project_path = excluded.project_path,
|
||||||
jsonl_path = excluded.jsonl_path,
|
jsonl_path = excluded.jsonl_path,
|
||||||
isArchived = 0,
|
isArchived = 0,
|
||||||
custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
|
custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
|
||||||
).run(
|
).run(
|
||||||
sessionId,
|
providerSessionId,
|
||||||
provider,
|
provider,
|
||||||
|
providerSessionId,
|
||||||
customName ?? null,
|
customName ?? null,
|
||||||
normalizedProjectPath,
|
normalizedProjectPath,
|
||||||
jsonlPath ?? null,
|
jsonlPath ?? null,
|
||||||
@@ -73,9 +140,77 @@ export const sessionsDb = {
|
|||||||
updatedAtValue
|
updatedAtValue
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return providerSessionId;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts one app-allocated session row before any provider run happens.
|
||||||
|
*
|
||||||
|
* The session gateway uses this when the frontend starts a brand-new chat:
|
||||||
|
* `session_id` is the stable app-facing id, while `provider_session_id`
|
||||||
|
* stays NULL until the provider runtime announces its own id and
|
||||||
|
* `assignProviderSessionId` records the mapping.
|
||||||
|
*/
|
||||||
|
createAppSession(sessionId: string, provider: string, projectPath: string): string {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
|
||||||
|
|
||||||
|
projectsDb.createProjectPath(normalizedProjectPath);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
|
||||||
|
VALUES (?, ?, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
|
||||||
|
).run(sessionId, provider, normalizedProjectPath);
|
||||||
|
|
||||||
return sessionId;
|
return sessionId;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the provider-native session id for one app-allocated session.
|
||||||
|
*
|
||||||
|
* If the filesystem watcher indexed the provider transcript before this
|
||||||
|
* mapping was recorded (a duplicate row keyed by the provider id exists),
|
||||||
|
* the duplicate is merged into the app row: its transcript path and name
|
||||||
|
* are adopted and the duplicate row is removed. Runs in a transaction so
|
||||||
|
* the sidebar can never observe both rows at once.
|
||||||
|
*/
|
||||||
|
assignProviderSessionId(sessionId: string, providerSessionId: string): void {
|
||||||
|
const db = getConnection();
|
||||||
|
|
||||||
|
const merge = db.transaction(() => {
|
||||||
|
const duplicate = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${SESSION_ROW_COLUMNS} FROM sessions
|
||||||
|
WHERE (session_id = ? OR provider_session_id = ?)
|
||||||
|
AND session_id <> ?
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(providerSessionId, providerSessionId, sessionId) as SessionRow | undefined;
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE session_id = ?').run(duplicate.session_id);
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE sessions SET
|
||||||
|
provider_session_id = ?,
|
||||||
|
jsonl_path = COALESCE(jsonl_path, ?),
|
||||||
|
custom_name = COALESCE(custom_name, ?),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE session_id = ?`
|
||||||
|
).run(providerSessionId, duplicate.jsonl_path, duplicate.custom_name, sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE sessions SET
|
||||||
|
provider_session_id = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE session_id = ?`
|
||||||
|
).run(providerSessionId, sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
merge();
|
||||||
|
},
|
||||||
|
|
||||||
updateSessionCustomName(sessionId: string, customName: string): void {
|
updateSessionCustomName(sessionId: string, customName: string): void {
|
||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -85,30 +220,91 @@ export const sessionsDb = {
|
|||||||
).run(customName, sessionId);
|
).run(customName, sessionId);
|
||||||
},
|
},
|
||||||
|
|
||||||
getSessionById(sessionId: string): SessionMetadataLookupRow | null {
|
getSessionById(sessionId: string): SessionRow | null {
|
||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE session_id = ?
|
WHERE session_id = ?
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
LIMIT 1`
|
LIMIT 1`
|
||||||
)
|
)
|
||||||
.get(sessionId) as SessionMetadataLookupRow | undefined;
|
.get(sessionId) as SessionRow | undefined;
|
||||||
|
|
||||||
return row ?? null;
|
return normalizeSessionRow(row) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves one session row through the provider-native id.
|
||||||
|
*
|
||||||
|
* The filesystem watcher only knows provider ids (they come from transcript
|
||||||
|
* file names), so it uses this lookup to translate disk artifacts back to
|
||||||
|
* the app-facing session row before broadcasting sidebar updates.
|
||||||
|
*/
|
||||||
|
getSessionByProviderSessionId(providerSessionId: string): SessionRow | null {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
|
FROM sessions
|
||||||
|
WHERE provider_session_id = ?
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(providerSessionId) as SessionRow | undefined;
|
||||||
|
|
||||||
|
return normalizeSessionRow(row) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the newest app-created session for a project that is still waiting
|
||||||
|
* for its provider-native id to be recorded.
|
||||||
|
*
|
||||||
|
* Primary intention: OpenCode can expose a new session in its shared
|
||||||
|
* `opencode.db` before the websocket runtime reports that same provider id
|
||||||
|
* back to our app. At that moment the sidebar already has an optimistic
|
||||||
|
* app-owned session row, but the watcher only knows the provider-native id.
|
||||||
|
*
|
||||||
|
* Without this lookup, the synchronizer would insert a second row keyed by
|
||||||
|
* the provider id, then `assignProviderSessionId()` would merge it a moment
|
||||||
|
* later. That eventually self-heals, but on slow networks the user can still
|
||||||
|
* briefly see two sidebar sessions for the same conversation.
|
||||||
|
*
|
||||||
|
* This helper lets the synchronizer claim the pending app row first, so the
|
||||||
|
* provider id is attached before any watcher-created row exists. The result
|
||||||
|
* is simpler than frontend dedupe and keeps the race resolved at the source.
|
||||||
|
*/
|
||||||
|
findLatestPendingAppSession(provider: string, projectPath: string): SessionRow | null {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
|
FROM sessions
|
||||||
|
WHERE provider = ?
|
||||||
|
AND project_path = ?
|
||||||
|
AND provider_session_id IS NULL
|
||||||
|
AND isArchived = 0
|
||||||
|
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(provider, normalizedProjectPath) as SessionRow | undefined;
|
||||||
|
|
||||||
|
return normalizeSessionRow(row) ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
getAllSessions(): SessionRow[] {
|
getAllSessions(): SessionRow[] {
|
||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
return db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE isArchived = 0`
|
WHERE isArchived = 0`
|
||||||
)
|
)
|
||||||
.all() as SessionRow[];
|
.all() as SessionRow[];
|
||||||
|
|
||||||
|
return normalizeSessionRows(rows);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,27 +313,31 @@ export const sessionsDb = {
|
|||||||
*/
|
*/
|
||||||
getArchivedSessions(): SessionRow[] {
|
getArchivedSessions(): SessionRow[] {
|
||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
return db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE isArchived = 1
|
WHERE isArchived = 1
|
||||||
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
|
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
|
||||||
)
|
)
|
||||||
.all() as SessionRow[];
|
.all() as SessionRow[];
|
||||||
|
|
||||||
|
return normalizeSessionRows(rows);
|
||||||
},
|
},
|
||||||
|
|
||||||
getSessionsByProjectPath(projectPath: string): SessionRow[] {
|
getSessionsByProjectPath(projectPath: string): SessionRow[] {
|
||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
return db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE project_path = ?
|
WHERE project_path = ?
|
||||||
AND isArchived = 0`
|
AND isArchived = 0`
|
||||||
)
|
)
|
||||||
.all(normalizedProjectPath) as SessionRow[];
|
.all(normalizedProjectPath) as SessionRow[];
|
||||||
|
|
||||||
|
return normalizeSessionRows(rows);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,21 +347,23 @@ export const sessionsDb = {
|
|||||||
getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] {
|
getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] {
|
||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
return db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE project_path = ?`
|
WHERE project_path = ?`
|
||||||
)
|
)
|
||||||
.all(normalizedProjectPath) as SessionRow[];
|
.all(normalizedProjectPath) as SessionRow[];
|
||||||
|
|
||||||
|
return normalizeSessionRows(rows);
|
||||||
},
|
},
|
||||||
|
|
||||||
getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] {
|
getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] {
|
||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
return db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE project_path = ?
|
WHERE project_path = ?
|
||||||
AND isArchived = 0
|
AND isArchived = 0
|
||||||
@@ -169,6 +371,8 @@ export const sessionsDb = {
|
|||||||
LIMIT ? OFFSET ?`
|
LIMIT ? OFFSET ?`
|
||||||
)
|
)
|
||||||
.all(normalizedProjectPath, limit, offset) as SessionRow[];
|
.all(normalizedProjectPath, limit, offset) as SessionRow[];
|
||||||
|
|
||||||
|
return normalizeSessionRows(rows);
|
||||||
},
|
},
|
||||||
|
|
||||||
countSessionsByProjectPath(projectPath: string): number {
|
countSessionsByProjectPath(projectPath: string): number {
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export const SESSIONS_TABLE_SCHEMA_SQL = `
|
|||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
session_id TEXT NOT NULL,
|
session_id TEXT NOT NULL,
|
||||||
provider TEXT NOT NULL DEFAULT 'claude',
|
provider TEXT NOT NULL DEFAULT 'claude',
|
||||||
|
-- The session id used by the provider CLI/SDK on disk (JSONL file name,
|
||||||
|
-- store.db folder, sqlite row id, ...). \`session_id\` is the stable
|
||||||
|
-- app-facing id that the frontend uses for the whole session lifetime;
|
||||||
|
-- \`provider_session_id\` is filled in once the provider announces its own
|
||||||
|
-- id mid-run, or equals \`session_id\` for sessions discovered on disk.
|
||||||
|
provider_session_id TEXT,
|
||||||
custom_name TEXT,
|
custom_name TEXT,
|
||||||
project_path TEXT,
|
project_path TEXT,
|
||||||
jsonl_path TEXT,
|
jsonl_path TEXT,
|
||||||
|
|||||||
108
server/modules/database/tests/sessions-provider-mapping.test.ts
Normal file
108
server/modules/database/tests/sessions-provider-mapping.test.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { closeConnection } from '@/modules/database/connection.js';
|
||||||
|
import { initializeDatabase } from '@/modules/database/init-db.js';
|
||||||
|
import { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
|
||||||
|
|
||||||
|
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||||
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
|
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'sessions-mapping-'));
|
||||||
|
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||||
|
|
||||||
|
closeConnection();
|
||||||
|
process.env.DATABASE_PATH = databasePath;
|
||||||
|
await initializeDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runTest();
|
||||||
|
} finally {
|
||||||
|
closeConnection();
|
||||||
|
if (previousDatabasePath === undefined) {
|
||||||
|
delete process.env.DATABASE_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.DATABASE_PATH = previousDatabasePath;
|
||||||
|
}
|
||||||
|
await rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('disk-discovered sessions are keyed by the provider id for both columns', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createSession('provider-abc', 'claude', '/workspace/demo', 'From Disk');
|
||||||
|
|
||||||
|
const row = sessionsDb.getSessionById('provider-abc');
|
||||||
|
assert.equal(row?.session_id, 'provider-abc');
|
||||||
|
assert.equal(row?.provider_session_id, 'provider-abc');
|
||||||
|
|
||||||
|
const byProviderId = sessionsDb.getSessionByProviderSessionId('provider-abc');
|
||||||
|
assert.equal(byProviderId?.session_id, 'provider-abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app sessions get the provider id assigned without creating a duplicate row', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-id-1', 'claude', '/workspace/demo');
|
||||||
|
sessionsDb.assignProviderSessionId('app-id-1', 'provider-xyz');
|
||||||
|
|
||||||
|
// A later synchronizer pass that discovers the transcript on disk must
|
||||||
|
// update the app row in place instead of inserting a provider-keyed row.
|
||||||
|
const returnedId = sessionsDb.createSession(
|
||||||
|
'provider-xyz',
|
||||||
|
'claude',
|
||||||
|
'/workspace/demo',
|
||||||
|
'Synced Name',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'/fake/path/provider-xyz.jsonl',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(returnedId, 'app-id-1');
|
||||||
|
assert.equal(sessionsDb.getAllSessions().length, 1);
|
||||||
|
|
||||||
|
const row = sessionsDb.getSessionById('app-id-1');
|
||||||
|
assert.equal(row?.provider_session_id, 'provider-xyz');
|
||||||
|
assert.equal(row?.jsonl_path, '/fake/path/provider-xyz.jsonl');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assignProviderSessionId merges a watcher-created duplicate into the app row', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-id-2', 'codex', '/workspace/demo');
|
||||||
|
|
||||||
|
// Simulate the race: the filesystem watcher indexed the provider
|
||||||
|
// transcript before the runtime announced its session id to the gateway.
|
||||||
|
sessionsDb.createSession(
|
||||||
|
'provider-race',
|
||||||
|
'codex',
|
||||||
|
'/workspace/demo',
|
||||||
|
'Watcher Name',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'/fake/provider-race.jsonl',
|
||||||
|
);
|
||||||
|
assert.equal(sessionsDb.getAllSessions().length, 2);
|
||||||
|
|
||||||
|
sessionsDb.assignProviderSessionId('app-id-2', 'provider-race');
|
||||||
|
|
||||||
|
const rows = sessionsDb.getAllSessions();
|
||||||
|
assert.equal(rows.length, 1);
|
||||||
|
assert.equal(rows[0]?.session_id, 'app-id-2');
|
||||||
|
assert.equal(rows[0]?.provider_session_id, 'provider-race');
|
||||||
|
// Transcript path and name from the duplicate are adopted.
|
||||||
|
assert.equal(rows[0]?.jsonl_path, '/fake/provider-race.jsonl');
|
||||||
|
assert.equal(rows[0]?.custom_name, 'Watcher Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy provider-keyed rows stay resolvable through both lookups', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createSession('legacy-1', 'gemini', '/workspace/demo');
|
||||||
|
|
||||||
|
assert.equal(sessionsDb.getSessionById('legacy-1')?.provider, 'gemini');
|
||||||
|
assert.equal(sessionsDb.getSessionByProviderSessionId('legacy-1')?.session_id, 'legacy-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -70,3 +70,15 @@ test('createSession reactivates archived rows when the session becomes active ag
|
|||||||
assert.equal(restoredSession?.isArchived, 0);
|
assert.equal(restoredSession?.isArchived, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('repository reads normalize SQLite UTC timestamps to ISO strings', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('session-timezone', 'claude', '/workspace/demo-project');
|
||||||
|
|
||||||
|
const row = sessionsDb.getSessionById('session-timezone');
|
||||||
|
assert.ok(row?.created_at.endsWith('Z'));
|
||||||
|
assert.ok(row?.updated_at.endsWith('Z'));
|
||||||
|
assert.match(row?.created_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
|
||||||
|
assert.match(row?.updated_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,10 +30,6 @@ type ProjectApiView = {
|
|||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
isStarred: boolean;
|
isStarred: boolean;
|
||||||
sessions: [];
|
sessions: [];
|
||||||
cursorSessions: [];
|
|
||||||
codexSessions: [];
|
|
||||||
geminiSessions: [];
|
|
||||||
opencodeSessions: [];
|
|
||||||
sessionMeta: {
|
sessionMeta: {
|
||||||
hasMore: false;
|
hasMore: false;
|
||||||
total: 0;
|
total: 0;
|
||||||
@@ -82,10 +78,6 @@ function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiVie
|
|||||||
isArchived: Boolean(projectRow.isArchived),
|
isArchived: Boolean(projectRow.isArchived),
|
||||||
isStarred: Boolean(projectRow.isStarred),
|
isStarred: Boolean(projectRow.isStarred),
|
||||||
sessions: [],
|
sessions: [],
|
||||||
cursorSessions: [],
|
|
||||||
codexSessions: [],
|
|
||||||
geminiSessions: [],
|
|
||||||
opencodeSessions: [],
|
|
||||||
sessionMeta: {
|
sessionMeta: {
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
total: 0,
|
total: 0,
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ import { AppError } from '@/shared/utils.js';
|
|||||||
|
|
||||||
type SessionSummary = {
|
type SessionSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
|
provider: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
lastActivity: string;
|
lastActivity: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>;
|
|
||||||
|
|
||||||
type SessionRepositoryRow = {
|
type SessionRepositoryRow = {
|
||||||
provider: string;
|
provider: string;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -31,10 +30,6 @@ export type ProjectListItem = {
|
|||||||
fullPath: string;
|
fullPath: string;
|
||||||
isStarred: boolean;
|
isStarred: boolean;
|
||||||
sessions: SessionSummary[];
|
sessions: SessionSummary[];
|
||||||
cursorSessions: SessionSummary[];
|
|
||||||
codexSessions: SessionSummary[];
|
|
||||||
geminiSessions: SessionSummary[];
|
|
||||||
opencodeSessions: SessionSummary[];
|
|
||||||
sessionMeta: {
|
sessionMeta: {
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -64,7 +59,7 @@ type SessionPaginationOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ProjectSessionsPageResult = {
|
type ProjectSessionsPageResult = {
|
||||||
sessionsByProvider: SessionsByProvider;
|
sessions: SessionSummary[];
|
||||||
total: number;
|
total: number;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
};
|
};
|
||||||
@@ -72,10 +67,6 @@ type ProjectSessionsPageResult = {
|
|||||||
export type ProjectSessionsPageApiView = {
|
export type ProjectSessionsPageApiView = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
sessions: SessionSummary[];
|
sessions: SessionSummary[];
|
||||||
cursorSessions: SessionSummary[];
|
|
||||||
codexSessions: SessionSummary[];
|
|
||||||
geminiSessions: SessionSummary[];
|
|
||||||
opencodeSessions: SessionSummary[];
|
|
||||||
sessionMeta: {
|
sessionMeta: {
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -129,39 +120,18 @@ function normalizeSessionPagination(options: SessionPaginationOptions = {}): { l
|
|||||||
function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary {
|
function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary {
|
||||||
return {
|
return {
|
||||||
id: row.session_id,
|
id: row.session_id,
|
||||||
|
provider: row.provider,
|
||||||
summary: row.custom_name || '',
|
summary: row.custom_name || '',
|
||||||
messageCount: 0,
|
messageCount: 0,
|
||||||
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByProvider {
|
|
||||||
const byProvider: SessionsByProvider = {
|
|
||||||
claude: [],
|
|
||||||
cursor: [],
|
|
||||||
codex: [],
|
|
||||||
gemini: [],
|
|
||||||
opencode: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const provider = row.provider as keyof SessionsByProvider;
|
|
||||||
const bucket = byProvider[provider];
|
|
||||||
if (!bucket) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
bucket.push(mapSessionRowToSummary(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
return byProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult {
|
function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult {
|
||||||
const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[];
|
const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionsByProvider: bucketSessionRowsByProvider(rows),
|
sessions: rows.map(mapSessionRowToSummary),
|
||||||
total: rows.length,
|
total: rows.length,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
};
|
};
|
||||||
@@ -183,16 +153,17 @@ function readProjectSessionsPageByPath(
|
|||||||
const total = sessionsDb.countSessionsByProjectPath(projectPath);
|
const total = sessionsDb.countSessionsByProjectPath(projectPath);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionsByProvider: bucketSessionRowsByProvider(rows),
|
sessions: rows.map(mapSessionRowToSummary),
|
||||||
total,
|
total,
|
||||||
hasMore: pagination.offset + rows.length < total,
|
hasMore: pagination.offset + rows.length < total,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast progress to all connected WebSocket clients
|
// Broadcast progress to all connected WebSocket clients.
|
||||||
|
// Uses the unified `kind` envelope like every other websocket frame.
|
||||||
function broadcastProgress(progress: ProgressUpdate) {
|
function broadcastProgress(progress: ProgressUpdate) {
|
||||||
const message = JSON.stringify({
|
const message = JSON.stringify({
|
||||||
type: 'loading_progress',
|
kind: 'loading_progress',
|
||||||
...progress,
|
...progress,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,7 +175,7 @@ function broadcastProgress(progress: ProgressUpdate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads all projects from DB and returns provider-bucketed session summaries.
|
* Reads all projects from DB and returns normalized session summaries.
|
||||||
*/
|
*/
|
||||||
export async function getProjectsWithSessions(
|
export async function getProjectsWithSessions(
|
||||||
options: GetProjectsWithSessionsOptions = {}
|
options: GetProjectsWithSessionsOptions = {}
|
||||||
@@ -252,11 +223,7 @@ export async function getProjectsWithSessions(
|
|||||||
displayName,
|
displayName,
|
||||||
fullPath: projectPath,
|
fullPath: projectPath,
|
||||||
isStarred: Boolean(row.isStarred),
|
isStarred: Boolean(row.isStarred),
|
||||||
sessions: sessionsPage.sessionsByProvider.claude,
|
sessions: sessionsPage.sessions,
|
||||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
|
||||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
|
||||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
|
||||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
|
||||||
sessionMeta: {
|
sessionMeta: {
|
||||||
hasMore: sessionsPage.hasMore,
|
hasMore: sessionsPage.hasMore,
|
||||||
total: sessionsPage.total,
|
total: sessionsPage.total,
|
||||||
@@ -309,11 +276,7 @@ export async function getArchivedProjectsWithSessions(
|
|||||||
fullPath: row.project_path,
|
fullPath: row.project_path,
|
||||||
isStarred: Boolean(row.isStarred),
|
isStarred: Boolean(row.isStarred),
|
||||||
isArchived: true,
|
isArchived: true,
|
||||||
sessions: sessionsPage.sessionsByProvider.claude,
|
sessions: sessionsPage.sessions,
|
||||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
|
||||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
|
||||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
|
||||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
|
||||||
sessionMeta: {
|
sessionMeta: {
|
||||||
hasMore: sessionsPage.hasMore,
|
hasMore: sessionsPage.hasMore,
|
||||||
total: sessionsPage.total,
|
total: sessionsPage.total,
|
||||||
@@ -342,11 +305,7 @@ export async function getProjectSessionsPage(
|
|||||||
const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options);
|
const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options);
|
||||||
return {
|
return {
|
||||||
projectId: projectRow.project_id,
|
projectId: projectRow.project_id,
|
||||||
sessions: sessionsPage.sessionsByProvider.claude,
|
sessions: sessionsPage.sessions,
|
||||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
|
||||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
|
||||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
|
||||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
|
||||||
sessionMeta: {
|
sessionMeta: {
|
||||||
hasMore: sessionsPage.hasMore,
|
hasMore: sessionsPage.hasMore,
|
||||||
total: sessionsPage.total,
|
total: sessionsPage.total,
|
||||||
|
|||||||
@@ -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 `public/modelConstants.js` if the provider appears in README or public API docs.
|
- Update the `PROVIDER_ORDER` list in `public/api-docs.html` if the provider should appear in the public API docs.
|
||||||
- Update `src/components/chat/hooks/useChatProviderState.ts` and
|
- 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.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { sessionSynchronizerService } from './services/session-synchronizer.service.js';
|
export { sessionSynchronizerService } from './services/session-synchronizer.service.js';
|
||||||
export { providerSkillsService } from './services/skills.service.js';
|
export { providerSkillsService } from './services/skills.service.js';
|
||||||
|
export { providerMcpService } from './services/mcp.service.js';
|
||||||
|
|
||||||
export { initializeSessionsWatcher } from './services/sessions-watcher.service.js';
|
export { initializeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||||
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';
|
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
|||||||
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.8 (1M context)) · $5/$25 per Mtok',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'fable',
|
||||||
|
label: 'Fable',
|
||||||
|
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "sonnet",
|
value: "sonnet",
|
||||||
label: "Sonnet",
|
label: "Sonnet",
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
|||||||
private readonly provider = 'claude' as const;
|
private readonly provider = 'claude' as const;
|
||||||
private readonly claudeHome = path.join(os.homedir(), '.claude');
|
private readonly claudeHome = path.join(os.homedir(), '.claude');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when a JSONL file is a subagent transcript rather than a
|
||||||
|
* top-level session.
|
||||||
|
*
|
||||||
|
* Claude stores subagent transcripts under a `subagents/` directory, e.g.
|
||||||
|
* `~/.claude/projects/<encoded-cwd>/<session-id>/subagents/agent-<id>.jsonl`.
|
||||||
|
* Those files repeat the parent session's `sessionId`, so indexing them as
|
||||||
|
* standalone sessions overwrites the parent row's `jsonl_path` and corrupts
|
||||||
|
* the main session record. The recursive scan in `synchronize()` reaches
|
||||||
|
* them, so both entry points must skip them.
|
||||||
|
*/
|
||||||
|
private isSubagentTranscript(filePath: string): boolean {
|
||||||
|
return path.normalize(filePath).split(path.sep).includes('subagents');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scans ~/.claude/projects and upserts discovered sessions into DB.
|
* Scans ~/.claude/projects and upserts discovered sessions into DB.
|
||||||
*/
|
*/
|
||||||
@@ -38,6 +53,10 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
|||||||
|
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
for (const filePath of files) {
|
for (const filePath of files) {
|
||||||
|
if (this.isSubagentTranscript(filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = await this.processSessionFile(filePath, nameMap);
|
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
continue;
|
continue;
|
||||||
@@ -66,6 +85,9 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
|||||||
if (!filePath.endsWith('.jsonl')) {
|
if (!filePath.endsWith('.jsonl')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (this.isSubagentTranscript(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
|
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
|
||||||
const parsed = await this.processSessionFile(filePath, nameMap);
|
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||||
@@ -111,7 +133,10 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
// App-created sessions are keyed by an app id, so disk-discovered provider
|
||||||
|
// ids must be resolved through the provider-id mapping first.
|
||||||
|
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
|
||||||
|
?? sessionsDb.getSessionById(parsed.sessionId);
|
||||||
const existingSessionName = existingSession?.custom_name;
|
const existingSessionName = existingSession?.custom_name;
|
||||||
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
|
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import readline from 'node:readline';
|
|||||||
|
|
||||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||||
import { sessionsDb } from '@/modules/database/index.js';
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
|
|
||||||
const PROVIDER = 'claude';
|
const PROVIDER = 'claude';
|
||||||
@@ -103,10 +103,13 @@ async function parseAgentTools(filePath: string): Promise<AnyRecord[]> {
|
|||||||
|
|
||||||
async function getSessionMessages(
|
async function getSessionMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
providerSessionId: string,
|
||||||
limit: number | null,
|
limit: number | null,
|
||||||
offset: number,
|
offset: number,
|
||||||
): Promise<ClaudeHistoryMessagesResult> {
|
): Promise<ClaudeHistoryMessagesResult> {
|
||||||
try {
|
try {
|
||||||
|
// The DB row is keyed by the app-facing session id, while the JSONL rows
|
||||||
|
// on disk carry the provider-native id — both ids are needed here.
|
||||||
const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||||
|
|
||||||
if (!jsonLPath) {
|
if (!jsonLPath) {
|
||||||
@@ -133,7 +136,7 @@ async function getSessionMessages(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const entry = JSON.parse(line) as AnyRecord;
|
const entry = JSON.parse(line) as AnyRecord;
|
||||||
if (entry.sessionId === sessionId) {
|
if (entry.sessionId === providerSessionId) {
|
||||||
messages.push(entry);
|
messages.push(entry);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -553,12 +556,13 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
|||||||
options: FetchHistoryOptions = {},
|
options: FetchHistoryOptions = {},
|
||||||
): Promise<FetchHistoryResult> {
|
): Promise<FetchHistoryResult> {
|
||||||
const { limit = null, offset = 0 } = options;
|
const { limit = null, offset = 0 } = options;
|
||||||
|
const providerSessionId = options.providerSessionId ?? sessionId;
|
||||||
|
|
||||||
let result: ClaudeHistoryResult;
|
let result: ClaudeHistoryResult;
|
||||||
try {
|
try {
|
||||||
// Load full history first so `total` reflects frontend-normalized messages,
|
// Load full history first so `total` reflects frontend-normalized messages,
|
||||||
// not raw JSONL records.
|
// not raw JSONL records.
|
||||||
result = await getSessionMessages(sessionId, null, 0);
|
result = await getSessionMessages(sessionId, providerSessionId, null, 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
|
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
|
||||||
@@ -606,7 +610,6 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalNormalized = normalized.length;
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const msg of normalized) {
|
for (const msg of normalized) {
|
||||||
if (msg.kind !== 'tool_result') {
|
if (msg.kind !== 'tool_result') {
|
||||||
@@ -615,18 +618,10 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
const normalizedOffset = Math.max(0, offset);
|
const normalizedOffset = Math.max(0, offset);
|
||||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||||
const messages = normalizedLimit === null
|
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
|
||||||
? normalized
|
|
||||||
: normalized.slice(
|
|
||||||
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
|
|
||||||
Math.max(0, totalNormalized - normalizedOffset),
|
|
||||||
);
|
|
||||||
const hasMore = normalizedLimit === null
|
|
||||||
? false
|
|
||||||
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages: page,
|
||||||
total,
|
total,
|
||||||
hasMore,
|
hasMore,
|
||||||
offset: normalizedOffset,
|
offset: normalizedOffset,
|
||||||
|
|||||||
@@ -43,11 +43,12 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
|
||||||
|
?? sessionsDb.getSessionById(parsed.sessionId);
|
||||||
if (existingSession) {
|
if (existingSession) {
|
||||||
// If session name is untitled and we now have a name, update it
|
// If session name is untitled and we now have a name, update it
|
||||||
if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') {
|
if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') {
|
||||||
sessionsDb.updateSessionCustomName(parsed.sessionId, parsed.sessionName);
|
sessionsDb.updateSessionCustomName(existingSession.session_id, parsed.sessionName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +121,10 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
// App-created sessions are keyed by an app id, so disk-discovered provider
|
||||||
|
// ids must be resolved through the provider-id mapping first.
|
||||||
|
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
|
||||||
|
?? sessionsDb.getSessionById(parsed.sessionId);
|
||||||
const existingSessionName = existingSession?.custom_name;
|
const existingSessionName = existingSession?.custom_name;
|
||||||
if (existingSessionName && existingSessionName !== 'Untitled Codex Session') {
|
if (existingSessionName && existingSessionName !== 'Untitled Codex Session') {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import readline from 'node:readline';
|
|||||||
import { sessionsDb } from '@/modules/database/index.js';
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||||
|
|
||||||
const PROVIDER = 'codex';
|
const PROVIDER = 'codex';
|
||||||
|
|
||||||
@@ -552,7 +552,6 @@ export class CodexSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalNormalized = normalized.length;
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const msg of normalized) {
|
for (const msg of normalized) {
|
||||||
if (msg.kind !== 'tool_result') {
|
if (msg.kind !== 'tool_result') {
|
||||||
@@ -561,18 +560,10 @@ export class CodexSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
const normalizedOffset = Math.max(0, offset);
|
const normalizedOffset = Math.max(0, offset);
|
||||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||||
const messages = normalizedLimit === null
|
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
|
||||||
? normalized
|
|
||||||
: normalized.slice(
|
|
||||||
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
|
|
||||||
Math.max(0, totalNormalized - normalizedOffset),
|
|
||||||
);
|
|
||||||
const hasMore = normalizedLimit === null
|
|
||||||
? false
|
|
||||||
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages: page,
|
||||||
total,
|
total,
|
||||||
hasMore,
|
hasMore,
|
||||||
offset: normalizedOffset,
|
offset: normalizedOffset,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
generateMessageId,
|
generateMessageId,
|
||||||
readObjectRecord,
|
readObjectRecord,
|
||||||
sanitizeLeafDirectoryName,
|
sanitizeLeafDirectoryName,
|
||||||
|
sliceTailPage,
|
||||||
} from '@/shared/utils.js';
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
const PROVIDER = 'cursor';
|
const PROVIDER = 'cursor';
|
||||||
@@ -363,42 +364,32 @@ export class CursorSessionsProvider implements IProviderSessions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches and paginates Cursor session history from its project-scoped store.db.
|
* Fetches and paginates Cursor session history from its project-scoped store.db.
|
||||||
|
*
|
||||||
|
* Pagination follows the shared tail contract (`sliceTailPage`): offset 0 is
|
||||||
|
* the most recent page, matching every other provider.
|
||||||
*/
|
*/
|
||||||
async fetchHistory(
|
async fetchHistory(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
options: FetchHistoryOptions = {},
|
options: FetchHistoryOptions = {},
|
||||||
): Promise<FetchHistoryResult> {
|
): Promise<FetchHistoryResult> {
|
||||||
const { projectPath = '', limit = null, offset = 0 } = options;
|
const { projectPath = '', limit = null, offset = 0 } = options;
|
||||||
|
// The store.db folder on disk is named after the provider-native id, not
|
||||||
|
// the app-facing session id this method is addressed with.
|
||||||
|
const providerSessionId = options.providerSessionId ?? sessionId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
|
const blobs = await this.loadCursorBlobs(providerSessionId, projectPath);
|
||||||
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
|
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
|
||||||
const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result');
|
const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result');
|
||||||
const total = renderableMessages.length;
|
const total = renderableMessages.length;
|
||||||
|
const { page, hasMore } = sliceTailPage(renderableMessages, limit, offset);
|
||||||
if (limit !== null) {
|
|
||||||
const start = offset;
|
|
||||||
const page = limit === 0
|
|
||||||
? []
|
|
||||||
: renderableMessages.slice(start, start + limit);
|
|
||||||
const hasMore = limit === 0
|
|
||||||
? start < total
|
|
||||||
: start + limit < total;
|
|
||||||
return {
|
|
||||||
messages: page,
|
|
||||||
total,
|
|
||||||
hasMore,
|
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: renderableMessages,
|
messages: page,
|
||||||
total,
|
total,
|
||||||
hasMore: false,
|
hasMore,
|
||||||
offset: 0,
|
offset,
|
||||||
limit: null,
|
limit,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -12,17 +12,14 @@ import {
|
|||||||
|
|
||||||
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
|
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||||
OPTIONS: [
|
OPTIONS: [
|
||||||
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
|
|
||||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
|
|
||||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||||
|
{ value: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite Preview' },
|
||||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
|
||||||
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
|
{ value: 'gemma-4-31b-it', label: 'Gemma 4 31B IT' },
|
||||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
{ value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 26B A4B IT' },
|
||||||
{ 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',
|
DEFAULT: 'gemini-3-flash-preview',
|
||||||
};
|
};
|
||||||
|
|
||||||
export class GeminiProviderModels implements IProviderModels {
|
export class GeminiProviderModels implements IProviderModels {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import readline from 'node:readline';
|
|||||||
import { sessionsDb } from '@/modules/database/index.js';
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||||
|
|
||||||
const PROVIDER = 'gemini';
|
const PROVIDER = 'gemini';
|
||||||
|
|
||||||
@@ -518,9 +518,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
|||||||
|
|
||||||
const start = Math.max(0, offset);
|
const start = Math.max(0, offset);
|
||||||
const pageLimit = limit === null ? null : Math.max(0, limit);
|
const pageLimit = limit === null ? null : Math.max(0, limit);
|
||||||
const messages = pageLimit === null
|
// Tail pagination via the shared contract: offset 0 returns the most
|
||||||
? normalized.slice(start)
|
// recent page, matching every other provider.
|
||||||
: normalized.slice(start, start + pageLimit);
|
const { page, hasMore } = sliceTailPage(normalized, pageLimit, start);
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const msg of normalized) {
|
for (const msg of normalized) {
|
||||||
if (msg.kind !== 'tool_result') {
|
if (msg.kind !== 'tool_result') {
|
||||||
@@ -529,9 +529,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages: page,
|
||||||
total,
|
total,
|
||||||
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
|
hasMore,
|
||||||
offset: start,
|
offset: start,
|
||||||
limit: pageLimit,
|
limit: pageLimit,
|
||||||
tokenUsage: result.tokenUsage,
|
tokenUsage: result.tokenUsage,
|
||||||
|
|||||||
@@ -112,7 +112,21 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fallbackTitle = 'Untitled OpenCode Session';
|
const fallbackTitle = 'Untitled OpenCode Session';
|
||||||
const existingSession = sessionsDb.getSessionById(sessionId);
|
const pendingAppSession = sessionsDb.getSessionByProviderSessionId(sessionId)
|
||||||
|
?? sessionsDb.getSessionById(sessionId)
|
||||||
|
?? sessionsDb.findLatestPendingAppSession(this.provider, projectPath);
|
||||||
|
if (pendingAppSession && !pendingAppSession.provider_session_id) {
|
||||||
|
// Slow networks can let the sqlite watcher index opencode.db before the
|
||||||
|
// runtime reports its provider id back through the websocket mapping.
|
||||||
|
// Bind that id to the fresh app row first so the watcher does not create
|
||||||
|
// a temporary provider-id sidebar entry for the same session.
|
||||||
|
sessionsDb.assignProviderSessionId(pendingAppSession.session_id, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// App-created sessions are keyed by an app id, so disk-discovered provider
|
||||||
|
// ids must be resolved through the provider-id mapping first.
|
||||||
|
const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId)
|
||||||
|
?? sessionsDb.getSessionById(sessionId);
|
||||||
const existingName = existingSession?.custom_name;
|
const existingName = existingSession?.custom_name;
|
||||||
const nextName = existingName && existingName !== fallbackTitle
|
const nextName = existingName && existingName !== fallbackTitle
|
||||||
? existingName
|
? existingName
|
||||||
@@ -120,7 +134,9 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
|
|||||||
|
|
||||||
// OpenCode stores every session in one shared sqlite database, so jsonl_path
|
// OpenCode stores every session in one shared sqlite database, so jsonl_path
|
||||||
// must stay null to avoid deleting opencode.db when one app session is removed.
|
// must stay null to avoid deleting opencode.db when one app session is removed.
|
||||||
sessionsDb.createSession(
|
// Return the canonical stored row id so watcher-triggered sidebar updates
|
||||||
|
// stay on the app session once provider_session_id has already been mapped.
|
||||||
|
return sessionsDb.createSession(
|
||||||
sessionId,
|
sessionId,
|
||||||
this.provider,
|
this.provider,
|
||||||
projectPath,
|
projectPath,
|
||||||
@@ -129,8 +145,6 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
|
|||||||
normalizeProviderTimestamp(row.time_updated ?? row.time_created),
|
normalizeProviderTimestamp(row.time_updated ?? row.time_created),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
return sessionId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private readFirstUserText(db: Database.Database, sessionId: string): string | undefined {
|
private readFirstUserText(db: Database.Database, sessionId: string): string | undefined {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
readObjectRecord,
|
readObjectRecord,
|
||||||
readJsonRecord,
|
readJsonRecord,
|
||||||
readOptionalString,
|
readOptionalString,
|
||||||
|
sliceTailPage,
|
||||||
} from '@/shared/utils.js';
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
const PROVIDER = 'opencode';
|
const PROVIDER = 'opencode';
|
||||||
@@ -325,6 +326,9 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
|
|||||||
options: FetchHistoryOptions = {},
|
options: FetchHistoryOptions = {},
|
||||||
): Promise<FetchHistoryResult> {
|
): Promise<FetchHistoryResult> {
|
||||||
const { limit = null, offset = 0 } = options;
|
const { limit = null, offset = 0 } = options;
|
||||||
|
// OpenCode's shared sqlite database keys messages by the provider-native
|
||||||
|
// session id, not the app-facing id this method is addressed with.
|
||||||
|
const providerSessionId = options.providerSessionId ?? sessionId;
|
||||||
const db = openOpenCodeDatabase();
|
const db = openOpenCodeDatabase();
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||||
@@ -349,27 +353,20 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
|
|||||||
m.id,
|
m.id,
|
||||||
COALESCE(p.time_created, 0),
|
COALESCE(p.time_created, 0),
|
||||||
p.id
|
p.id
|
||||||
`).all(sessionId) as OpenCodeHistoryRow[];
|
`).all(providerSessionId) as OpenCodeHistoryRow[];
|
||||||
|
|
||||||
const normalized = this.normalizeHistoryRows(rows, sessionId);
|
const normalized = this.normalizeHistoryRows(rows, sessionId);
|
||||||
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId);
|
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, providerSessionId);
|
||||||
|
|
||||||
const normalizedOffset = Math.max(0, offset);
|
const normalizedOffset = Math.max(0, offset);
|
||||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||||
const total = normalized.length;
|
const total = normalized.length;
|
||||||
const messages = normalizedLimit === null
|
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
|
||||||
? normalized
|
|
||||||
: normalized.slice(
|
|
||||||
Math.max(0, total - normalizedOffset - normalizedLimit),
|
|
||||||
Math.max(0, total - normalizedOffset),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages: page,
|
||||||
total,
|
total,
|
||||||
hasMore: normalizedLimit === null
|
hasMore,
|
||||||
? false
|
|
||||||
: Math.max(0, total - normalizedOffset - normalizedLimit) > 0,
|
|
||||||
offset: normalizedOffset,
|
offset: normalizedOffset,
|
||||||
limit: normalizedLimit,
|
limit: normalizedLimit,
|
||||||
tokenUsage,
|
tokenUsage,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express, { type Request, type Response } from 'express';
|
import express, { type Request, type Response } from 'express';
|
||||||
|
|
||||||
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
||||||
|
import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js';
|
||||||
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
||||||
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
|
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
|
||||||
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
|
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
|
||||||
@@ -382,7 +383,51 @@ router.post(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/capabilities',
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
res.json(createApiSuccessResponse({
|
||||||
|
providers: providerCapabilitiesService.listAllProviderCapabilities(),
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:provider/capabilities',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const provider = parseProvider(req.params.provider);
|
||||||
|
res.json(createApiSuccessResponse(
|
||||||
|
providerCapabilitiesService.getProviderCapabilities(provider),
|
||||||
|
));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// ----------------- Session routes -----------------
|
// ----------------- Session routes -----------------
|
||||||
|
/**
|
||||||
|
* Session gateway entry point: allocates the stable app-facing session id for
|
||||||
|
* a brand-new chat. The frontend must call this before the first `chat.send`
|
||||||
|
* so the session id in the URL, the store, and the websocket all agree from
|
||||||
|
* the very first message — there is no client-visible session-id handoff.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/sessions',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
const provider = parseProvider(body.provider);
|
||||||
|
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : '';
|
||||||
|
const result = sessionsService.createAppSession(provider, projectPath);
|
||||||
|
res.status(201).json(createApiSuccessResponse(result));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/sessions/running',
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
const sessions = sessionsService.listRunningSessions();
|
||||||
|
res.json(createApiSuccessResponse({ sessions }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/sessions/archived',
|
'/sessions/archived',
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
@@ -459,7 +504,7 @@ router.get(
|
|||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
});
|
});
|
||||||
res.json(result);
|
res.json(createApiSuccessResponse(result));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -80,4 +80,30 @@ export const providerMcpService = {
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes one MCP server from every provider. Mirrors `addMcpServerToAllProviders`
|
||||||
|
* by iterating the live provider registry, so callers stay in sync with which
|
||||||
|
* providers exist instead of maintaining their own provider list.
|
||||||
|
*/
|
||||||
|
async removeMcpServerFromAllProviders(
|
||||||
|
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||||
|
): Promise<Array<{ provider: LLMProvider; removed: boolean; error?: string }>> {
|
||||||
|
const results: Array<{ provider: LLMProvider; removed: boolean; error?: string }> = [];
|
||||||
|
const providers = providerRegistry.listProviders();
|
||||||
|
for (const provider of providers) {
|
||||||
|
try {
|
||||||
|
const result = await provider.mcp.removeServer(input);
|
||||||
|
results.push({ provider: provider.id, removed: result.removed });
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
provider: provider.id,
|
||||||
|
removed: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { LLMProvider } from '@/shared/types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static, backend-owned description of what one provider integration supports.
|
||||||
|
*
|
||||||
|
* The frontend renders its composer UI (permission mode picker, image upload,
|
||||||
|
* abort button, ...) purely from this shape, which is what keeps the frontend
|
||||||
|
* free of per-provider conditionals. New provider features should be exposed
|
||||||
|
* here instead of branching on the provider id in React components.
|
||||||
|
*/
|
||||||
|
type ProviderCapabilities = {
|
||||||
|
provider: LLMProvider;
|
||||||
|
/** Permission modes the provider runtime understands, in cycle order. */
|
||||||
|
permissionModes: string[];
|
||||||
|
defaultPermissionMode: string;
|
||||||
|
/** Whether image attachments can be included in a chat.send. */
|
||||||
|
supportsImages: boolean;
|
||||||
|
/** Whether an in-flight run can be cancelled via chat.abort. */
|
||||||
|
supportsAbort: boolean;
|
||||||
|
/** Whether interactive tool permission prompts can reach the UI. */
|
||||||
|
supportsPermissionRequests: boolean;
|
||||||
|
/** Whether the token-usage endpoint has data for this provider. */
|
||||||
|
supportsTokenUsage: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The capability matrix mirrors what each runtime actually implements today:
|
||||||
|
* - permission modes match the option sets accepted by each CLI/SDK.
|
||||||
|
* - only the Claude SDK integration surfaces interactive permission requests.
|
||||||
|
* - Cursor has no token usage endpoint support (its store.db has no usage rows).
|
||||||
|
*/
|
||||||
|
const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||||
|
claude: {
|
||||||
|
provider: 'claude',
|
||||||
|
permissionModes: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||||
|
defaultPermissionMode: 'default',
|
||||||
|
supportsImages: true,
|
||||||
|
supportsAbort: true,
|
||||||
|
supportsPermissionRequests: true,
|
||||||
|
supportsTokenUsage: true,
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
provider: 'cursor',
|
||||||
|
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||||
|
defaultPermissionMode: 'default',
|
||||||
|
supportsImages: false,
|
||||||
|
supportsAbort: true,
|
||||||
|
supportsPermissionRequests: false,
|
||||||
|
supportsTokenUsage: false,
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
provider: 'codex',
|
||||||
|
permissionModes: ['default', 'acceptEdits', 'bypassPermissions'],
|
||||||
|
defaultPermissionMode: 'default',
|
||||||
|
supportsImages: false,
|
||||||
|
supportsAbort: true,
|
||||||
|
supportsPermissionRequests: false,
|
||||||
|
supportsTokenUsage: true,
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
provider: 'gemini',
|
||||||
|
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||||
|
defaultPermissionMode: 'default',
|
||||||
|
supportsImages: false,
|
||||||
|
supportsAbort: true,
|
||||||
|
supportsPermissionRequests: false,
|
||||||
|
supportsTokenUsage: true,
|
||||||
|
},
|
||||||
|
opencode: {
|
||||||
|
provider: 'opencode',
|
||||||
|
permissionModes: ['default'],
|
||||||
|
defaultPermissionMode: 'default',
|
||||||
|
supportsImages: false,
|
||||||
|
supportsAbort: true,
|
||||||
|
supportsPermissionRequests: false,
|
||||||
|
supportsTokenUsage: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application service exposing the provider capability matrix.
|
||||||
|
*/
|
||||||
|
export const providerCapabilitiesService = {
|
||||||
|
getProviderCapabilities(provider: LLMProvider): ProviderCapabilities {
|
||||||
|
return PROVIDER_CAPABILITIES[provider];
|
||||||
|
},
|
||||||
|
|
||||||
|
listAllProviderCapabilities(): ProviderCapabilities[] {
|
||||||
|
return Object.values(PROVIDER_CAPABILITIES);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -17,7 +17,7 @@ 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']);
|
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']);
|
||||||
|
|
||||||
type ProviderModelsServiceDependencies = {
|
type ProviderModelsServiceDependencies = {
|
||||||
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { promises as fsPromises } from 'node:fs';
|
|||||||
|
|
||||||
import chokidar, { type FSWatcher } from 'chokidar';
|
import chokidar, { type FSWatcher } from 'chokidar';
|
||||||
|
|
||||||
|
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||||
import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js';
|
import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js';
|
||||||
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
||||||
import type { LLMProvider } from '@/shared/types.js';
|
import type { LLMProvider } from '@/shared/types.js';
|
||||||
import { getProjectsWithSessions } from '@/modules/projects/index.js';
|
import { generateDisplayName } from '@/modules/projects/index.js';
|
||||||
|
|
||||||
type WatcherEventType = 'add' | 'change';
|
type WatcherEventType = 'add' | 'change';
|
||||||
|
|
||||||
@@ -58,6 +59,11 @@ const watchers: FSWatcher[] = [];
|
|||||||
type PendingWatcherUpdate = {
|
type PendingWatcherUpdate = {
|
||||||
providers: Set<LLMProvider>;
|
providers: Set<LLMProvider>;
|
||||||
changeTypes: Set<WatcherEventType>;
|
changeTypes: Set<WatcherEventType>;
|
||||||
|
/**
|
||||||
|
* Provider-native session ids reported by the synchronizers. They are
|
||||||
|
* translated back to app-facing session rows at flush time, because the
|
||||||
|
* transcript file names on disk only ever contain provider ids.
|
||||||
|
*/
|
||||||
updatedSessionIds: Set<string>;
|
updatedSessionIds: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,6 +137,50 @@ function queuePendingWatcherUpdate(
|
|||||||
schedulePendingWatcherFlush();
|
schedulePendingWatcherFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds one `session_upserted` delta event for a provider-native session id.
|
||||||
|
*
|
||||||
|
* The event carries everything a sidebar needs to upsert the session in place
|
||||||
|
* (session summary plus owning-project metadata), so clients never need a full
|
||||||
|
* project-list refetch when a transcript file changes on disk. Returns `null`
|
||||||
|
* when the id cannot be resolved to an indexed session row.
|
||||||
|
*/
|
||||||
|
async function buildSessionUpsertedEvent(updatedProviderSessionId: string): Promise<string | null> {
|
||||||
|
const row = sessionsDb.getSessionByProviderSessionId(updatedProviderSessionId)
|
||||||
|
?? sessionsDb.getSessionById(updatedProviderSessionId);
|
||||||
|
if (!row || row.isArchived) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectPath = row.project_path;
|
||||||
|
const project = projectPath ? projectsDb.getProjectPath(projectPath) : null;
|
||||||
|
const displayName = project?.custom_project_name?.trim()
|
||||||
|
? project.custom_project_name
|
||||||
|
: await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath);
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
kind: 'session_upserted',
|
||||||
|
sessionId: row.session_id,
|
||||||
|
provider: row.provider,
|
||||||
|
session: {
|
||||||
|
id: row.session_id,
|
||||||
|
summary: row.custom_name || '',
|
||||||
|
messageCount: 0,
|
||||||
|
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
||||||
|
},
|
||||||
|
project: project
|
||||||
|
? {
|
||||||
|
projectId: project.project_id,
|
||||||
|
path: project.project_path,
|
||||||
|
fullPath: project.project_path,
|
||||||
|
displayName,
|
||||||
|
isStarred: Boolean(project.isStarred),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function flushPendingWatcherUpdate(): Promise<void> {
|
async function flushPendingWatcherUpdate(): Promise<void> {
|
||||||
clearPendingWatcherFlushTimer();
|
clearPendingWatcherFlushTimer();
|
||||||
|
|
||||||
@@ -149,33 +199,29 @@ async function flushPendingWatcherUpdate(): Promise<void> {
|
|||||||
watcherRefreshInFlight = true;
|
watcherRefreshInFlight = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedProjects = await getProjectsWithSessions({ skipSynchronization: true });
|
// Per-session deltas instead of full project snapshots: an upsert of one
|
||||||
const changeTypes = Array.from(queuedUpdate.changeTypes);
|
// session can never clobber unrelated client state, so the frontend needs
|
||||||
const watchProviders = Array.from(queuedUpdate.providers);
|
// no "suppress updates while a run is active" protection logic.
|
||||||
const updatedSessionIds = Array.from(queuedUpdate.updatedSessionIds);
|
const events: string[] = [];
|
||||||
|
for (const updatedSessionId of queuedUpdate.updatedSessionIds) {
|
||||||
// Backward-compatible fields stay populated with the first queued values.
|
const event = await buildSessionUpsertedEvent(updatedSessionId);
|
||||||
const updateMessage = JSON.stringify({
|
if (event) {
|
||||||
type: 'projects_updated',
|
events.push(event);
|
||||||
projects: updatedProjects,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
changeType: changeTypes[0] ?? 'change',
|
|
||||||
updatedSessionId: updatedSessionIds[0] ?? undefined,
|
|
||||||
watchProvider: watchProviders[0] ?? undefined,
|
|
||||||
changeTypes,
|
|
||||||
updatedSessionIds,
|
|
||||||
watchProviders,
|
|
||||||
batched: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
connectedClients.forEach(client => {
|
|
||||||
if (client.readyState === WS_OPEN_STATE) {
|
|
||||||
client.send(updateMessage);
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
connectedClients.forEach(client => {
|
||||||
|
if (client.readyState === WS_OPEN_STATE) {
|
||||||
|
for (const event of events) {
|
||||||
|
client.send(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.error('Session watcher refresh failed while broadcasting projects_updated', { error: message });
|
console.error('Session watcher refresh failed while broadcasting session_upserted', { error: message });
|
||||||
} finally {
|
} finally {
|
||||||
watcherRefreshInFlight = false;
|
watcherRefreshInFlight = false;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import fsp from 'node:fs/promises';
|
import fsp from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import { chatRunRegistry } from '@/modules/websocket/index.js';
|
||||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||||
import type {
|
import type {
|
||||||
FetchHistoryOptions,
|
FetchHistoryOptions,
|
||||||
@@ -11,6 +13,12 @@ import type {
|
|||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
import { AppError } from '@/shared/utils.js';
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type CreateAppSessionResult = {
|
||||||
|
sessionId: string;
|
||||||
|
provider: LLMProvider;
|
||||||
|
projectPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ArchivedSessionListItem = {
|
type ArchivedSessionListItem = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
provider: LLMProvider;
|
provider: LLMProvider;
|
||||||
@@ -77,6 +85,21 @@ export const sessionsService = {
|
|||||||
return providerRegistry.listProviders().map((provider) => provider.id);
|
return providerRegistry.listProviders().map((provider) => provider.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns app-facing ids for provider runs that are currently processing.
|
||||||
|
*
|
||||||
|
* This is intentionally status-only: callers that only need sidebar activity
|
||||||
|
* indicators should not attach to chat streams or request replayed messages.
|
||||||
|
*/
|
||||||
|
listRunningSessions(): Array<{
|
||||||
|
sessionId: string;
|
||||||
|
provider: LLMProvider;
|
||||||
|
startedAt: number;
|
||||||
|
lastSeq: number;
|
||||||
|
}> {
|
||||||
|
return chatRunRegistry.listRunningRuns();
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes one provider-native event into frontend session message events.
|
* Normalizes one provider-native event into frontend session message events.
|
||||||
*/
|
*/
|
||||||
@@ -89,12 +112,43 @@ export const sessionsService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches persisted history by session id.
|
* Allocates a stable app-facing session id before any provider run happens.
|
||||||
|
*
|
||||||
|
* This is the entry point of the session gateway: the frontend calls this
|
||||||
|
* (via `POST /api/providers/sessions`) when the user starts a brand-new
|
||||||
|
* chat, navigates to the returned id immediately, and the id never changes
|
||||||
|
* for the lifetime of the conversation. The provider-native id is mapped to
|
||||||
|
* this row later, when the provider runtime announces it mid-run.
|
||||||
|
*/
|
||||||
|
createAppSession(provider: LLMProvider, projectPath: string): CreateAppSessionResult {
|
||||||
|
const normalizedProjectPath = projectPath.trim();
|
||||||
|
if (!normalizedProjectPath) {
|
||||||
|
throw new AppError('projectPath is required.', {
|
||||||
|
code: 'PROJECT_PATH_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
sessionsDb.createAppSession(sessionId, provider, normalizedProjectPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
provider,
|
||||||
|
projectPath: normalizedProjectPath,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches persisted history by app session id.
|
||||||
*
|
*
|
||||||
* Provider and provider-specific lookup hints are resolved from the indexed
|
* Provider and provider-specific lookup hints are resolved from the indexed
|
||||||
* session metadata in the database.
|
* session metadata in the database. The provider adapter receives the
|
||||||
|
* provider-native session id (the one written into transcripts on disk),
|
||||||
|
* and every returned message is remapped back to the app session id so
|
||||||
|
* provider ids never reach the frontend.
|
||||||
*/
|
*/
|
||||||
fetchHistory(
|
async fetchHistory(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
options: Pick<FetchHistoryOptions, 'limit' | 'offset'> = {},
|
options: Pick<FetchHistoryOptions, 'limit' | 'offset'> = {},
|
||||||
): Promise<FetchHistoryResult> {
|
): Promise<FetchHistoryResult> {
|
||||||
@@ -106,12 +160,33 @@ export const sessionsService = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App-created sessions that never produced a provider transcript yet
|
||||||
|
// (e.g. first message still streaming) simply have no history.
|
||||||
|
if (!session.provider_session_id) {
|
||||||
|
return {
|
||||||
|
messages: [],
|
||||||
|
total: 0,
|
||||||
|
hasMore: false,
|
||||||
|
offset: options.offset ?? 0,
|
||||||
|
limit: options.limit ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const provider = session.provider as LLMProvider;
|
const provider = session.provider as LLMProvider;
|
||||||
return providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
|
const result = await providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
|
||||||
limit: options.limit ?? null,
|
limit: options.limit ?? null,
|
||||||
offset: options.offset ?? 0,
|
offset: options.offset ?? 0,
|
||||||
projectPath: session.project_path ?? '',
|
projectPath: session.project_path ?? '',
|
||||||
|
providerSessionId: session.provider_session_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
messages: result.messages.map((message) => ({
|
||||||
|
...message,
|
||||||
|
sessionId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -272,6 +272,55 @@ test('OpenCode session synchronizer indexes sqlite sessions without deletable tr
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('OpenCode session synchronizer returns the app session id once provider mapping exists', { concurrency: false }, async () => {
|
||||||
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-mapped-'));
|
||||||
|
const workspacePath = path.join(tempRoot, 'workspace');
|
||||||
|
await mkdir(workspacePath, { recursive: true });
|
||||||
|
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createOpenCodeDatabase(tempRoot, workspacePath);
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-session-1', 'opencode', workspacePath);
|
||||||
|
sessionsDb.assignProviderSessionId('app-session-1', 'open-session-1');
|
||||||
|
|
||||||
|
const synchronizer = new OpenCodeSessionSynchronizer();
|
||||||
|
return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => {
|
||||||
|
assert.equal(sessionId, 'app-session-1');
|
||||||
|
assert.equal(sessionsDb.getAllSessions().length, 1);
|
||||||
|
assert.equal(sessionsDb.getSessionById('app-session-1')?.provider_session_id, 'open-session-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restoreHomeDir();
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OpenCode session synchronizer adopts the pending app session before watcher sync creates a duplicate', { concurrency: false }, async () => {
|
||||||
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-race-'));
|
||||||
|
const workspacePath = path.join(tempRoot, 'workspace');
|
||||||
|
await mkdir(workspacePath, { recursive: true });
|
||||||
|
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createOpenCodeDatabase(tempRoot, workspacePath);
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-session-race', 'opencode', workspacePath);
|
||||||
|
|
||||||
|
const synchronizer = new OpenCodeSessionSynchronizer();
|
||||||
|
return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => {
|
||||||
|
assert.equal(sessionId, 'app-session-race');
|
||||||
|
assert.equal(sessionsDb.getAllSessions().length, 1);
|
||||||
|
assert.equal(sessionsDb.getSessionById('app-session-race')?.provider_session_id, 'open-session-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restoreHomeDir();
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
|
test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
|
||||||
const provider = new OpenCodeSessionsProvider();
|
const provider = new OpenCodeSessionsProvider();
|
||||||
const normalized = provider.normalizeMessage({
|
const normalized = provider.normalizeMessage({
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ Benefits:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `services/websocket-server.service.ts` | Creates `WebSocketServer`, binds `verifyClient`, routes connection by pathname |
|
| `services/websocket-server.service.ts` | Creates `WebSocketServer`, binds `verifyClient`, routes connection by pathname |
|
||||||
| `services/websocket-auth.service.ts` | Authenticates upgrade requests and attaches `request.user` |
|
| `services/websocket-auth.service.ts` | Authenticates upgrade requests and attaches `request.user` |
|
||||||
| `services/chat-websocket.service.ts` | Handles `/ws` chat protocol and provider command/session control messages |
|
| `services/chat-websocket.service.ts` | Handles the `/ws` chat protocol (`chat.send` / `chat.abort` / `chat.subscribe` / `chat.permission-response`) |
|
||||||
|
| `services/chat-run-registry.service.ts` | Tracks live provider runs per app session id: seq numbering, event replay buffer, provider-id mapping, completion state |
|
||||||
|
| `services/chat-session-writer.service.ts` | Gateway writer handed to provider runtimes: remaps provider session ids to app ids, swallows `session_created`, assigns `seq` |
|
||||||
| `services/shell-websocket.service.ts` | Handles `/shell` PTY lifecycle, reconnect buffering, auth URL detection |
|
| `services/shell-websocket.service.ts` | Handles `/shell` PTY lifecycle, reconnect buffering, auth URL detection |
|
||||||
| `services/plugin-websocket-proxy.service.ts` | Bridges client socket to plugin socket |
|
| `services/plugin-websocket-proxy.service.ts` | Bridges client socket to plugin socket |
|
||||||
| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) |
|
| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) for non-chat writer consumers |
|
||||||
| `services/websocket-state.service.ts` | Holds shared chat client set and open-state constant |
|
| `services/websocket-state.service.ts` | Holds shared chat client set and open-state constant |
|
||||||
|
|
||||||
## High-Level Architecture
|
## High-Level Architecture
|
||||||
@@ -52,12 +54,12 @@ flowchart LR
|
|||||||
D -->|other| H[close()]
|
D -->|other| H[close()]
|
||||||
|
|
||||||
E --> I[connectedClients Set]
|
E --> I[connectedClients Set]
|
||||||
E --> J[WebSocketWriter]
|
E --> J[chatRunRegistry + ChatSessionWriter]
|
||||||
F --> K[ptySessionsMap]
|
F --> K[ptySessionsMap]
|
||||||
G --> L[Upstream Plugin ws://127.0.0.1:port/ws]
|
G --> L[Upstream Plugin ws://127.0.0.1:port/ws]
|
||||||
|
|
||||||
I --> M[projects.service broadcastProgress]
|
I --> M[projects.service loading_progress]
|
||||||
I --> N[sessions-watcher.service projects_updated]
|
I --> N[sessions-watcher.service session_upserted]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Connection Handshake + Routing
|
## Connection Handshake + Routing
|
||||||
@@ -105,37 +107,41 @@ sequenceDiagram
|
|||||||
When a chat socket connects:
|
When a chat socket connects:
|
||||||
|
|
||||||
1. Add socket to `connectedClients`.
|
1. Add socket to `connectedClients`.
|
||||||
2. Build `WebSocketWriter` (captures `userId` from authenticated request).
|
2. Parse each incoming message with `parseIncomingJsonObject`.
|
||||||
3. Parse each incoming message with `parseIncomingJsonObject`.
|
3. Dispatch by `data.type` (four message types, none provider-specific).
|
||||||
4. Dispatch by `data.type`.
|
4. On close, remove socket from `connectedClients`.
|
||||||
5. On close, remove socket from `connectedClients`.
|
|
||||||
|
### Session identity model
|
||||||
|
|
||||||
|
The frontend only ever knows the **app session id** (allocated by
|
||||||
|
`POST /api/providers/sessions` or discovered via the session index). The
|
||||||
|
provider-native id (JSONL file name, CLI resume id) stays inside the backend:
|
||||||
|
|
||||||
|
1. `chat.send` resolves the app id to `{ provider, provider_session_id, project_path }` from the sessions DB.
|
||||||
|
2. The provider runtime receives the provider-native id for resume.
|
||||||
|
3. The `ChatSessionWriter` remaps every outbound event back to the app id, and turns `session_created` announcements into a DB mapping update instead of forwarding them.
|
||||||
|
|
||||||
### Chat Message Dispatch
|
### Chat Message Dispatch
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A[Incoming WS message] --> B[parseIncomingJsonObject]
|
A[Incoming WS message] --> B[parseIncomingJsonObject]
|
||||||
B -->|invalid| C[send {type:error}]
|
B -->|invalid| C[send kind:protocol_error]
|
||||||
B -->|ok| D{data.type}
|
B -->|ok| D{data.type}
|
||||||
|
|
||||||
D -->|claude-command| E[queryClaudeSDK]
|
D -->|chat.send| E[resolve session row -> startRun -> spawnFns provider]
|
||||||
D -->|cursor-command| F[spawnCursor]
|
D -->|chat.abort| F[abortFns provider + synthetic complete]
|
||||||
D -->|codex-command| G[queryCodex]
|
D -->|chat.subscribe| G[chat_subscribed ack + attach socket + replay events seq > lastSeq]
|
||||||
D -->|gemini-command| H[spawnGemini]
|
D -->|chat.permission-response| H[resolveToolApproval]
|
||||||
D -->|cursor-resume| I[spawnCursor resume]
|
D -->|other| I[send kind:protocol_error]
|
||||||
D -->|abort-session| J[abort by provider]
|
|
||||||
D -->|claude-permission-response| K[resolveToolApproval]
|
|
||||||
D -->|cursor-abort| L[abortCursorSession]
|
|
||||||
D -->|check-session-status| M[is*SessionActive + optional reconnectSessionWriter]
|
|
||||||
D -->|get-pending-permissions| N[getPendingApprovalsForSession]
|
|
||||||
D -->|get-active-sessions| O[getActive*Sessions]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Chat Notes
|
### Chat Notes
|
||||||
|
|
||||||
1. `abort-session` returns a normalized `complete` message with `aborted: true`.
|
1. **Unified envelope**: every server-to-client frame carries a `kind` — either a provider `NormalizedMessage` kind or a gateway kind (`chat_subscribed`, `session_upserted`, `loading_progress`, `protocol_error`). There is no second `type`-based protocol.
|
||||||
2. `check-session-status` returns `{ type: "session-status", isProcessing }`.
|
2. **Unified terminal lifecycle**: every provider run ends with exactly one `complete` message built by `createCompleteMessage()` (`server/shared/utils.ts`): `{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }`. The chat handler emits a synthetic `complete` for runs that crash or get aborted, and the run registry drops duplicate completes.
|
||||||
3. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
|
3. **Per-run event log**: every live event gets a monotonically increasing `seq`. `chat.subscribe { sessions: [{ sessionId, lastSeq }] }` re-attaches the live stream to the requesting socket (any provider, not just Claude) and replays events with `seq > lastSeq`. If the buffer no longer covers `lastSeq`, the client refreshes over REST.
|
||||||
|
4. `chat_subscribed` includes `isProcessing` (replaces `check-session-status`) and `pendingPermissions` (replaces `get-pending-permissions`).
|
||||||
|
|
||||||
## `/shell` Terminal Flow
|
## `/shell` Terminal Flow
|
||||||
|
|
||||||
@@ -223,9 +229,9 @@ Only chat sockets (`/ws`) are tracked in `connectedClients`.
|
|||||||
That shared set is consumed by:
|
That shared set is consumed by:
|
||||||
|
|
||||||
1. `modules/projects/services/projects-with-sessions-fetch.service.ts`
|
1. `modules/projects/services/projects-with-sessions-fetch.service.ts`
|
||||||
Broadcasts `loading_progress` while project snapshots are being built.
|
Broadcasts `kind: loading_progress` while project snapshots are being built.
|
||||||
2. `modules/providers/services/sessions-watcher.service.ts`
|
2. `modules/providers/services/sessions-watcher.service.ts`
|
||||||
Broadcasts `projects_updated` when provider session artifacts change.
|
Broadcasts per-session `kind: session_upserted` deltas when provider session artifacts change (no full project snapshots).
|
||||||
|
|
||||||
This design centralizes cross-module realtime fanout without requiring route-local references to WebSocket internals.
|
This design centralizes cross-module realtime fanout without requiring route-local references to WebSocket internals.
|
||||||
|
|
||||||
@@ -252,7 +258,7 @@ Current explicit close codes in this module:
|
|||||||
|
|
||||||
Other errors:
|
Other errors:
|
||||||
|
|
||||||
1. Chat handler catches and emits `{ type: "error", error }`.
|
1. Chat handler catches and emits `{ kind: "protocol_error", code, error }`.
|
||||||
2. Shell handler catches and writes terminal-visible error output.
|
2. Shell handler catches and writes terminal-visible error output.
|
||||||
3. Unknown websocket paths are closed immediately.
|
3. Unknown websocket paths are closed immediately.
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { WS_OPEN_STATE, connectedClients } from './services/websocket-state.service.js';
|
export { WS_OPEN_STATE, connectedClients } from './services/websocket-state.service.js';
|
||||||
export { createWebSocketServer } from './services/websocket-server.service.js';
|
export { createWebSocketServer } from './services/websocket-server.service.js';
|
||||||
|
export { chatRunRegistry } from './services/chat-run-registry.service.js';
|
||||||
|
|||||||
327
server/modules/websocket/services/chat-run-registry.service.ts
Normal file
327
server/modules/websocket/services/chat-run-registry.service.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import { generateDisplayName } from '@/modules/projects/index.js';
|
||||||
|
import { ChatSessionWriter } from '@/modules/websocket/services/chat-session-writer.service.js';
|
||||||
|
import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
|
||||||
|
import type {
|
||||||
|
LLMProvider,
|
||||||
|
NormalizedMessage,
|
||||||
|
RealtimeClientConnection,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
|
||||||
|
type ChatRunStatus = 'running' | 'completed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One live (or recently finished) provider run for a single app session.
|
||||||
|
*
|
||||||
|
* State notes — why each mutable field is essential:
|
||||||
|
* - `providerSessionId`: the provider-native id captured mid-run. The abort
|
||||||
|
* handler needs it to address the provider runtime, and the DB mapping is
|
||||||
|
* written from it so history/resume work after the run.
|
||||||
|
* - `status`: drives `chat_subscribed.isProcessing`, prevents double sends
|
||||||
|
* into the same session, and guards the synthetic-complete fallback in the
|
||||||
|
* chat handler (only emitted when a runtime died without completing).
|
||||||
|
* - `lastSeq` / `events`: the per-run event log. Every live event gets a
|
||||||
|
* monotonically increasing `seq` and is buffered so a reconnecting client
|
||||||
|
* can replay exactly the events it missed via `chat.subscribe`.
|
||||||
|
*/
|
||||||
|
type ChatRun = {
|
||||||
|
appSessionId: string;
|
||||||
|
provider: LLMProvider;
|
||||||
|
providerSessionId: string | null;
|
||||||
|
status: ChatRunStatus;
|
||||||
|
lastSeq: number;
|
||||||
|
events: NormalizedMessage[];
|
||||||
|
writer: ChatSessionWriter;
|
||||||
|
startedAt: number;
|
||||||
|
completedAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a completed run stays available for replay. Covers the window
|
||||||
|
* between a run finishing and the client refreshing history over REST (for
|
||||||
|
* example when the browser tab was asleep while the run completed).
|
||||||
|
*/
|
||||||
|
const COMPLETED_RUN_RETENTION_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on buffered events per run so a very long tool-heavy run cannot
|
||||||
|
* grow memory unbounded. When exceeded, the oldest events are dropped —
|
||||||
|
* a reconnecting client whose `lastSeq` predates the buffer falls back to a
|
||||||
|
* REST history refresh, which is always the authoritative source.
|
||||||
|
*/
|
||||||
|
const MAX_BUFFERED_EVENTS_PER_RUN = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active and recently-completed runs keyed by app session id.
|
||||||
|
*
|
||||||
|
* This map is the single in-memory source of truth for "is something running
|
||||||
|
* for this session" — the chat websocket handler, abort path, and subscribe
|
||||||
|
* path all consult it instead of asking each provider runtime individually.
|
||||||
|
*/
|
||||||
|
const runs = new Map<string, ChatRun>();
|
||||||
|
|
||||||
|
async function broadcastCanonicalSessionUpsert(appSessionId: string): Promise<void> {
|
||||||
|
const row = sessionsDb.getSessionById(appSessionId);
|
||||||
|
if (!row || row.isArchived) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectPath = row.project_path;
|
||||||
|
const project = projectPath ? projectsDb.getProjectPath(projectPath) : null;
|
||||||
|
const displayName = project?.custom_project_name?.trim()
|
||||||
|
? project.custom_project_name
|
||||||
|
: await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath);
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
kind: 'session_upserted',
|
||||||
|
sessionId: row.session_id,
|
||||||
|
providerSessionId: row.provider_session_id,
|
||||||
|
provider: row.provider,
|
||||||
|
session: {
|
||||||
|
id: row.session_id,
|
||||||
|
summary: row.custom_name || '',
|
||||||
|
messageCount: 0,
|
||||||
|
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
||||||
|
},
|
||||||
|
project: project
|
||||||
|
? {
|
||||||
|
projectId: project.project_id,
|
||||||
|
path: project.project_path,
|
||||||
|
fullPath: project.project_path,
|
||||||
|
displayName,
|
||||||
|
isStarred: Boolean(project.isStarred),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
connectedClients.forEach((client) => {
|
||||||
|
if (client.readyState === WS_OPEN_STATE) {
|
||||||
|
client.send(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function evictRunLater(appSessionId: string): void {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const run = runs.get(appSessionId);
|
||||||
|
if (run && run.status === 'completed') {
|
||||||
|
runs.delete(appSessionId);
|
||||||
|
}
|
||||||
|
}, COMPLETED_RUN_RETENTION_MS);
|
||||||
|
|
||||||
|
// Never keep the process alive just to evict a buffered run.
|
||||||
|
timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorates one outbound live event for a run and records it in the event log.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* 1. Remap `sessionId` (and `actualSessionId` on `complete`) to the stable
|
||||||
|
* app session id — provider-native ids never leave the backend.
|
||||||
|
* 2. Assign the next `seq` so clients can detect/replay gaps.
|
||||||
|
* 3. Buffer the event for `chat.subscribe` replay.
|
||||||
|
* 4. Flip the run to `completed` when the terminal `complete` event passes by.
|
||||||
|
*/
|
||||||
|
function decorateAndRecordEvent(run: ChatRun, message: NormalizedMessage): NormalizedMessage | null {
|
||||||
|
// Exactly-one-complete contract: when a run is aborted the chat handler
|
||||||
|
// emits the terminal `complete` immediately, but the killed runtime may
|
||||||
|
// still emit its own `complete` from its exit handler moments later.
|
||||||
|
// Whichever arrives first wins; the duplicate is dropped here.
|
||||||
|
if (message.kind === 'complete' && run.status === 'completed') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
run.lastSeq += 1;
|
||||||
|
|
||||||
|
const outbound: NormalizedMessage = {
|
||||||
|
...message,
|
||||||
|
sessionId: run.appSessionId,
|
||||||
|
seq: run.lastSeq,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (message.kind === 'complete') {
|
||||||
|
// The provider may report its own id here; the frontend only ever knows
|
||||||
|
// the app id, so the "actual" id is by definition the app id as well.
|
||||||
|
outbound.actualSessionId = run.appSessionId;
|
||||||
|
run.status = 'completed';
|
||||||
|
run.completedAt = Date.now();
|
||||||
|
evictRunLater(run.appSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
run.events.push(outbound);
|
||||||
|
if (run.events.length > MAX_BUFFERED_EVENTS_PER_RUN) {
|
||||||
|
run.events.splice(0, run.events.length - MAX_BUFFERED_EVENTS_PER_RUN);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outbound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the provider-native session id for a run and persists the
|
||||||
|
* app-id-to-provider-id mapping so history fetches and future resumes can
|
||||||
|
* address the provider transcript.
|
||||||
|
*
|
||||||
|
* Called from the gateway writer when the runtime either calls
|
||||||
|
* `setSessionId(...)` or emits its `session_created` event — whichever
|
||||||
|
* happens first wins; later calls with the same id are no-ops.
|
||||||
|
*/
|
||||||
|
function recordProviderSessionId(run: ChatRun, providerSessionId: string): void {
|
||||||
|
if (!providerSessionId || run.providerSessionId === providerSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
run.providerSessionId = providerSessionId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionsDb.assignProviderSessionId(run.appSessionId, providerSessionId);
|
||||||
|
void broadcastCanonicalSessionUpsert(run.appSessionId).catch((error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[ChatRunRegistry] Failed to broadcast canonical session mapping', {
|
||||||
|
appSessionId: run.appSessionId,
|
||||||
|
providerSessionId,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[ChatRunRegistry] Failed to persist provider session id mapping', {
|
||||||
|
appSessionId: run.appSessionId,
|
||||||
|
providerSessionId,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of live provider runs keyed by the stable app session id.
|
||||||
|
*
|
||||||
|
* The registry is what makes the websocket protocol provider-independent:
|
||||||
|
* every run gets a `ChatSessionWriter` that remaps provider-native session
|
||||||
|
* ids to the app id, assigns `seq` numbers, and buffers events for replay —
|
||||||
|
* regardless of which provider runtime produced them.
|
||||||
|
*/
|
||||||
|
export const chatRunRegistry = {
|
||||||
|
/**
|
||||||
|
* Starts tracking a run and returns it, or `null` when a run is already in
|
||||||
|
* progress for the session (callers must reject the duplicate send).
|
||||||
|
*/
|
||||||
|
startRun(input: {
|
||||||
|
appSessionId: string;
|
||||||
|
provider: LLMProvider;
|
||||||
|
providerSessionId: string | null;
|
||||||
|
connection: RealtimeClientConnection;
|
||||||
|
userId: string | number | null;
|
||||||
|
}): ChatRun | null {
|
||||||
|
const existing = runs.get(input.appSessionId);
|
||||||
|
if (existing && existing.status === 'running') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run: ChatRun = {
|
||||||
|
appSessionId: input.appSessionId,
|
||||||
|
provider: input.provider,
|
||||||
|
providerSessionId: input.providerSessionId,
|
||||||
|
status: 'running',
|
||||||
|
lastSeq: 0,
|
||||||
|
events: [],
|
||||||
|
writer: null as unknown as ChatSessionWriter,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
run.writer = new ChatSessionWriter({
|
||||||
|
connection: input.connection,
|
||||||
|
userId: input.userId,
|
||||||
|
provider: input.provider,
|
||||||
|
providerSessionId: input.providerSessionId,
|
||||||
|
onProviderSessionId: (providerSessionId) => {
|
||||||
|
recordProviderSessionId(run, providerSessionId);
|
||||||
|
},
|
||||||
|
decorateOutboundEvent: (message) => decorateAndRecordEvent(run, message),
|
||||||
|
});
|
||||||
|
|
||||||
|
runs.set(input.appSessionId, run);
|
||||||
|
return run;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRun(appSessionId: string): ChatRun | undefined {
|
||||||
|
return runs.get(appSessionId);
|
||||||
|
},
|
||||||
|
|
||||||
|
isProcessing(appSessionId: string): boolean {
|
||||||
|
return runs.get(appSessionId)?.status === 'running';
|
||||||
|
},
|
||||||
|
|
||||||
|
listRunningRuns(): Array<{
|
||||||
|
sessionId: string;
|
||||||
|
provider: LLMProvider;
|
||||||
|
startedAt: number;
|
||||||
|
lastSeq: number;
|
||||||
|
}> {
|
||||||
|
return Array.from(runs.values())
|
||||||
|
.filter((run) => run.status === 'running')
|
||||||
|
.map((run) => ({
|
||||||
|
sessionId: run.appSessionId,
|
||||||
|
provider: run.provider,
|
||||||
|
startedAt: run.startedAt,
|
||||||
|
lastSeq: run.lastSeq,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-attaches a run's outbound stream to a (new) websocket connection.
|
||||||
|
*
|
||||||
|
* This is the generic replacement for the Claude-only writer reconnect:
|
||||||
|
* after a page refresh the new socket subscribes and immediately starts
|
||||||
|
* receiving the still-running stream, for every provider.
|
||||||
|
*/
|
||||||
|
attachConnection(appSessionId: string, connection: RealtimeClientConnection): boolean {
|
||||||
|
const run = runs.get(appSessionId);
|
||||||
|
if (!run) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
run.writer.updateWebSocket(connection);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns buffered events with `seq` greater than `afterSeq` for replay.
|
||||||
|
*
|
||||||
|
* An empty array with `run.lastSeq > afterSeq` not covered by the buffer
|
||||||
|
* means the buffer was truncated; the client should refresh over REST.
|
||||||
|
*/
|
||||||
|
replayEvents(appSessionId: string, afterSeq: number): NormalizedMessage[] {
|
||||||
|
const run = runs.get(appSessionId);
|
||||||
|
if (!run) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return run.events.filter((event) => typeof event.seq === 'number' && event.seq > afterSeq);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits a synthetic terminal `complete` if (and only if) the run is still
|
||||||
|
* marked running. Used when a provider runtime throws or resolves without
|
||||||
|
* having produced its own terminal event, and by the abort path.
|
||||||
|
*/
|
||||||
|
completeRun(appSessionId: string, opts: { exitCode: number; aborted?: boolean }): void {
|
||||||
|
const run = runs.get(appSessionId);
|
||||||
|
if (!run || run.status !== 'running') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
run.writer.sendComplete(opts);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only escape hatch: clears every tracked run.
|
||||||
|
*/
|
||||||
|
clearAll(): void {
|
||||||
|
runs.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
145
server/modules/websocket/services/chat-session-writer.service.ts
Normal file
145
server/modules/websocket/services/chat-session-writer.service.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
|
||||||
|
import type {
|
||||||
|
LLMProvider,
|
||||||
|
NormalizedMessage,
|
||||||
|
RealtimeClientConnection,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
import { createCompleteMessage, readObjectRecord } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type ChatSessionWriterOptions = {
|
||||||
|
connection: RealtimeClientConnection;
|
||||||
|
userId: string | number | null;
|
||||||
|
provider: LLMProvider;
|
||||||
|
/** Provider-native id when resuming an existing session, otherwise null. */
|
||||||
|
providerSessionId: string | null;
|
||||||
|
/**
|
||||||
|
* Invoked the moment the provider runtime reveals its native session id
|
||||||
|
* (either via `setSessionId` or a `session_created` event). The registry
|
||||||
|
* persists the app-id-to-provider-id mapping from this callback.
|
||||||
|
*/
|
||||||
|
onProviderSessionId: (providerSessionId: string) => void;
|
||||||
|
/**
|
||||||
|
* Remaps/sequences/buffers one outbound live event. Implemented by the chat
|
||||||
|
* run registry; the writer never forwards a provider event untouched.
|
||||||
|
* Returns `null` when the event must be dropped (duplicate terminal
|
||||||
|
* `complete` after an abort already completed the run).
|
||||||
|
*/
|
||||||
|
decorateOutboundEvent: (message: NormalizedMessage) => NormalizedMessage | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway writer handed to provider runtimes instead of a raw websocket writer.
|
||||||
|
*
|
||||||
|
* It exposes the exact same surface as `WebSocketWriter` (`send`,
|
||||||
|
* `setSessionId`, `getSessionId`, `updateWebSocket`, `userId`,
|
||||||
|
* `isWebSocketWriter`) so the provider runtimes (`claude-sdk.js`,
|
||||||
|
* `cursor-cli.js`, ...) need zero changes — but everything that flows through
|
||||||
|
* it is translated from the provider's world into the app's protocol:
|
||||||
|
*
|
||||||
|
* - `session_created` events are swallowed and turned into a provider-id
|
||||||
|
* mapping; the frontend never learns provider-native ids.
|
||||||
|
* - every other event gets `sessionId` remapped to the app session id and a
|
||||||
|
* per-run `seq` assigned before being forwarded.
|
||||||
|
* - `setSessionId(...)` calls (used by runtimes to label captured ids) are
|
||||||
|
* intercepted and recorded as the provider-id mapping as well.
|
||||||
|
*/
|
||||||
|
export class ChatSessionWriter {
|
||||||
|
ws: RealtimeClientConnection;
|
||||||
|
userId: string | number | null;
|
||||||
|
/**
|
||||||
|
* Some runtimes feature-detect their writer with this flag; keep it so the
|
||||||
|
* gateway writer is a drop-in replacement for `WebSocketWriter`.
|
||||||
|
*/
|
||||||
|
isWebSocketWriter = true;
|
||||||
|
|
||||||
|
private readonly options: ChatSessionWriterOptions;
|
||||||
|
/**
|
||||||
|
* The provider-native session id as the runtime knows it. Kept locally
|
||||||
|
* (besides the registry) because runtimes read it back via `getSessionId()`
|
||||||
|
* to label their own outgoing events — those labels are remapped on send
|
||||||
|
* anyway, but the runtime-visible value must stay provider-native.
|
||||||
|
*/
|
||||||
|
private providerSessionId: string | null;
|
||||||
|
|
||||||
|
constructor(options: ChatSessionWriterOptions) {
|
||||||
|
this.options = options;
|
||||||
|
this.ws = options.connection;
|
||||||
|
this.userId = options.userId;
|
||||||
|
this.providerSessionId = options.providerSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: unknown): void {
|
||||||
|
const record = readObjectRecord(data);
|
||||||
|
if (!record || typeof record.kind !== 'string') {
|
||||||
|
// Provider runtimes only emit kind-based normalized messages. Anything
|
||||||
|
// else indicates a programming error; drop it rather than leaking an
|
||||||
|
// un-remapped payload to the client.
|
||||||
|
console.error('[ChatSessionWriter] Dropping non-normalized outbound payload', data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = record as NormalizedMessage;
|
||||||
|
|
||||||
|
if (message.kind === 'session_created') {
|
||||||
|
const announcedId =
|
||||||
|
typeof message.newSessionId === 'string' && message.newSessionId
|
||||||
|
? message.newSessionId
|
||||||
|
: message.sessionId;
|
||||||
|
if (announcedId) {
|
||||||
|
this.captureProviderSessionId(announcedId);
|
||||||
|
}
|
||||||
|
// Swallowed on purpose: the frontend already has the stable app session
|
||||||
|
// id, so there is no client-side handoff to perform anymore.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outbound = this.options.decorateOutboundEvent(message);
|
||||||
|
if (outbound) {
|
||||||
|
this.forward(outbound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the synthetic terminal `complete` for runs that ended without one
|
||||||
|
* (runtime crash before completing, or user abort).
|
||||||
|
*/
|
||||||
|
sendComplete(opts: { exitCode: number; aborted?: boolean }): void {
|
||||||
|
const message = createCompleteMessage({
|
||||||
|
provider: this.options.provider,
|
||||||
|
sessionId: this.providerSessionId,
|
||||||
|
exitCode: opts.exitCode,
|
||||||
|
aborted: opts.aborted,
|
||||||
|
});
|
||||||
|
const outbound = this.options.decorateOutboundEvent(message);
|
||||||
|
if (outbound) {
|
||||||
|
this.forward(outbound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWebSocket(newConnection: RealtimeClientConnection): void {
|
||||||
|
this.ws = newConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionId(sessionId: string): void {
|
||||||
|
this.captureProviderSessionId(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionId(): string | null {
|
||||||
|
return this.providerSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private captureProviderSessionId(providerSessionId: string): void {
|
||||||
|
if (!providerSessionId || this.providerSessionId === providerSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.providerSessionId = providerSessionId;
|
||||||
|
this.options.onProviderSessionId(providerSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private forward(message: NormalizedMessage): void {
|
||||||
|
if (this.ws.readyState === WS_OPEN_STATE) {
|
||||||
|
this.ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,35 @@
|
|||||||
import type { WebSocket } from 'ws';
|
import type { WebSocket } from 'ws';
|
||||||
|
|
||||||
import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js';
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
import { WebSocketWriter } from '@/modules/websocket/services/websocket-writer.service.js';
|
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
|
||||||
|
import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
|
||||||
import type {
|
import type {
|
||||||
AnyRecord,
|
AnyRecord,
|
||||||
AuthenticatedWebSocketRequest,
|
AuthenticatedWebSocketRequest,
|
||||||
LLMProvider,
|
LLMProvider,
|
||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js';
|
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
||||||
|
|
||||||
type ChatIncomingMessage = AnyRecord & {
|
/**
|
||||||
type?: string;
|
* One provider runtime entry point. All five runtimes share this signature,
|
||||||
command?: string;
|
* which lets the chat handler dispatch through a provider-keyed map instead
|
||||||
options?: AnyRecord;
|
* of provider-specific branches.
|
||||||
provider?: string;
|
*/
|
||||||
sessionId?: string;
|
type ProviderSpawnFn = (
|
||||||
requestId?: string;
|
command: string,
|
||||||
allow?: unknown;
|
options: AnyRecord,
|
||||||
updatedInput?: unknown;
|
writer: unknown
|
||||||
message?: unknown;
|
) => Promise<unknown>;
|
||||||
rememberEntry?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_PROVIDER: LLMProvider = 'claude';
|
|
||||||
|
|
||||||
type ChatWebSocketDependencies = {
|
type ChatWebSocketDependencies = {
|
||||||
queryClaudeSDK: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
/** Provider runtimes keyed by provider id. */
|
||||||
spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
spawnFns: Record<LLMProvider, ProviderSpawnFn>;
|
||||||
queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
/**
|
||||||
spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
* Abort functions keyed by provider id. They are addressed with the
|
||||||
spawnOpenCode: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
* provider-native session id (that is how runtimes key their process maps).
|
||||||
abortClaudeSDKSession: (sessionId: string) => Promise<boolean>;
|
* The Claude abort is async; the rest are sync — both shapes are accepted.
|
||||||
abortCursorSession: (sessionId: string) => boolean;
|
*/
|
||||||
abortCodexSession: (sessionId: string) => boolean;
|
abortFns: Record<LLMProvider, (providerSessionId: string) => boolean | Promise<boolean>>;
|
||||||
abortGeminiSession: (sessionId: string) => boolean;
|
|
||||||
abortOpenCodeSession: (sessionId: string) => boolean;
|
|
||||||
resolveToolApproval: (
|
resolveToolApproval: (
|
||||||
requestId: string,
|
requestId: string,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -44,31 +39,10 @@ type ChatWebSocketDependencies = {
|
|||||||
rememberEntry?: unknown;
|
rememberEntry?: unknown;
|
||||||
}
|
}
|
||||||
) => void;
|
) => void;
|
||||||
isClaudeSDKSessionActive: (sessionId: string) => boolean;
|
/** Claude-only today: pending tool approvals included in `chat_subscribed`. */
|
||||||
isCursorSessionActive: (sessionId: string) => boolean;
|
getPendingApprovalsForSession: (providerSessionId: string) => unknown[];
|
||||||
isCodexSessionActive: (sessionId: string) => boolean;
|
|
||||||
isGeminiSessionActive: (sessionId: string) => boolean;
|
|
||||||
isOpenCodeSessionActive: (sessionId: string) => boolean;
|
|
||||||
reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean;
|
|
||||||
getPendingApprovalsForSession: (sessionId: string) => unknown[];
|
|
||||||
getActiveClaudeSDKSessions: () => unknown;
|
|
||||||
getActiveCursorSessions: () => unknown;
|
|
||||||
getActiveCodexSessions: () => unknown;
|
|
||||||
getActiveGeminiSessions: () => unknown;
|
|
||||||
getActiveOpenCodeSessions: () => unknown;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes potentially invalid provider names coming from websocket payloads.
|
|
||||||
*/
|
|
||||||
function readProvider(value: unknown): LLMProvider {
|
|
||||||
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini' || value === 'opencode') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEFAULT_PROVIDER;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the authenticated request user id in the formats currently produced
|
* Extracts the authenticated request user id in the formats currently produced
|
||||||
* by platform and OSS auth code paths.
|
* by platform and OSS auth code paths.
|
||||||
@@ -92,8 +66,258 @@ function readRequestUserId(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendJson(ws: WebSocket, payload: unknown): void {
|
||||||
|
if (ws.readyState === WS_OPEN_STATE) {
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports a protocol-level failure to the requesting client.
|
||||||
|
*
|
||||||
|
* Protocol errors deliberately use their own `kind` (instead of the provider
|
||||||
|
* `error` message kind) so the frontend can distinguish "your request was
|
||||||
|
* invalid" from "the model run produced an error" without inspecting text.
|
||||||
|
*/
|
||||||
|
function sendProtocolError(
|
||||||
|
ws: WebSocket,
|
||||||
|
code: string,
|
||||||
|
error: string,
|
||||||
|
sessionId?: string
|
||||||
|
): void {
|
||||||
|
sendJson(ws, {
|
||||||
|
kind: 'protocol_error',
|
||||||
|
code,
|
||||||
|
error,
|
||||||
|
sessionId: sessionId ?? null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequiredSessionId(data: AnyRecord): string | null {
|
||||||
|
const sessionId = typeof data.sessionId === 'string' ? data.sessionId.trim() : '';
|
||||||
|
return sessionId.length > 0 ? sessionId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles `chat.send`: resolves the session row (provider, project path, and
|
||||||
|
* provider-native id all come from the database — never from the client),
|
||||||
|
* registers the run, and dispatches to the provider runtime.
|
||||||
|
*/
|
||||||
|
async function handleChatSend(
|
||||||
|
ws: WebSocket,
|
||||||
|
userId: string | number | null,
|
||||||
|
data: AnyRecord,
|
||||||
|
dependencies: ChatWebSocketDependencies
|
||||||
|
): Promise<void> {
|
||||||
|
const sessionId = readRequiredSessionId(data);
|
||||||
|
if (!sessionId) {
|
||||||
|
sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.send requires a sessionId.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sessionsDb.getSessionById(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
sendProtocolError(
|
||||||
|
ws,
|
||||||
|
'SESSION_NOT_FOUND',
|
||||||
|
`Session "${sessionId}" was not found. Create it via POST /api/providers/sessions first.`,
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = session.provider as LLMProvider;
|
||||||
|
const spawnFn = dependencies.spawnFns[provider];
|
||||||
|
if (!spawnFn) {
|
||||||
|
sendProtocolError(ws, 'UNSUPPORTED_PROVIDER', `Provider "${provider}" is not available.`, sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = chatRunRegistry.startRun({
|
||||||
|
appSessionId: sessionId,
|
||||||
|
provider,
|
||||||
|
providerSessionId: session.provider_session_id,
|
||||||
|
connection: ws,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!run) {
|
||||||
|
sendProtocolError(
|
||||||
|
ws,
|
||||||
|
'RUN_IN_PROGRESS',
|
||||||
|
`Session "${sessionId}" already has a run in progress.`,
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientOptions = (data.options ?? {}) as AnyRecord;
|
||||||
|
const command = typeof data.content === 'string' ? data.content : '';
|
||||||
|
|
||||||
|
// The provider runtimes receive the provider-native session id (that is the
|
||||||
|
// id their CLI/SDK understands for resume). Brand-new sessions have no
|
||||||
|
// provider id yet, so the runtime starts fresh and announces one, which the
|
||||||
|
// gateway writer captures and maps back to the app session id.
|
||||||
|
const runtimeOptions: AnyRecord = {
|
||||||
|
...clientOptions,
|
||||||
|
sessionId: session.provider_session_id ?? undefined,
|
||||||
|
resume: Boolean(session.provider_session_id),
|
||||||
|
cwd: clientOptions.cwd ?? session.project_path ?? undefined,
|
||||||
|
projectPath: session.project_path ?? clientOptions.projectPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await spawnFn(command, runtimeOptions, run.writer);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[Chat] Provider runtime "${provider}" failed`, { sessionId, error: message });
|
||||||
|
} finally {
|
||||||
|
// Safety net: a runtime that crashed (or resolved) without emitting its
|
||||||
|
// terminal `complete` would otherwise leave the session stuck in
|
||||||
|
// "processing" forever on every connected client.
|
||||||
|
chatRunRegistry.completeRun(sessionId, { exitCode: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles `chat.abort`: cancels the run for one app session and emits the
|
||||||
|
* terminal `complete` on its behalf (runtimes skip their own complete for
|
||||||
|
* aborted runs, and the registry drops any duplicate).
|
||||||
|
*/
|
||||||
|
async function handleChatAbort(
|
||||||
|
ws: WebSocket,
|
||||||
|
data: AnyRecord,
|
||||||
|
dependencies: ChatWebSocketDependencies
|
||||||
|
): Promise<void> {
|
||||||
|
const sessionId = readRequiredSessionId(data);
|
||||||
|
if (!sessionId) {
|
||||||
|
sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.abort requires a sessionId.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = chatRunRegistry.getRun(sessionId);
|
||||||
|
if (!run || run.status !== 'running') {
|
||||||
|
sendProtocolError(ws, 'NO_ACTIVE_RUN', `Session "${sessionId}" has no active run.`, sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortFn = dependencies.abortFns[run.provider];
|
||||||
|
let success = false;
|
||||||
|
if (abortFn && run.providerSessionId) {
|
||||||
|
success = Boolean(await abortFn(run.providerSessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
chatRunRegistry.completeRun(sessionId, {
|
||||||
|
exitCode: success ? 0 : 1,
|
||||||
|
aborted: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles `chat.subscribe`: for each requested session, reports whether a run
|
||||||
|
* is processing, re-attaches the live stream to this socket, replays missed
|
||||||
|
* events (seq > lastSeq), and includes pending permission requests.
|
||||||
|
*
|
||||||
|
* This single message replaces the old `check-session-status`,
|
||||||
|
* `get-pending-permissions`, and Claude-only writer reconnect flows.
|
||||||
|
*/
|
||||||
|
function handleChatSubscribe(
|
||||||
|
ws: WebSocket,
|
||||||
|
data: AnyRecord,
|
||||||
|
dependencies: ChatWebSocketDependencies
|
||||||
|
): void {
|
||||||
|
const targets = Array.isArray(data.sessions) ? data.sessions : [];
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!target || typeof target !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = typeof (target as AnyRecord).sessionId === 'string'
|
||||||
|
? ((target as AnyRecord).sessionId as string).trim()
|
||||||
|
: '';
|
||||||
|
if (!sessionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSeqRaw = (target as AnyRecord).lastSeq;
|
||||||
|
const lastSeq = typeof lastSeqRaw === 'number' && Number.isFinite(lastSeqRaw)
|
||||||
|
? Math.max(0, Math.floor(lastSeqRaw))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const run = chatRunRegistry.getRun(sessionId);
|
||||||
|
const isProcessing = chatRunRegistry.isProcessing(sessionId);
|
||||||
|
|
||||||
|
// Future live events for this run should land on the socket that asked —
|
||||||
|
// this is what makes mid-stream page refreshes work for all providers.
|
||||||
|
if (isProcessing) {
|
||||||
|
chatRunRegistry.attachConnection(sessionId, ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending approvals are tracked under the provider-native id inside the
|
||||||
|
// Claude runtime; remap their sessionId so the client only sees app ids.
|
||||||
|
const pendingPermissions = (run?.providerSessionId
|
||||||
|
? dependencies.getPendingApprovalsForSession(run.providerSessionId)
|
||||||
|
: []
|
||||||
|
).map((approval) =>
|
||||||
|
approval && typeof approval === 'object'
|
||||||
|
? { ...(approval as AnyRecord), sessionId }
|
||||||
|
: approval,
|
||||||
|
);
|
||||||
|
|
||||||
|
sendJson(ws, {
|
||||||
|
kind: 'chat_subscribed',
|
||||||
|
sessionId,
|
||||||
|
isProcessing,
|
||||||
|
lastSeq: run?.lastSeq ?? 0,
|
||||||
|
pendingPermissions,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replay only for RUNNING runs, strictly after the ack. Completed runs
|
||||||
|
// are fully persisted to the provider transcript and served over REST —
|
||||||
|
// replaying them (e.g. after a page reload where the client's lastSeq is
|
||||||
|
// 0) would duplicate messages the history fetch already returned.
|
||||||
|
if (isProcessing) {
|
||||||
|
for (const event of chatRunRegistry.replayEvents(sessionId, lastSeq)) {
|
||||||
|
sendJson(ws, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles `chat.permission-response`: forwards a tool-approval decision to the
|
||||||
|
* pending approval resolver (Claude is the only provider with interactive
|
||||||
|
* approvals today, but the message is intentionally provider-neutral).
|
||||||
|
*/
|
||||||
|
function handlePermissionResponse(data: AnyRecord, dependencies: ChatWebSocketDependencies): void {
|
||||||
|
if (typeof data.requestId !== 'string' || data.requestId.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies.resolveToolApproval(data.requestId, {
|
||||||
|
allow: Boolean(data.allow),
|
||||||
|
updatedInput: data.updatedInput,
|
||||||
|
message: typeof data.message === 'string' ? data.message : undefined,
|
||||||
|
rememberEntry: data.rememberEntry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles authenticated chat websocket messages used by the main chat panel.
|
* Handles authenticated chat websocket messages used by the main chat panel.
|
||||||
|
*
|
||||||
|
* Inbound protocol (client to server):
|
||||||
|
* - `chat.send` { sessionId, content, options? }
|
||||||
|
* - `chat.abort` { sessionId }
|
||||||
|
* - `chat.subscribe` { sessions: [{ sessionId, lastSeq? }] }
|
||||||
|
* - `chat.permission-response` { requestId, allow, updatedInput?, message?, rememberEntry? }
|
||||||
|
*
|
||||||
|
* Outbound protocol (server to client): every frame is `kind`-based — either
|
||||||
|
* a provider `NormalizedMessage` (with `seq`) or a gateway event
|
||||||
|
* (`chat_subscribed`, `session_upserted`, `loading_progress`,
|
||||||
|
* `protocol_error`).
|
||||||
*/
|
*/
|
||||||
export function handleChatConnection(
|
export function handleChatConnection(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
@@ -103,7 +327,7 @@ export function handleChatConnection(
|
|||||||
console.log('[INFO] Chat WebSocket connected');
|
console.log('[INFO] Chat WebSocket connected');
|
||||||
connectedClients.add(ws);
|
connectedClients.add(ws);
|
||||||
|
|
||||||
const writer = new WebSocketWriter(ws, readRequestUserId(request));
|
const userId = readRequestUserId(request);
|
||||||
|
|
||||||
ws.on('message', async (rawMessage) => {
|
ws.on('message', async (rawMessage) => {
|
||||||
try {
|
try {
|
||||||
@@ -112,169 +336,30 @@ export function handleChatConnection(
|
|||||||
throw new Error('Invalid websocket payload');
|
throw new Error('Invalid websocket payload');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = parsed as ChatIncomingMessage;
|
const data = parsed as AnyRecord;
|
||||||
const messageType = data.type;
|
const messageType = typeof data.type === 'string' ? data.type : '';
|
||||||
if (!messageType) {
|
|
||||||
throw new Error('Message type is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'claude-command') {
|
switch (messageType) {
|
||||||
await dependencies.queryClaudeSDK(data.command ?? '', data.options, writer);
|
case 'chat.send':
|
||||||
return;
|
await handleChatSend(ws, userId, data, dependencies);
|
||||||
}
|
return;
|
||||||
|
case 'chat.abort':
|
||||||
if (messageType === 'cursor-command') {
|
await handleChatAbort(ws, data, dependencies);
|
||||||
await dependencies.spawnCursor(data.command ?? '', data.options, writer);
|
return;
|
||||||
return;
|
case 'chat.subscribe':
|
||||||
}
|
handleChatSubscribe(ws, data, dependencies);
|
||||||
|
return;
|
||||||
if (messageType === 'codex-command') {
|
case 'chat.permission-response':
|
||||||
await dependencies.queryCodex(data.command ?? '', data.options, writer);
|
handlePermissionResponse(data, dependencies);
|
||||||
return;
|
return;
|
||||||
}
|
default:
|
||||||
|
sendProtocolError(ws, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type "${messageType}".`);
|
||||||
if (messageType === 'gemini-command') {
|
return;
|
||||||
await dependencies.spawnGemini(data.command ?? '', data.options, writer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'opencode-command') {
|
|
||||||
await dependencies.spawnOpenCode(data.command ?? '', data.options, writer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'cursor-resume') {
|
|
||||||
await dependencies.spawnCursor(
|
|
||||||
'',
|
|
||||||
{
|
|
||||||
sessionId: data.sessionId,
|
|
||||||
resume: true,
|
|
||||||
cwd: data.options?.cwd,
|
|
||||||
},
|
|
||||||
writer
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'abort-session') {
|
|
||||||
const provider = readProvider(data.provider);
|
|
||||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
|
||||||
let success = false;
|
|
||||||
|
|
||||||
if (provider === 'cursor') {
|
|
||||||
success = dependencies.abortCursorSession(sessionId);
|
|
||||||
} else if (provider === 'codex') {
|
|
||||||
success = dependencies.abortCodexSession(sessionId);
|
|
||||||
} else if (provider === 'gemini') {
|
|
||||||
success = dependencies.abortGeminiSession(sessionId);
|
|
||||||
} else if (provider === 'opencode') {
|
|
||||||
success = dependencies.abortOpenCodeSession(sessionId);
|
|
||||||
} else {
|
|
||||||
success = await dependencies.abortClaudeSDKSession(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.send(
|
|
||||||
createNormalizedMessage({
|
|
||||||
kind: 'complete',
|
|
||||||
exitCode: success ? 0 : 1,
|
|
||||||
aborted: true,
|
|
||||||
success,
|
|
||||||
sessionId,
|
|
||||||
provider,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'claude-permission-response') {
|
|
||||||
if (typeof data.requestId === 'string' && data.requestId.length > 0) {
|
|
||||||
dependencies.resolveToolApproval(data.requestId, {
|
|
||||||
allow: Boolean(data.allow),
|
|
||||||
updatedInput: data.updatedInput,
|
|
||||||
message: typeof data.message === 'string' ? data.message : undefined,
|
|
||||||
rememberEntry: data.rememberEntry,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'cursor-abort') {
|
|
||||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
|
||||||
const success = dependencies.abortCursorSession(sessionId);
|
|
||||||
writer.send(
|
|
||||||
createNormalizedMessage({
|
|
||||||
kind: 'complete',
|
|
||||||
exitCode: success ? 0 : 1,
|
|
||||||
aborted: true,
|
|
||||||
success,
|
|
||||||
sessionId,
|
|
||||||
provider: 'cursor',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'check-session-status') {
|
|
||||||
const provider = readProvider(data.provider);
|
|
||||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
|
||||||
let isActive = false;
|
|
||||||
|
|
||||||
if (provider === 'cursor') {
|
|
||||||
isActive = dependencies.isCursorSessionActive(sessionId);
|
|
||||||
} else if (provider === 'codex') {
|
|
||||||
isActive = dependencies.isCodexSessionActive(sessionId);
|
|
||||||
} else if (provider === 'gemini') {
|
|
||||||
isActive = dependencies.isGeminiSessionActive(sessionId);
|
|
||||||
} else if (provider === 'opencode') {
|
|
||||||
isActive = dependencies.isOpenCodeSessionActive(sessionId);
|
|
||||||
} else {
|
|
||||||
isActive = dependencies.isClaudeSDKSessionActive(sessionId);
|
|
||||||
if (isActive) {
|
|
||||||
dependencies.reconnectSessionWriter(sessionId, ws);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.send({
|
|
||||||
type: 'session-status',
|
|
||||||
sessionId,
|
|
||||||
provider,
|
|
||||||
isProcessing: isActive,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'get-pending-permissions') {
|
|
||||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
|
||||||
if (sessionId && dependencies.isClaudeSDKSessionActive(sessionId)) {
|
|
||||||
const pending = dependencies.getPendingApprovalsForSession(sessionId);
|
|
||||||
writer.send({
|
|
||||||
type: 'pending-permissions-response',
|
|
||||||
sessionId,
|
|
||||||
data: pending,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'get-active-sessions') {
|
|
||||||
writer.send({
|
|
||||||
type: 'active-sessions',
|
|
||||||
sessions: {
|
|
||||||
claude: dependencies.getActiveClaudeSDKSessions(),
|
|
||||||
cursor: dependencies.getActiveCursorSessions(),
|
|
||||||
codex: dependencies.getActiveCodexSessions(),
|
|
||||||
gemini: dependencies.getActiveGeminiSessions(),
|
|
||||||
opencode: dependencies.getActiveOpenCodeSessions(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.error('[ERROR] Chat WebSocket error:', message);
|
console.error('[ERROR] Chat WebSocket error:', message);
|
||||||
writer.send({
|
sendProtocolError(ws, 'INTERNAL_ERROR', message);
|
||||||
type: 'error',
|
|
||||||
error: message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
|
|||||||
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
|
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
|
||||||
|
|
||||||
type ShellWebSocketDependencies = {
|
type ShellWebSocketDependencies = {
|
||||||
getSessionById: (sessionId: string) => { cliSessionId?: string } | null | undefined;
|
resolveProviderSessionId: (
|
||||||
|
sessionId: string,
|
||||||
|
provider: string,
|
||||||
|
) => string | null | undefined;
|
||||||
stripAnsiSequences: (content: string) => string;
|
stripAnsiSequences: (content: string) => string;
|
||||||
normalizeDetectedUrl: (url: string) => string | null;
|
normalizeDetectedUrl: (url: string) => string | null;
|
||||||
extractUrlsFromText: (content: string) => string[];
|
extractUrlsFromText: (content: string) => string[];
|
||||||
@@ -76,6 +79,36 @@ function parseShellMessage(rawMessage: RawData): ShellIncomingMessage | null {
|
|||||||
return payload as ShellIncomingMessage;
|
return payload as ShellIncomingMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9_.\-:]+$/;
|
||||||
|
|
||||||
|
function resolveResumeSessionId(
|
||||||
|
message: ShellIncomingMessage,
|
||||||
|
dependencies: ShellWebSocketDependencies
|
||||||
|
): string {
|
||||||
|
const hasSession = readBoolean(message.hasSession);
|
||||||
|
const sessionId = readString(message.sessionId);
|
||||||
|
const provider = readString(message.provider, 'claude');
|
||||||
|
|
||||||
|
if (!hasSession || !sessionId) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let resumeSessionId: string | null | undefined;
|
||||||
|
try {
|
||||||
|
resumeSessionId = dependencies.resolveProviderSessionId(sessionId, provider);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to resolve provider session ID:', error);
|
||||||
|
resumeSessionId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedSessionId = resumeSessionId === undefined ? sessionId : resumeSessionId;
|
||||||
|
if (!resolvedSessionId || !SAFE_SESSION_ID_PATTERN.test(resolvedSessionId)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves provider command line for plain shell and agent-backed shell modes.
|
* Resolves provider command line for plain shell and agent-backed shell modes.
|
||||||
*/
|
*/
|
||||||
@@ -84,10 +117,9 @@ function buildShellCommand(
|
|||||||
dependencies: ShellWebSocketDependencies
|
dependencies: ShellWebSocketDependencies
|
||||||
): string {
|
): string {
|
||||||
const hasSession = readBoolean(message.hasSession);
|
const hasSession = readBoolean(message.hasSession);
|
||||||
const sessionId = readString(message.sessionId);
|
|
||||||
const initialCommand = readString(message.initialCommand);
|
const initialCommand = readString(message.initialCommand);
|
||||||
const provider = readString(message.provider, 'claude');
|
const provider = readString(message.provider, 'claude');
|
||||||
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
|
const resumeSessionId = resolveResumeSessionId(message, dependencies);
|
||||||
const isPlainShell =
|
const isPlainShell =
|
||||||
readBoolean(message.isPlainShell) ||
|
readBoolean(message.isPlainShell) ||
|
||||||
(!!initialCommand && !hasSession) ||
|
(!!initialCommand && !hasSession) ||
|
||||||
@@ -98,58 +130,43 @@ function buildShellCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'cursor') {
|
if (provider === 'cursor') {
|
||||||
if (hasSession && sessionId) {
|
if (resumeSessionId) {
|
||||||
return `cursor-agent --resume="${sessionId}"`;
|
return `cursor-agent --resume="${resumeSessionId}"`;
|
||||||
}
|
}
|
||||||
return 'cursor-agent';
|
return 'cursor-agent';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'codex') {
|
if (provider === 'codex') {
|
||||||
if (hasSession && sessionId) {
|
if (resumeSessionId) {
|
||||||
if (os.platform() === 'win32') {
|
if (os.platform() === 'win32') {
|
||||||
return `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
return `codex resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
||||||
}
|
}
|
||||||
return `codex resume "${sessionId}" || codex`;
|
return `codex resume "${resumeSessionId}" || codex`;
|
||||||
}
|
}
|
||||||
return 'codex';
|
return 'codex';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'gemini') {
|
if (provider === 'gemini') {
|
||||||
const command = initialCommand || 'gemini';
|
const command = initialCommand || 'gemini';
|
||||||
let resumeId = sessionId;
|
if (resumeSessionId) {
|
||||||
if (hasSession && sessionId) {
|
return `${command} --resume "${resumeSessionId}"`;
|
||||||
try {
|
|
||||||
const existingSession = dependencies.getSessionById(sessionId);
|
|
||||||
if (existingSession && existingSession.cliSessionId) {
|
|
||||||
resumeId = existingSession.cliSessionId;
|
|
||||||
if (!safeSessionIdPattern.test(resumeId)) {
|
|
||||||
resumeId = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get Gemini CLI session ID:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasSession && resumeId) {
|
|
||||||
return `${command} --resume "${resumeId}"`;
|
|
||||||
}
|
}
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'opencode') {
|
if (provider === 'opencode') {
|
||||||
if (hasSession && sessionId) {
|
if (resumeSessionId) {
|
||||||
return `opencode --session "${sessionId}"`;
|
return `opencode --session "${resumeSessionId}"`;
|
||||||
}
|
}
|
||||||
return initialCommand || 'opencode';
|
return initialCommand || 'opencode';
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = initialCommand || 'claude';
|
const command = initialCommand || 'claude';
|
||||||
if (hasSession && sessionId) {
|
if (resumeSessionId) {
|
||||||
if (os.platform() === 'win32') {
|
if (os.platform() === 'win32') {
|
||||||
return `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
return `claude --resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
||||||
}
|
}
|
||||||
return `claude --resume "${sessionId}" || claude`;
|
return `claude --resume "${resumeSessionId}" || claude`;
|
||||||
}
|
}
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
@@ -261,6 +278,7 @@ export function handleShellConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shellCommand = buildShellCommand(data, dependencies);
|
const shellCommand = buildShellCommand(data, dependencies);
|
||||||
|
const resumeSessionId = resolveResumeSessionId(data, dependencies);
|
||||||
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
||||||
const shellArgs =
|
const shellArgs =
|
||||||
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
||||||
@@ -406,8 +424,8 @@ export function handleShellConnection(
|
|||||||
: provider === 'opencode'
|
: provider === 'opencode'
|
||||||
? 'OpenCode'
|
? 'OpenCode'
|
||||||
: 'Claude';
|
: 'Claude';
|
||||||
welcomeMsg = hasSession
|
welcomeMsg = hasSession && resumeSessionId
|
||||||
? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n`
|
? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n`
|
||||||
: `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
: `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
244
server/modules/websocket/tests/chat-run-registry.test.ts
Normal file
244
server/modules/websocket/tests/chat-run-registry.test.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
|
||||||
|
import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal stand-in for a websocket connection: collects every JSON frame the
|
||||||
|
* gateway writer forwards so assertions can inspect the outbound protocol.
|
||||||
|
*/
|
||||||
|
class FakeConnection {
|
||||||
|
readyState = 1; // WS_OPEN_STATE
|
||||||
|
frames: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
send(data: string): void {
|
||||||
|
this.frames.push(JSON.parse(data) as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||||
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
|
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'chat-run-registry-'));
|
||||||
|
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||||
|
|
||||||
|
closeConnection();
|
||||||
|
process.env.DATABASE_PATH = databasePath;
|
||||||
|
await initializeDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runTest();
|
||||||
|
} finally {
|
||||||
|
connectedClients.clear();
|
||||||
|
chatRunRegistry.clearAll();
|
||||||
|
closeConnection();
|
||||||
|
if (previousDatabasePath === undefined) {
|
||||||
|
delete process.env.DATABASE_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.DATABASE_PATH = previousDatabasePath;
|
||||||
|
}
|
||||||
|
await rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('live events are remapped to the app session id and sequenced', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-run-1', 'claude', '/workspace/demo');
|
||||||
|
const connection = new FakeConnection();
|
||||||
|
const run = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-1',
|
||||||
|
provider: 'claude',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
assert.ok(run);
|
||||||
|
|
||||||
|
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'provider-id-9', content: 'hello' });
|
||||||
|
run.writer.send({ kind: 'text', provider: 'claude', sessionId: 'provider-id-9', content: 'hello world' });
|
||||||
|
|
||||||
|
assert.equal(connection.frames.length, 2);
|
||||||
|
assert.equal(connection.frames[0]?.sessionId, 'app-run-1');
|
||||||
|
assert.equal(connection.frames[0]?.seq, 1);
|
||||||
|
assert.equal(connection.frames[1]?.sessionId, 'app-run-1');
|
||||||
|
assert.equal(connection.frames[1]?.seq, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session_created is swallowed and persisted as the provider-id mapping', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo');
|
||||||
|
const connection = new FakeConnection();
|
||||||
|
connectedClients.add(connection as never);
|
||||||
|
const run = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-2',
|
||||||
|
provider: 'cursor',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(run);
|
||||||
|
|
||||||
|
run.writer.send({
|
||||||
|
kind: 'session_created',
|
||||||
|
provider: 'cursor',
|
||||||
|
sessionId: 'cursor-native-7',
|
||||||
|
newSessionId: 'cursor-native-7',
|
||||||
|
});
|
||||||
|
|
||||||
|
// The provider-native event itself is never forwarded...
|
||||||
|
const sessionUpserts = connection.frames.filter((frame) => frame.kind === 'session_upserted');
|
||||||
|
assert.equal(sessionUpserts.length, 1);
|
||||||
|
assert.equal(sessionUpserts[0]?.sessionId, 'app-run-2');
|
||||||
|
assert.equal(sessionUpserts[0]?.providerSessionId, 'cursor-native-7');
|
||||||
|
// ...but the canonical mapping is recorded and persisted in the database.
|
||||||
|
assert.equal(run.providerSessionId, 'cursor-native-7');
|
||||||
|
assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('complete marks the run finished and duplicate completes are dropped', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-run-3', 'codex', '/workspace/demo');
|
||||||
|
const connection = new FakeConnection();
|
||||||
|
const run = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-3',
|
||||||
|
provider: 'codex',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(run);
|
||||||
|
|
||||||
|
run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 0 });
|
||||||
|
// Late duplicate from a killed runtime's exit handler.
|
||||||
|
run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 1 });
|
||||||
|
|
||||||
|
const completes = connection.frames.filter((frame) => frame.kind === 'complete');
|
||||||
|
assert.equal(completes.length, 1);
|
||||||
|
assert.equal(completes[0]?.actualSessionId, 'app-run-3');
|
||||||
|
assert.equal(chatRunRegistry.isProcessing('app-run-3'), false);
|
||||||
|
|
||||||
|
// completeRun is also a no-op once the run already completed.
|
||||||
|
chatRunRegistry.completeRun('app-run-3', { exitCode: 1 });
|
||||||
|
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listRunningRuns returns only currently running app sessions', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo');
|
||||||
|
sessionsDb.createAppSession('app-run-8', 'codex', '/workspace/demo');
|
||||||
|
const connection = new FakeConnection();
|
||||||
|
|
||||||
|
const completedRun = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-7',
|
||||||
|
provider: 'claude',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(completedRun);
|
||||||
|
|
||||||
|
const runningRun = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-8',
|
||||||
|
provider: 'codex',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(runningRun);
|
||||||
|
|
||||||
|
chatRunRegistry.completeRun('app-run-7', { exitCode: 0 });
|
||||||
|
|
||||||
|
const runningSessions = chatRunRegistry.listRunningRuns();
|
||||||
|
assert.deepEqual(runningSessions.map((session) => session.sessionId), ['app-run-8']);
|
||||||
|
assert.equal(runningSessions[0]?.provider, 'codex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replayEvents returns only events after the requested seq', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-run-4', 'claude', '/workspace/demo');
|
||||||
|
const connection = new FakeConnection();
|
||||||
|
const run = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-4',
|
||||||
|
provider: 'claude',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(run);
|
||||||
|
|
||||||
|
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'a' });
|
||||||
|
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'b' });
|
||||||
|
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'c' });
|
||||||
|
|
||||||
|
const replayed = chatRunRegistry.replayEvents('app-run-4', 1);
|
||||||
|
assert.deepEqual(replayed.map((event) => event.content), ['b', 'c']);
|
||||||
|
assert.deepEqual(replayed.map((event) => event.seq), [2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attachConnection reroutes the live stream to a new socket', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-run-5', 'gemini', '/workspace/demo');
|
||||||
|
const firstConnection = new FakeConnection();
|
||||||
|
const run = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-5',
|
||||||
|
provider: 'gemini',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection: firstConnection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(run);
|
||||||
|
|
||||||
|
run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'before' });
|
||||||
|
|
||||||
|
const secondConnection = new FakeConnection();
|
||||||
|
assert.equal(chatRunRegistry.attachConnection('app-run-5', secondConnection), true);
|
||||||
|
run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'after' });
|
||||||
|
|
||||||
|
assert.deepEqual(firstConnection.frames.map((frame) => frame.content), ['before']);
|
||||||
|
assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startRun rejects a second concurrent run for the same session', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-run-6', 'opencode', '/workspace/demo');
|
||||||
|
const connection = new FakeConnection();
|
||||||
|
const first = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-6',
|
||||||
|
provider: 'opencode',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(first);
|
||||||
|
|
||||||
|
const second = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-6',
|
||||||
|
provider: 'opencode',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.equal(second, null);
|
||||||
|
|
||||||
|
// After the run finishes a new one is allowed again.
|
||||||
|
chatRunRegistry.completeRun('app-run-6', { exitCode: 0 });
|
||||||
|
const third = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-6',
|
||||||
|
provider: 'opencode',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(third);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
|
|||||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
import { 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 { createNormalizedMessage } from './shared/utils.js';
|
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
// Track active sessions
|
// Track active sessions
|
||||||
const activeCodexSessions = new Map();
|
const activeCodexSessions = new Map();
|
||||||
@@ -352,21 +352,26 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send completion event
|
// Send the terminal completion event — skipped for aborted runs, whose
|
||||||
if (!terminalFailure) {
|
// terminal `complete` (aborted: true) was already sent by abort-session.
|
||||||
sendMessage(ws, createNormalizedMessage({
|
const runSession = capturedSessionId ? activeCodexSessions.get(capturedSessionId) : null;
|
||||||
kind: 'complete',
|
const runAborted = runSession?.status === 'aborted' || abortController.signal.aborted;
|
||||||
actualSessionId: capturedSessionId || thread.id || sessionId || null,
|
if (!runAborted) {
|
||||||
sessionId: capturedSessionId || sessionId || null,
|
sendMessage(ws, createCompleteMessage({
|
||||||
provider: 'codex'
|
|
||||||
}));
|
|
||||||
notifyRunStopped({
|
|
||||||
userId: ws?.userId || null,
|
|
||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
sessionId: capturedSessionId || sessionId || null,
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
sessionName: sessionSummary,
|
actualSessionId: capturedSessionId || thread.id || sessionId || null,
|
||||||
stopReason: 'completed'
|
exitCode: terminalFailure ? 1 : 0,
|
||||||
});
|
}));
|
||||||
|
if (!terminalFailure) {
|
||||||
|
notifyRunStopped({
|
||||||
|
userId: ws?.userId || null,
|
||||||
|
provider: 'codex',
|
||||||
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
|
sessionName: sessionSummary,
|
||||||
|
stopReason: 'completed'
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -386,6 +391,11 @@ 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 { createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
|
import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
|
||||||
|
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
|
|
||||||
@@ -92,6 +92,9 @@ 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) {
|
||||||
@@ -191,6 +194,10 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|||||||
|
|
||||||
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
|
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
|
||||||
const args = ['run', '--format', 'json'];
|
const args = ['run', '--format', 'json'];
|
||||||
|
// OpenCode's `run` command owns workspace selection through `--dir`.
|
||||||
|
// Relying on the child-process cwd alone is not enough on Linux, where
|
||||||
|
// the CLI can still resolve the session under the server install dir.
|
||||||
|
args.push('--dir', workingDir);
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
args.push('--session', sessionId);
|
args.push('--session', sessionId);
|
||||||
}
|
}
|
||||||
@@ -256,13 +263,12 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.send(createNormalizedMessage({
|
// Terminal complete — skipped for aborted runs (abort-session
|
||||||
kind: 'complete',
|
// already sent the aborted complete on this run's behalf).
|
||||||
exitCode: code,
|
if (!completeSent && !opencodeProcess.aborted) {
|
||||||
isNewSession: !sessionId && !!command,
|
completeSent = true;
|
||||||
sessionId: finalSessionId,
|
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: code }));
|
||||||
provider: 'opencode',
|
}
|
||||||
}));
|
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
notifyTerminalState({ code });
|
notifyTerminalState({ code });
|
||||||
@@ -302,6 +308,10 @@ 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);
|
||||||
});
|
});
|
||||||
@@ -315,6 +325,9 @@ 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;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
@@ -12,6 +12,11 @@ const findEnvKey = (name) =>
|
|||||||
async function createFakeOpenCodeExecutable(binDir) {
|
async function createFakeOpenCodeExecutable(binDir) {
|
||||||
const scriptPath = path.join(binDir, 'opencode.js');
|
const scriptPath = path.join(binDir, 'opencode.js');
|
||||||
await writeFile(scriptPath, `
|
await writeFile(scriptPath, `
|
||||||
|
const capturePath = process.env.OPENCODE_ARGS_CAPTURE;
|
||||||
|
if (capturePath) {
|
||||||
|
require('node:fs').writeFileSync(capturePath, JSON.stringify(process.argv.slice(2)));
|
||||||
|
}
|
||||||
|
|
||||||
const events = [
|
const events = [
|
||||||
{ type: 'text', sessionID: 'open-live-1', text: 'assistant response' },
|
{ type: 'text', sessionID: 'open-live-1', text: 'assistant response' },
|
||||||
{ type: 'step_finish', sessionID: 'open-live-1' },
|
{ type: 'step_finish', sessionID: 'open-live-1' },
|
||||||
@@ -35,10 +40,12 @@ for (const event of events) {
|
|||||||
|
|
||||||
test('spawnOpenCode emits session_created before normalized live messages for new sessions', async () => {
|
test('spawnOpenCode emits session_created before normalized live messages for new sessions', async () => {
|
||||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-live-'));
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-live-'));
|
||||||
|
const argsCapturePath = path.join(tempRoot, 'opencode-args.json');
|
||||||
const pathKey = findEnvKey('PATH');
|
const pathKey = findEnvKey('PATH');
|
||||||
const pathExtKey = findEnvKey('PATHEXT');
|
const pathExtKey = findEnvKey('PATHEXT');
|
||||||
const previousPath = process.env[pathKey];
|
const previousPath = process.env[pathKey];
|
||||||
const previousPathExt = process.env[pathExtKey];
|
const previousPathExt = process.env[pathExtKey];
|
||||||
|
const previousArgsCapture = process.env.OPENCODE_ARGS_CAPTURE;
|
||||||
const messages = [];
|
const messages = [];
|
||||||
const writer = {
|
const writer = {
|
||||||
userId: null,
|
userId: null,
|
||||||
@@ -54,6 +61,7 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
|
|||||||
try {
|
try {
|
||||||
await createFakeOpenCodeExecutable(tempRoot);
|
await createFakeOpenCodeExecutable(tempRoot);
|
||||||
process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`;
|
process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`;
|
||||||
|
process.env.OPENCODE_ARGS_CAPTURE = argsCapturePath;
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD')
|
process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD')
|
||||||
? previousPathExt
|
? previousPathExt
|
||||||
@@ -77,6 +85,11 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
|
|||||||
assert.equal(streamEnd?.sessionId, 'open-live-1');
|
assert.equal(streamEnd?.sessionId, 'open-live-1');
|
||||||
assert.equal(complete?.sessionId, 'open-live-1');
|
assert.equal(complete?.sessionId, 'open-live-1');
|
||||||
assert.equal(messages.some((message) => message.kind === 'error'), false);
|
assert.equal(messages.some((message) => message.kind === 'error'), false);
|
||||||
|
|
||||||
|
const launchedArgs = JSON.parse(await readFile(argsCapturePath, 'utf8'));
|
||||||
|
assert.ok(Array.isArray(launchedArgs));
|
||||||
|
assert.deepEqual(launchedArgs.slice(0, 4), ['run', '--format', 'json', '--dir']);
|
||||||
|
assert.equal(launchedArgs[4], tempRoot);
|
||||||
} finally {
|
} finally {
|
||||||
if (previousPath === undefined) {
|
if (previousPath === undefined) {
|
||||||
delete process.env[pathKey];
|
delete process.env[pathKey];
|
||||||
@@ -90,6 +103,12 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
|
|||||||
process.env[pathExtKey] = previousPathExt;
|
process.env[pathExtKey] = previousPathExt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (previousArgsCapture === undefined) {
|
||||||
|
delete process.env.OPENCODE_ARGS_CAPTURE;
|
||||||
|
} else {
|
||||||
|
process.env.OPENCODE_ARGS_CAPTURE = previousArgsCapture;
|
||||||
|
}
|
||||||
|
|
||||||
await rm(tempRoot, { recursive: true, force: true });
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -646,7 +646,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]'
|
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable'
|
||||||
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
|
* 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',
|
||||||
|
|||||||
@@ -98,6 +98,44 @@ function normalizeSessionName(sessionName) {
|
|||||||
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
|
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rowMatchesProvider(row, provider) {
|
||||||
|
return row && (!provider || row.provider === provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSessionRow(sessionId, provider) {
|
||||||
|
if (!sessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appSessionRow = sessionsDb.getSessionById(sessionId);
|
||||||
|
if (rowMatchesProvider(appSessionRow, provider)) {
|
||||||
|
return appSessionRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId);
|
||||||
|
if (rowMatchesProvider(providerSessionRow, provider)) {
|
||||||
|
return providerSessionRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNotificationSession(event) {
|
||||||
|
if (!event?.sessionId || !event.provider || event.provider === 'system') {
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = resolveSessionRow(event.sessionId, event.provider);
|
||||||
|
if (!row || row.session_id === event.sessionId) {
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
sessionId: row.session_id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSessionName(event) {
|
function resolveSessionName(event) {
|
||||||
const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
|
const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
|
||||||
if (explicitSessionName) {
|
if (explicitSessionName) {
|
||||||
@@ -112,28 +150,29 @@ function resolveSessionName(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPushBody(event) {
|
function buildPushBody(event) {
|
||||||
|
const normalizedEvent = normalizeNotificationSession(event);
|
||||||
const CODE_MAP = {
|
const CODE_MAP = {
|
||||||
'permission.required': event.meta?.toolName
|
'permission.required': normalizedEvent.meta?.toolName
|
||||||
? `Action Required: Tool "${event.meta.toolName}" needs approval`
|
? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval`
|
||||||
: 'Action Required: A tool needs your approval',
|
: 'Action Required: A tool needs your approval',
|
||||||
'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped',
|
'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped',
|
||||||
'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error',
|
'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error',
|
||||||
'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification',
|
'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification',
|
||||||
'push.enabled': 'Push notifications are now enabled!'
|
'push.enabled': 'Push notifications are now enabled!'
|
||||||
};
|
};
|
||||||
const providerLabel = PROVIDER_LABELS[event.provider] || 'Assistant';
|
const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant';
|
||||||
const sessionName = resolveSessionName(event);
|
const sessionName = resolveSessionName(normalizedEvent);
|
||||||
const message = CODE_MAP[event.code] || 'You have a new notification';
|
const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: sessionName || 'CloudCLI',
|
title: sessionName || 'CloudCLI',
|
||||||
body: `${providerLabel}: ${message}`,
|
body: `${providerLabel}: ${message}`,
|
||||||
data: {
|
data: {
|
||||||
sessionId: event.sessionId || null,
|
sessionId: normalizedEvent.sessionId || null,
|
||||||
code: event.code,
|
code: normalizedEvent.code,
|
||||||
provider: event.provider || null,
|
provider: normalizedEvent.provider || null,
|
||||||
sessionName,
|
sessionName,
|
||||||
tag: `${event.provider || 'assistant'}:${event.sessionId || 'none'}:${event.code}`
|
tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -175,15 +214,16 @@ function notifyUserIfEnabled({ userId, event }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedEvent = normalizeNotificationSession(event);
|
||||||
const preferences = notificationPreferencesDb.getPreferences(userId);
|
const preferences = notificationPreferencesDb.getPreferences(userId);
|
||||||
if (!shouldSendPush(preferences, event)) {
|
if (!shouldSendPush(preferences, normalizedEvent)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isDuplicate(event)) {
|
if (isDuplicate(normalizedEvent)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendWebPush(userId, event).catch((err) => {
|
sendWebPush(userId, normalizedEvent).catch((err) => {
|
||||||
console.error('Web push send error:', err);
|
console.error('Web push send error:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
80
server/services/tests/notification-orchestrator.test.js
Normal file
80
server/services/tests/notification-orchestrator.test.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import webPush from 'web-push';
|
||||||
|
|
||||||
|
import {
|
||||||
|
closeConnection,
|
||||||
|
initializeDatabase,
|
||||||
|
notificationPreferencesDb,
|
||||||
|
pushSubscriptionsDb,
|
||||||
|
sessionsDb,
|
||||||
|
userDb,
|
||||||
|
} from '../modules/database/index.js';
|
||||||
|
|
||||||
|
import { notifyRunStopped } from './notification-orchestrator.js';
|
||||||
|
|
||||||
|
async function withIsolatedDatabase(runTest) {
|
||||||
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
|
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'notification-orchestrator-'));
|
||||||
|
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||||
|
|
||||||
|
closeConnection();
|
||||||
|
process.env.DATABASE_PATH = databasePath;
|
||||||
|
await initializeDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runTest();
|
||||||
|
} finally {
|
||||||
|
closeConnection();
|
||||||
|
if (previousDatabasePath === undefined) {
|
||||||
|
delete process.env.DATABASE_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.DATABASE_PATH = previousDatabasePath;
|
||||||
|
}
|
||||||
|
await rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('push payload uses the app session id when notified with a provider session id', async () => {
|
||||||
|
const originalSendNotification = webPush.sendNotification;
|
||||||
|
const sentPayloads = [];
|
||||||
|
|
||||||
|
webPush.sendNotification = async (_subscription, payload) => {
|
||||||
|
sentPayloads.push(JSON.parse(payload));
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withIsolatedDatabase(async () => {
|
||||||
|
const user = userDb.createUser('notify-user', 'hash');
|
||||||
|
const userId = Number(user.id);
|
||||||
|
|
||||||
|
notificationPreferencesDb.updatePreferences(userId, {
|
||||||
|
channels: { webPush: true },
|
||||||
|
events: { actionRequired: true, stop: true, error: true },
|
||||||
|
});
|
||||||
|
pushSubscriptionsDb.saveSubscription(userId, 'https://example.test/push', 'p256dh', 'auth');
|
||||||
|
sessionsDb.createAppSession('app-session-1', 'claude', '/workspace/demo');
|
||||||
|
sessionsDb.assignProviderSessionId('app-session-1', 'claude-native-1');
|
||||||
|
|
||||||
|
notifyRunStopped({
|
||||||
|
userId,
|
||||||
|
provider: 'claude',
|
||||||
|
sessionId: 'claude-native-1',
|
||||||
|
stopReason: 'completed',
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.equal(sentPayloads.length, 1);
|
||||||
|
assert.equal(sentPayloads[0]?.data?.sessionId, 'app-session-1');
|
||||||
|
assert.match(sentPayloads[0]?.data?.tag, /app-session-1/);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
webPush.sendNotification = originalSendNotification;
|
||||||
|
}
|
||||||
|
});
|
||||||
42
server/shared/tests/slice-tail-page.test.ts
Normal file
42
server/shared/tests/slice-tail-page.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { sliceTailPage } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
const ITEMS = ['a', 'b', 'c', 'd', 'e'];
|
||||||
|
|
||||||
|
test('offset 0 returns the most recent page', () => {
|
||||||
|
const { page, hasMore } = sliceTailPage(ITEMS, 2, 0);
|
||||||
|
assert.deepEqual(page, ['d', 'e']);
|
||||||
|
assert.equal(hasMore, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('increasing offsets walk backwards in time', () => {
|
||||||
|
const { page, hasMore } = sliceTailPage(ITEMS, 2, 2);
|
||||||
|
assert.deepEqual(page, ['b', 'c']);
|
||||||
|
assert.equal(hasMore, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the oldest page reports hasMore false', () => {
|
||||||
|
const { page, hasMore } = sliceTailPage(ITEMS, 2, 4);
|
||||||
|
assert.deepEqual(page, ['a']);
|
||||||
|
assert.equal(hasMore, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null limit returns everything', () => {
|
||||||
|
const { page, hasMore } = sliceTailPage(ITEMS, null, 0);
|
||||||
|
assert.deepEqual(page, ITEMS);
|
||||||
|
assert.equal(hasMore, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offsets past the start return an empty page', () => {
|
||||||
|
const { page, hasMore } = sliceTailPage(ITEMS, 3, 10);
|
||||||
|
assert.deepEqual(page, []);
|
||||||
|
assert.equal(hasMore, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zero limit returns an empty page but keeps hasMore accurate', () => {
|
||||||
|
const { page, hasMore } = sliceTailPage(ITEMS, 0, 0);
|
||||||
|
assert.deepEqual(page, []);
|
||||||
|
assert.equal(hasMore, true);
|
||||||
|
});
|
||||||
@@ -68,7 +68,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
|
|||||||
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
|
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One selectable model row (matches the documentation `public/modelConstants.js` option shape).
|
* One selectable model row in a provider model catalog.
|
||||||
*/
|
*/
|
||||||
export type ProviderModelOption = {
|
export type ProviderModelOption = {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -175,6 +175,30 @@ export type MessageKind =
|
|||||||
| 'interactive_prompt'
|
| 'interactive_prompt'
|
||||||
| 'task_notification';
|
| 'task_notification';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event kinds added by the chat gateway layer on top of provider message kinds.
|
||||||
|
*
|
||||||
|
* These are app-level realtime events (subscription acks, sidebar deltas,
|
||||||
|
* project loading progress, protocol failures) that are not produced by any
|
||||||
|
* provider adapter. Together with `MessageKind` they form the complete set of
|
||||||
|
* `kind` values a websocket client can receive, so the frontend only ever
|
||||||
|
* needs one kind-based switch.
|
||||||
|
*/
|
||||||
|
export type GatewayEventKind =
|
||||||
|
| 'chat_subscribed'
|
||||||
|
| 'session_upserted'
|
||||||
|
| 'loading_progress'
|
||||||
|
| 'protocol_error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete set of `kind` values emitted to websocket clients.
|
||||||
|
*
|
||||||
|
* Every server-to-client websocket frame carries a `kind` from this union.
|
||||||
|
* Provider runtimes emit `MessageKind` values; gateway services emit
|
||||||
|
* `GatewayEventKind` values.
|
||||||
|
*/
|
||||||
|
export type ServerEventKind = MessageKind | GatewayEventKind;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider-neutral message envelope used in REST responses and realtime channels.
|
* Provider-neutral message envelope used in REST responses and realtime channels.
|
||||||
*
|
*
|
||||||
@@ -187,6 +211,13 @@ export type NormalizedMessage = {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
provider: LLMProvider;
|
provider: LLMProvider;
|
||||||
kind: MessageKind;
|
kind: MessageKind;
|
||||||
|
/**
|
||||||
|
* Monotonic per-run sequence number assigned by the chat run registry when a
|
||||||
|
* live event is forwarded to the websocket. History messages loaded over
|
||||||
|
* REST do not carry it. Clients use it with `chat.subscribe` to replay only
|
||||||
|
* the live events they missed across websocket reconnects.
|
||||||
|
*/
|
||||||
|
seq?: number;
|
||||||
role?: 'user' | 'assistant';
|
role?: 'user' | 'assistant';
|
||||||
content?: string;
|
content?: string;
|
||||||
/**
|
/**
|
||||||
@@ -237,11 +268,18 @@ export type NormalizedMessage = {
|
|||||||
*
|
*
|
||||||
* Consumers should pass provider-specific lookup hints (`projectPath`) only
|
* Consumers should pass provider-specific lookup hints (`projectPath`) only
|
||||||
* when the selected provider requires them.
|
* when the selected provider requires them.
|
||||||
|
*
|
||||||
|
* `providerSessionId` is the provider-native session id from the sessions
|
||||||
|
* index (transcript file name / provider database key). Provider adapters
|
||||||
|
* must use it — never the app-facing session id they were called with — when
|
||||||
|
* matching transcript rows on disk, because app-created sessions use an
|
||||||
|
* app-allocated id that the provider has never seen.
|
||||||
*/
|
*/
|
||||||
export type FetchHistoryOptions = {
|
export type FetchHistoryOptions = {
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
limit?: number | null;
|
limit?: number | null;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
providerSessionId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -346,6 +346,84 @@ export function createNormalizedMessage(fields: NormalizedMessageInput): Normali
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the unified terminal `complete` lifecycle message.
|
||||||
|
*
|
||||||
|
* Contract: every provider run ends with exactly one `complete` (the
|
||||||
|
* abort-session handler emits it on behalf of cancelled runs, so aborted runs
|
||||||
|
* must NOT emit their own). The frontend treats `complete` as the only
|
||||||
|
* terminal signal and never needs provider-specific handling:
|
||||||
|
*
|
||||||
|
* - `sessionId` — the id the client knows this run by ('' if never discovered)
|
||||||
|
* - `actualSessionId` — canonical id after the run; equals `sessionId` unless
|
||||||
|
* the provider rewrote it mid-run
|
||||||
|
* - `exitCode` — 0 on success; a missing/null code (e.g. killed process)
|
||||||
|
* is reported as failure
|
||||||
|
* - `success` — exitCode === 0 and not aborted
|
||||||
|
* - `aborted` — run was cancelled by the user
|
||||||
|
*/
|
||||||
|
export function createCompleteMessage(opts: {
|
||||||
|
provider: NormalizedMessage['provider'];
|
||||||
|
sessionId?: string | null;
|
||||||
|
actualSessionId?: string | null;
|
||||||
|
exitCode?: number | null;
|
||||||
|
aborted?: boolean;
|
||||||
|
}): NormalizedMessage {
|
||||||
|
const exitCode = typeof opts.exitCode === 'number' ? opts.exitCode : 1;
|
||||||
|
const aborted = Boolean(opts.aborted);
|
||||||
|
|
||||||
|
return createNormalizedMessage({
|
||||||
|
kind: 'complete',
|
||||||
|
provider: opts.provider,
|
||||||
|
sessionId: opts.sessionId || null,
|
||||||
|
actualSessionId: opts.actualSessionId || opts.sessionId || null,
|
||||||
|
exitCode,
|
||||||
|
success: exitCode === 0 && !aborted,
|
||||||
|
aborted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
//----------------- CONVERSATION HISTORY PAGINATION UTILITIES ------------
|
||||||
|
/**
|
||||||
|
* Slices one page from the END of a chronologically ordered message list.
|
||||||
|
*
|
||||||
|
* This is the single pagination contract for conversation history across all
|
||||||
|
* providers: `offset = 0` returns the most recent `limit` items, increasing
|
||||||
|
* offsets walk backwards in time (for "scroll up to load older" UIs), and a
|
||||||
|
* `null` limit returns everything. Items must already be sorted oldest-first;
|
||||||
|
* the returned page preserves that order.
|
||||||
|
*
|
||||||
|
* Every provider history reader must use this helper instead of slicing
|
||||||
|
* manually so `offset`/`limit` query params behave identically regardless of
|
||||||
|
* which provider produced the session.
|
||||||
|
*/
|
||||||
|
export function sliceTailPage<T>(
|
||||||
|
items: T[],
|
||||||
|
limit: number | null,
|
||||||
|
offset: number,
|
||||||
|
): { page: T[]; hasMore: boolean } {
|
||||||
|
const total = items.length;
|
||||||
|
const normalizedOffset = Math.max(0, offset);
|
||||||
|
|
||||||
|
if (limit === null) {
|
||||||
|
// A null limit returns the full list; offset still trims newest entries
|
||||||
|
// so "everything before the page I already have" stays expressible.
|
||||||
|
const end = Math.max(0, total - normalizedOffset);
|
||||||
|
return {
|
||||||
|
page: items.slice(0, end),
|
||||||
|
hasMore: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = Math.max(0, total - normalizedOffset);
|
||||||
|
const start = Math.max(0, end - Math.max(0, limit));
|
||||||
|
return {
|
||||||
|
page: items.slice(start, end),
|
||||||
|
hasMore: start > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
//----------------- MCP CONFIG PARSING UTILITIES ------------
|
//----------------- MCP CONFIG PARSING UTILITIES ------------
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -10,6 +10,33 @@ import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/Palett
|
|||||||
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
||||||
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
||||||
import { useProjectsState } from '../../hooks/useProjectsState';
|
import { useProjectsState } from '../../hooks/useProjectsState';
|
||||||
|
import { api } from '../../utils/api';
|
||||||
|
|
||||||
|
type RunningSessionApiItem = {
|
||||||
|
sessionId?: unknown;
|
||||||
|
startedAt?: unknown;
|
||||||
|
statusText?: unknown;
|
||||||
|
canInterrupt?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunningSessionsApiPayload = {
|
||||||
|
data?: {
|
||||||
|
sessions?: RunningSessionApiItem[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseStartedAt = (value: unknown): number | undefined => {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export default function AppContent() {
|
export default function AppContent() {
|
||||||
return (
|
return (
|
||||||
@@ -24,16 +51,13 @@ function AppContentInner() {
|
|||||||
const { sessionId } = useParams<{ sessionId?: string }>();
|
const { sessionId } = useParams<{ sessionId?: string }>();
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
||||||
const { ws, sendMessage, latestMessage, isConnected } = useWebSocket();
|
const { ws, sendMessage, subscribe } = useWebSocket();
|
||||||
const wasConnectedRef = useRef(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeSessions,
|
|
||||||
processingSessions,
|
processingSessions,
|
||||||
markSessionAsActive,
|
markSessionProcessing,
|
||||||
markSessionAsInactive,
|
markSessionIdle,
|
||||||
markSessionAsProcessing,
|
syncProcessingSessions,
|
||||||
markSessionAsNotProcessing,
|
|
||||||
} = useSessionProtection();
|
} = useSessionProtection();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -47,19 +71,62 @@ function AppContentInner() {
|
|||||||
setActiveTab,
|
setActiveTab,
|
||||||
setSidebarOpen,
|
setSidebarOpen,
|
||||||
setIsInputFocused,
|
setIsInputFocused,
|
||||||
setShowSettings,
|
|
||||||
openSettings,
|
openSettings,
|
||||||
refreshProjectsSilently,
|
refreshProjectsSilently,
|
||||||
|
registerOptimisticSession,
|
||||||
sidebarSharedProps,
|
sidebarSharedProps,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
} = useProjectsState({
|
} = useProjectsState({
|
||||||
sessionId,
|
sessionId,
|
||||||
navigate,
|
navigate,
|
||||||
latestMessage,
|
subscribe,
|
||||||
isMobile,
|
isMobile,
|
||||||
activeSessions,
|
activeSessions: processingSessions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const refreshRunningSessions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.runningSessions();
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as RunningSessionsApiPayload;
|
||||||
|
const sessions = Array.isArray(payload.data?.sessions) ? payload.data.sessions : [];
|
||||||
|
|
||||||
|
syncProcessingSessions(
|
||||||
|
sessions
|
||||||
|
.map((session) => {
|
||||||
|
if (typeof session.sessionId !== 'string' || !session.sessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
startedAt: parseStartedAt(session.startedAt),
|
||||||
|
statusText: typeof session.statusText === 'string' ? session.statusText : undefined,
|
||||||
|
canInterrupt: typeof session.canInterrupt === 'boolean' ? session.canInterrupt : undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((session): session is NonNullable<typeof session> => Boolean(session)),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AppContent] Failed to sync running sessions:', error);
|
||||||
|
}
|
||||||
|
}, [syncProcessingSessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshRunningSessions();
|
||||||
|
}, [refreshRunningSessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void refreshRunningSessions();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [refreshRunningSessions]);
|
||||||
|
|
||||||
usePaletteOpsRegister({
|
usePaletteOpsRegister({
|
||||||
openSettings,
|
openSettings,
|
||||||
refreshProjects: refreshProjectsSilently,
|
refreshProjects: refreshProjectsSilently,
|
||||||
@@ -99,23 +166,9 @@ function AppContentInner() {
|
|||||||
};
|
};
|
||||||
}, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]);
|
}, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]);
|
||||||
|
|
||||||
// Permission recovery: query pending permissions on WebSocket reconnect or session change
|
// Pending tool permissions are recovered through the `chat.subscribe` flow:
|
||||||
useEffect(() => {
|
// the `chat_subscribed` ack carries them on session open and on reconnect,
|
||||||
const isReconnect = isConnected && !wasConnectedRef.current;
|
// so no separate permission-recovery message is needed here.
|
||||||
|
|
||||||
if (isReconnect) {
|
|
||||||
wasConnectedRef.current = true;
|
|
||||||
} else if (!isConnected) {
|
|
||||||
wasConnectedRef.current = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isConnected && selectedSession?.id) {
|
|
||||||
sendMessage({
|
|
||||||
type: 'get-pending-permissions',
|
|
||||||
sessionId: selectedSession.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isConnected, selectedSession?.id, sendMessage]);
|
|
||||||
|
|
||||||
// Adjust the app container to stay above the virtual keyboard on iOS Safari.
|
// Adjust the app container to stay above the virtual keyboard on iOS Safari.
|
||||||
// On Chrome for Android the layout viewport already shrinks when the keyboard opens,
|
// On Chrome for Android the layout viewport already shrinks when the keyboard opens,
|
||||||
@@ -180,20 +233,20 @@ function AppContentInner() {
|
|||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
ws={ws}
|
ws={ws}
|
||||||
sendMessage={sendMessage}
|
sendMessage={sendMessage}
|
||||||
latestMessage={latestMessage}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onMenuClick={() => setSidebarOpen(true)}
|
onMenuClick={() => setSidebarOpen(true)}
|
||||||
isLoading={isLoadingProjects}
|
isLoading={isLoadingProjects}
|
||||||
onInputFocusChange={setIsInputFocused}
|
onInputFocusChange={setIsInputFocused}
|
||||||
onSessionActive={markSessionAsActive}
|
onSessionProcessing={markSessionProcessing}
|
||||||
onSessionInactive={markSessionAsInactive}
|
onSessionIdle={markSessionIdle}
|
||||||
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) })
|
||||||
}
|
}
|
||||||
onShowSettings={() => setShowSettings(true)}
|
onSessionEstablished={(targetSessionId, context) =>
|
||||||
|
registerOptimisticSession({ sessionId: targetSessionId, ...context })
|
||||||
|
}
|
||||||
|
onShowSettings={openSettings}
|
||||||
externalMessageUpdate={externalMessageUpdate}
|
externalMessageUpdate={externalMessageUpdate}
|
||||||
newSessionTrigger={newSessionTrigger}
|
newSessionTrigger={newSessionTrigger}
|
||||||
/>
|
/>
|
||||||
|
|||||||
1
src/components/browser-use/index.ts
Normal file
1
src/components/browser-use/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as BrowserUsePanel } from './view/BrowserUsePanel';
|
||||||
536
src/components/browser-use/view/BrowserUsePanel.tsx
Normal file
536
src/components/browser-use/view/BrowserUsePanel.tsx
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Clock3,
|
||||||
|
Download,
|
||||||
|
Expand,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
MonitorPlay,
|
||||||
|
RefreshCw,
|
||||||
|
Settings,
|
||||||
|
Square,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '../../../lib/utils';
|
||||||
|
import { Badge, Button } from '../../../shared/view/ui';
|
||||||
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
import type { SettingsMainTab } from '../../settings/types/types';
|
||||||
|
|
||||||
|
type BrowserUseStatus = {
|
||||||
|
enabled: boolean;
|
||||||
|
available: boolean;
|
||||||
|
playwrightInstalled: boolean;
|
||||||
|
chromiumInstalled: boolean;
|
||||||
|
installInProgress: boolean;
|
||||||
|
sessionCount: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowserUseSession = {
|
||||||
|
id: string;
|
||||||
|
status: 'ready' | 'stopped' | 'unavailable';
|
||||||
|
url: string | null;
|
||||||
|
title: string | null;
|
||||||
|
screenshotDataUrl: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lastAction: string | null;
|
||||||
|
message: string | null;
|
||||||
|
createdBy: 'agent';
|
||||||
|
profileName: string | null;
|
||||||
|
viewport: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null;
|
||||||
|
cursor: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
actor: 'agent';
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowserUsePanelProps = {
|
||||||
|
isVisible: boolean;
|
||||||
|
onShowSettings?: (tab?: SettingsMainTab) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readJson<T>(response: Response): Promise<T> {
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || data.success === false) {
|
||||||
|
throw new Error(data.error || data.details || `Request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(value: string | null): string {
|
||||||
|
if (!value) return 'Never';
|
||||||
|
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
if (!Number.isFinite(timestamp)) return 'Unknown';
|
||||||
|
|
||||||
|
const elapsedSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
|
||||||
|
if (elapsedSeconds < 10) return 'Just now';
|
||||||
|
if (elapsedSeconds < 60) return `${elapsedSeconds}s ago`;
|
||||||
|
const elapsedMinutes = Math.round(elapsedSeconds / 60);
|
||||||
|
if (elapsedMinutes < 60) return `${elapsedMinutes}m ago`;
|
||||||
|
const elapsedHours = Math.round(elapsedMinutes / 60);
|
||||||
|
if (elapsedHours < 24) return `${elapsedHours}h ago`;
|
||||||
|
return `${Math.round(elapsedHours / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDomain(url: string | null): string {
|
||||||
|
if (!url) return 'No page loaded';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAction(action: string | null): string {
|
||||||
|
if (!action) return 'Waiting';
|
||||||
|
return action.replace(/_/g, ' ').replace(/:/g, ': ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusTone(status: BrowserUseSession['status']): string {
|
||||||
|
if (status === 'ready') {
|
||||||
|
return 'border-primary/30 bg-primary/5 text-foreground';
|
||||||
|
}
|
||||||
|
if (status === 'stopped') {
|
||||||
|
return 'border-border bg-muted text-muted-foreground';
|
||||||
|
}
|
||||||
|
return 'border-border bg-background text-muted-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuntimeTone(status: BrowserUseStatus | null, installing: boolean): string {
|
||||||
|
if (!status?.enabled) return 'border-border bg-muted text-muted-foreground';
|
||||||
|
if (status.available) return 'border-primary/30 bg-primary/5 text-foreground';
|
||||||
|
if (status.installInProgress || installing) return 'border-primary/30 bg-primary/5 text-foreground';
|
||||||
|
return 'border-border bg-background text-muted-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusDot(status: BrowserUseSession['status']): string {
|
||||||
|
if (status === 'ready') return 'bg-primary';
|
||||||
|
if (status === 'stopped') return 'bg-muted-foreground/50';
|
||||||
|
return 'bg-border';
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROMPTS = [
|
||||||
|
'Use Browser to inspect the checkout flow and report any broken UI states.',
|
||||||
|
'Open <url> with Browser, interact with the page, and summarize what changed after each step.',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUsePanelProps) {
|
||||||
|
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
|
||||||
|
const [sessions, setSessions] = useState<BrowserUseSession[]>([]);
|
||||||
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [isInstalling, setIsInstalling] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selectedSession = useMemo(
|
||||||
|
() => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null,
|
||||||
|
[selectedSessionId, sessions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeSessions = sessions.filter((session) => session.status === 'ready');
|
||||||
|
const needsBrowserBinaries = Boolean(status?.enabled && (!status.playwrightInstalled || !status.chromiumInstalled));
|
||||||
|
const runtimeLabel = !status?.enabled
|
||||||
|
? 'Disabled'
|
||||||
|
: status.available
|
||||||
|
? 'Ready'
|
||||||
|
: status.installInProgress || isInstalling
|
||||||
|
? 'Installing'
|
||||||
|
: 'Setup required';
|
||||||
|
|
||||||
|
const cursorStyle = selectedSession?.cursor && selectedSession.viewport
|
||||||
|
? {
|
||||||
|
left: `${(selectedSession.cursor.x / selectedSession.viewport.width) * 100}%`,
|
||||||
|
top: `${(selectedSession.cursor.y / selectedSession.viewport.height) * 100}%`,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
const [statusResponse, sessionsResponse] = await Promise.all([
|
||||||
|
authenticatedFetch('/api/browser-use/status'),
|
||||||
|
authenticatedFetch('/api/browser-use/sessions'),
|
||||||
|
]);
|
||||||
|
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
|
||||||
|
const sessionsData = await readJson<{ data: { sessions: BrowserUseSession[] } }>(sessionsResponse);
|
||||||
|
const nextSessions = sessionsData.data.sessions;
|
||||||
|
setStatus(statusData.data);
|
||||||
|
setSessions(nextSessions);
|
||||||
|
setSelectedSessionId((current) => (
|
||||||
|
current && nextSessions.some((session) => session.id === current)
|
||||||
|
? current
|
||||||
|
: nextSessions[0]?.id || null
|
||||||
|
));
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load Browser');
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) return;
|
||||||
|
void refresh();
|
||||||
|
}, [isVisible, refresh]);
|
||||||
|
|
||||||
|
const runAction = useCallback(async (action: () => Promise<void>) => {
|
||||||
|
setIsBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Browser action failed');
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const stopSession = () => runAction(async () => {
|
||||||
|
if (!selectedSession) return;
|
||||||
|
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/stop`, { method: 'POST' });
|
||||||
|
await readJson(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteSession = () => runAction(async () => {
|
||||||
|
if (!selectedSession) return;
|
||||||
|
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}`, { method: 'DELETE' });
|
||||||
|
await readJson(response);
|
||||||
|
setIsFullscreen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const installBrowserBinaries = () => runAction(async () => {
|
||||||
|
setIsInstalling(true);
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch('/api/browser-use/runtime/install', { method: 'POST' });
|
||||||
|
await readJson(response);
|
||||||
|
} finally {
|
||||||
|
setIsInstalling(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderSessionItem = (session: BrowserUseSession) => {
|
||||||
|
const isSelected = selectedSession?.id === session.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={session.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedSessionId(session.id)}
|
||||||
|
className={cn(
|
||||||
|
'group w-full rounded-md border px-3 py-2.5 text-left transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary/50 bg-primary/10 text-foreground'
|
||||||
|
: 'border-border/60 bg-card/30 text-muted-foreground hover:bg-muted/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusDot(session.status))} />
|
||||||
|
<div className="truncate text-sm font-medium">{session.title || getDomain(session.url)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate pl-3.5 text-xs text-muted-foreground">{getDomain(session.url)}</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="shrink-0 border-border bg-background text-[10px] text-muted-foreground">
|
||||||
|
{session.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||||
|
<Clock3 className="h-3 w-3" />
|
||||||
|
<span>{formatRelativeTime(session.updatedAt)}</span>
|
||||||
|
<span className="truncate">- {formatAction(session.lastAction)}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEmptyState = () => (
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-2xl rounded-md border border-border bg-card/40 p-5 shadow-sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-border bg-background">
|
||||||
|
<MonitorPlay className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-foreground">
|
||||||
|
{status?.enabled ? 'No browser sessions yet' : 'Browser is disabled'}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
|
||||||
|
{status?.enabled
|
||||||
|
? 'Agent browser sessions appear here while an AI task is using Browser.'
|
||||||
|
: 'Enable Browser in settings to let agents open monitored browser sessions.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{needsBrowserBinaries && (
|
||||||
|
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3">
|
||||||
|
<div className="text-sm font-medium text-foreground">Runtime setup required</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{status?.message}</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="mt-3"
|
||||||
|
onClick={installBrowserBinaries}
|
||||||
|
disabled={isBusy || isInstalling || status?.installInProgress}
|
||||||
|
>
|
||||||
|
{isInstalling || status?.installInProgress ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isInstalling || status?.installInProgress ? 'Installing...' : 'Install Runtime'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-2 sm:grid-cols-2">
|
||||||
|
{PROMPTS.map((prompt) => (
|
||||||
|
<div key={prompt} className="rounded-md border border-border/70 bg-background/70 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
<Bot className="h-3.5 w-3.5" />
|
||||||
|
Prompt
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-foreground">{prompt}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBrowserSurface = (fullscreen = false) => (
|
||||||
|
<div className={cn('flex flex-1 items-center justify-center bg-neutral-950', fullscreen ? 'min-h-[80vh]' : 'min-h-[420px]')}>
|
||||||
|
{selectedSession?.screenshotDataUrl ? (
|
||||||
|
<div className="relative inline-block max-h-full">
|
||||||
|
<img
|
||||||
|
src={selectedSession.screenshotDataUrl}
|
||||||
|
alt="Browser session screenshot"
|
||||||
|
className={fullscreen ? 'block max-h-[80vh] w-auto max-w-full object-contain' : 'block max-h-[72vh] w-auto max-w-full object-contain'}
|
||||||
|
/>
|
||||||
|
{cursorStyle && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white/90 bg-primary/80 shadow-[0_0_0_6px_hsl(var(--primary)/0.18)]"
|
||||||
|
style={cursorStyle}
|
||||||
|
>
|
||||||
|
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-6 text-center">
|
||||||
|
<MonitorPlay className="mx-auto h-9 w-9 text-neutral-500" />
|
||||||
|
<div className="mt-3 text-sm font-medium text-neutral-100">{selectedSession?.message || 'Waiting for screenshot'}</div>
|
||||||
|
<p className="mt-1 text-xs text-neutral-400">The next agent browser snapshot will render here.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-border/60 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MonitorPlay className="h-4 w-4 text-primary" />
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">Browser</h3>
|
||||||
|
<Badge variant="outline" className={cn('text-[10px]', getRuntimeTone(status, isInstalling))}>
|
||||||
|
{runtimeLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">Monitor browser sessions opened by AI agents.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{onShowSettings && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => onShowSettings('browser')}
|
||||||
|
title="Open Browser settings"
|
||||||
|
aria-label="Open Browser settings"
|
||||||
|
>
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => void refresh()}
|
||||||
|
disabled={isRefreshing || isBusy}
|
||||||
|
title="Refresh browser sessions"
|
||||||
|
aria-label="Refresh browser sessions"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="border-b border-destructive/20 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<div className="border-b border-border/60 bg-muted/20 px-3 py-2 lg:hidden">
|
||||||
|
<div className="flex gap-2 overflow-x-auto">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<button
|
||||||
|
key={session.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedSessionId(session.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex min-w-[180px] items-center gap-2 rounded-md border px-2.5 py-2 text-left',
|
||||||
|
selectedSession?.id === session.id
|
||||||
|
? 'border-primary/40 bg-primary/5'
|
||||||
|
: 'border-border bg-background',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusDot(session.status))} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
|
||||||
|
{session.title || getDomain(session.url)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<main className="flex min-h-0 flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
|
||||||
|
<div className="min-w-0 truncate">
|
||||||
|
{activeSessions.length} active
|
||||||
|
<span className="px-1.5">/</span>
|
||||||
|
{sessions.length} total
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 truncate">
|
||||||
|
Updated {formatRelativeTime(selectedSession?.updatedAt || null)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
renderEmptyState()
|
||||||
|
) : (
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
|
||||||
|
<div className="mx-auto flex min-h-[500px] max-w-7xl flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||||
|
<Badge variant="outline" className={selectedSession ? cn('text-[10px]', getStatusTone(selectedSession.status)) : 'text-[10px]'}>
|
||||||
|
{selectedSession?.status || 'empty'}
|
||||||
|
</Badge>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium text-foreground">
|
||||||
|
{selectedSession?.title || getDomain(selectedSession?.url || null)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{selectedSession?.url || 'No page loaded'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden text-xs text-muted-foreground md:block">
|
||||||
|
{formatAction(selectedSession?.lastAction || null)}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl} title="Full screen" aria-label="Full screen">
|
||||||
|
<Expand className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'} title="Stop session" aria-label="Stop session">
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={deleteSession} disabled={isBusy || !selectedSession} title="Delete session" aria-label="Delete session">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{renderBrowserSurface()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside className="hidden min-h-0 flex-col border-l border-border/60 bg-background lg:flex">
|
||||||
|
<div className="border-b border-border/60 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-foreground">Sessions</div>
|
||||||
|
<div className="mt-0.5 text-xs text-muted-foreground">{sessions.length} total</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-[10px]">{activeSessions.length} active</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{sessions.length > 0 ? (
|
||||||
|
<div className="space-y-2">{sessions.map(renderSessionItem)}</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
|
||||||
|
No agent browser sessions.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 p-3">
|
||||||
|
<div className="rounded-md border border-border/70 bg-muted/30 p-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
<Bot className="h-3.5 w-3.5" />
|
||||||
|
Selected
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-2 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span>Status</span>
|
||||||
|
<span className="font-medium text-foreground">{selectedSession?.status || 'None'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span>Last action</span>
|
||||||
|
<span className="truncate font-medium text-foreground">{formatAction(selectedSession?.lastAction || null)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span>Profile</span>
|
||||||
|
<span className="truncate font-medium text-foreground">{selectedSession?.profileName || 'Temporary'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFullscreen && selectedSession && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/90 p-6">
|
||||||
|
<div className="flex h-full flex-col rounded-md border border-white/10 bg-black">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 text-sm text-white/80">
|
||||||
|
<div className="min-w-0 truncate">{selectedSession.title || selectedSession.url || 'Browser session'}</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(false)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{renderBrowserSurface(true)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,12 +12,14 @@ import type {
|
|||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
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 {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
PendingPermissionRequest,
|
PendingPermissionRequest,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
|
SessionEstablishedContext,
|
||||||
} from '../types/types';
|
} from '../types/types';
|
||||||
import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } from '../../../types/app';
|
import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } from '../../../types/app';
|
||||||
import { escapeRegExp } from '../utils/chatFormatting';
|
import { escapeRegExp } from '../utils/chatFormatting';
|
||||||
@@ -25,10 +27,6 @@ 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;
|
||||||
@@ -46,17 +44,20 @@ interface UseChatComposerStateArgs {
|
|||||||
tokenBudget: Record<string, unknown> | null;
|
tokenBudget: Record<string, unknown> | null;
|
||||||
sendMessage: (message: unknown) => void;
|
sendMessage: (message: unknown) => void;
|
||||||
sendByCtrlEnter?: boolean;
|
sendByCtrlEnter?: boolean;
|
||||||
onSessionActive?: (sessionId?: string | null) => void;
|
onSessionProcessing?: MarkSessionProcessing;
|
||||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
/**
|
||||||
|
* Invoked with the freshly allocated session id when the user sends the
|
||||||
|
* first message of a brand-new conversation. The backend allocates the id
|
||||||
|
* via POST /api/providers/sessions BEFORE the websocket send, so the id is
|
||||||
|
* stable for the conversation's whole lifetime — the consumer navigates to
|
||||||
|
* /session/:id and records it as the current session.
|
||||||
|
*/
|
||||||
|
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||||
onInputFocusChange?: (focused: boolean) => void;
|
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[]>>;
|
||||||
}
|
}
|
||||||
@@ -177,17 +178,13 @@ export function useChatComposerState({
|
|||||||
tokenBudget,
|
tokenBudget,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
onSessionActive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
|
onSessionEstablished,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
onFileOpen,
|
onFileOpen,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
pendingViewSessionRef,
|
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
addMessage,
|
addMessage,
|
||||||
setIsLoading,
|
|
||||||
setCanAbortSession,
|
|
||||||
setClaudeStatus,
|
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
}: UseChatComposerStateArgs) {
|
}: UseChatComposerStateArgs) {
|
||||||
@@ -609,8 +606,54 @@ export function useChatComposerState({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveSessionId =
|
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
||||||
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
|
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
|
||||||
|
|
||||||
|
// The conversation always has a stable backend-allocated session id
|
||||||
|
// BEFORE the first websocket send: brand-new chats allocate one here
|
||||||
|
// via the session gateway. There is no client-visible session-id
|
||||||
|
// handoff later — this id stays valid for the conversation's lifetime.
|
||||||
|
let targetSessionId = selectedSession?.id || currentSessionId || null;
|
||||||
|
if (!targetSessionId) {
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch('/api/providers/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider,
|
||||||
|
projectPath: resolvedProjectPath,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to create session (${response.status})`);
|
||||||
|
}
|
||||||
|
const body = await response.json();
|
||||||
|
targetSessionId = body?.data?.sessionId || null;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error('Session creation failed:', error);
|
||||||
|
addMessage({
|
||||||
|
type: 'error',
|
||||||
|
content: `Failed to start a new session: ${message}`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetSessionId) {
|
||||||
|
addMessage({
|
||||||
|
type: 'error',
|
||||||
|
content: 'Failed to start a new session: no session id returned.',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSessionEstablished?.(targetSessionId, {
|
||||||
|
provider,
|
||||||
|
project: selectedProject,
|
||||||
|
summary: sessionSummary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
const userMessage: ChatMessage = {
|
||||||
type: 'user',
|
type: 'user',
|
||||||
@@ -620,27 +663,17 @@ export function useChatComposerState({
|
|||||||
};
|
};
|
||||||
|
|
||||||
addMessage(userMessage);
|
addMessage(userMessage);
|
||||||
setIsLoading(true); // Processing banner starts
|
// Mark this request as processing in the per-session activity map (the
|
||||||
setCanAbortSession(true);
|
// single source of truth the indicator derives from). The id is always
|
||||||
setClaudeStatus({
|
// concrete at this point — no pending placeholder exists anymore.
|
||||||
text: 'Processing',
|
onSessionProcessing?.(targetSessionId, {
|
||||||
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 =
|
||||||
@@ -669,87 +702,35 @@ export function useChatComposerState({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toolsSettings = getToolsSettings();
|
const toolsSettings = getToolsSettings();
|
||||||
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
const model =
|
||||||
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
|
provider === 'cursor'
|
||||||
|
? cursorModel
|
||||||
|
: provider === 'codex'
|
||||||
|
? codexModel
|
||||||
|
: provider === 'gemini'
|
||||||
|
? geminiModel
|
||||||
|
: provider === 'opencode'
|
||||||
|
? opencodeModel
|
||||||
|
: claudeModel;
|
||||||
|
|
||||||
if (provider === 'cursor') {
|
// One message shape for every provider. The backend resolves the
|
||||||
sendMessage({
|
// provider, project path, and provider-native resume id from the
|
||||||
type: 'cursor-command',
|
// session row; `options` only carries composer-level preferences.
|
||||||
command: messageContent,
|
sendMessage({
|
||||||
sessionId: effectiveSessionId,
|
type: 'chat.send',
|
||||||
options: {
|
sessionId: targetSessionId,
|
||||||
cwd: resolvedProjectPath,
|
content: messageContent,
|
||||||
projectPath: resolvedProjectPath,
|
options: {
|
||||||
sessionId: effectiveSessionId,
|
model,
|
||||||
resume: Boolean(effectiveSessionId),
|
// Codex has no plan mode; downgrade rather than sending an
|
||||||
model: cursorModel,
|
// unsupported value to its runtime.
|
||||||
skipPermissions: toolsSettings?.skipPermissions || false,
|
permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode,
|
||||||
sessionSummary,
|
toolsSettings,
|
||||||
toolsSettings,
|
skipPermissions: toolsSettings?.skipPermissions || false,
|
||||||
},
|
sessionSummary,
|
||||||
});
|
images: uploadedImages,
|
||||||
} else if (provider === 'codex') {
|
},
|
||||||
sendMessage({
|
});
|
||||||
type: 'codex-command',
|
|
||||||
command: messageContent,
|
|
||||||
sessionId: effectiveSessionId,
|
|
||||||
options: {
|
|
||||||
cwd: resolvedProjectPath,
|
|
||||||
projectPath: resolvedProjectPath,
|
|
||||||
sessionId: effectiveSessionId,
|
|
||||||
resume: Boolean(effectiveSessionId),
|
|
||||||
model: codexModel,
|
|
||||||
sessionSummary,
|
|
||||||
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (provider === 'gemini') {
|
|
||||||
sendMessage({
|
|
||||||
type: 'gemini-command',
|
|
||||||
command: messageContent,
|
|
||||||
sessionId: effectiveSessionId,
|
|
||||||
options: {
|
|
||||||
cwd: resolvedProjectPath,
|
|
||||||
projectPath: resolvedProjectPath,
|
|
||||||
sessionId: effectiveSessionId,
|
|
||||||
resume: Boolean(effectiveSessionId),
|
|
||||||
model: geminiModel,
|
|
||||||
sessionSummary,
|
|
||||||
permissionMode,
|
|
||||||
toolsSettings,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (provider === 'opencode') {
|
|
||||||
sendMessage({
|
|
||||||
type: 'opencode-command',
|
|
||||||
command: messageContent,
|
|
||||||
sessionId: effectiveSessionId,
|
|
||||||
options: {
|
|
||||||
cwd: resolvedProjectPath,
|
|
||||||
projectPath: resolvedProjectPath,
|
|
||||||
sessionId: effectiveSessionId,
|
|
||||||
resume: Boolean(effectiveSessionId),
|
|
||||||
model: opencodeModel,
|
|
||||||
sessionSummary,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
sendMessage({
|
|
||||||
type: 'claude-command',
|
|
||||||
command: messageContent,
|
|
||||||
options: {
|
|
||||||
projectPath: resolvedProjectPath,
|
|
||||||
cwd: resolvedProjectPath,
|
|
||||||
sessionId: effectiveSessionId,
|
|
||||||
resume: Boolean(effectiveSessionId),
|
|
||||||
toolsSettings,
|
|
||||||
permissionMode,
|
|
||||||
model: claudeModel,
|
|
||||||
sessionSummary,
|
|
||||||
images: uploadedImages,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setInput('');
|
setInput('');
|
||||||
inputValueRef.current = '';
|
inputValueRef.current = '';
|
||||||
@@ -776,19 +757,15 @@ export function useChatComposerState({
|
|||||||
geminiModel,
|
geminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
isLoading,
|
isLoading,
|
||||||
onSessionActive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
pendingViewSessionRef,
|
onSessionEstablished,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
provider,
|
provider,
|
||||||
resetCommandMenuState,
|
resetCommandMenuState,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
setCanAbortSession,
|
|
||||||
addMessage,
|
addMessage,
|
||||||
setClaudeStatus,
|
|
||||||
setIsLoading,
|
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
],
|
],
|
||||||
@@ -944,29 +921,19 @@ export function useChatComposerState({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cursorSessionId =
|
const targetSessionId = selectedSession?.id || currentSessionId || null;
|
||||||
typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;
|
|
||||||
|
|
||||||
const candidateSessionIds = [
|
|
||||||
currentSessionId,
|
|
||||||
provider === 'cursor' ? cursorSessionId : null,
|
|
||||||
selectedSession?.id || null,
|
|
||||||
];
|
|
||||||
|
|
||||||
const targetSessionId =
|
|
||||||
candidateSessionIds.find((sessionId) => Boolean(sessionId)) || null;
|
|
||||||
|
|
||||||
if (!targetSessionId) {
|
if (!targetSessionId) {
|
||||||
console.warn('Abort requested but no concrete session ID is available yet.');
|
console.warn('Abort requested but no session ID is available.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The backend resolves the provider from the session row, so no provider
|
||||||
|
// field is needed here.
|
||||||
sendMessage({
|
sendMessage({
|
||||||
type: 'abort-session',
|
type: 'chat.abort',
|
||||||
sessionId: targetSessionId,
|
sessionId: targetSessionId,
|
||||||
provider,
|
|
||||||
});
|
});
|
||||||
}, [canAbortSession, currentSessionId, provider, selectedSession?.id, sendMessage]);
|
}, [canAbortSession, currentSessionId, selectedSession?.id, sendMessage]);
|
||||||
|
|
||||||
const handleGrantToolPermission = useCallback(
|
const handleGrantToolPermission = useCallback(
|
||||||
(suggestion: { entry: string; toolName: string }) => {
|
(suggestion: { entry: string; toolName: string }) => {
|
||||||
@@ -991,7 +958,7 @@ export function useChatComposerState({
|
|||||||
|
|
||||||
validIds.forEach((requestId) => {
|
validIds.forEach((requestId) => {
|
||||||
sendMessage({
|
sendMessage({
|
||||||
type: 'claude-permission-response',
|
type: 'chat.permission-response',
|
||||||
requestId,
|
requestId,
|
||||||
allow: Boolean(decision?.allow),
|
allow: Boolean(decision?.allow),
|
||||||
updatedInput: decision?.updatedInput,
|
updatedInput: decision?.updatedInput,
|
||||||
@@ -1000,15 +967,11 @@ export function useChatComposerState({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setPendingPermissionRequests((previous) => {
|
setPendingPermissionRequests((previous) =>
|
||||||
const next = previous.filter((request) => !validIds.includes(request.requestId));
|
previous.filter((request) => !validIds.includes(request.requestId)),
|
||||||
if (next.length === 0) {
|
);
|
||||||
setClaudeStatus(null);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
|
[sendMessage, setPendingPermissionRequests],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
|
|||||||
@@ -17,17 +17,35 @@ const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
|
|||||||
opencode: 'anthropic/claude-sonnet-4-5',
|
opencode: 'anthropic/claude-sonnet-4-5',
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
|
/**
|
||||||
if (provider === 'codex') {
|
* Fallback permission-mode matrix used only until the backend capability
|
||||||
return ['default', 'acceptEdits', 'bypassPermissions'];
|
* matrix (`GET /api/providers/capabilities`) has loaded. The backend is the
|
||||||
}
|
* source of truth; this mirror exists so the composer renders sensibly on
|
||||||
if (provider === 'claude') {
|
* first paint and when the capabilities request fails.
|
||||||
return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'];
|
*/
|
||||||
}
|
const FALLBACK_PERMISSION_MODES: Record<LLMProvider, PermissionMode[]> = {
|
||||||
if (provider === 'opencode') {
|
claude: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||||
return ['default'];
|
cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||||
}
|
codex: ['default', 'acceptEdits', 'bypassPermissions'],
|
||||||
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||||
|
opencode: ['default'],
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderCapabilities = {
|
||||||
|
provider: LLMProvider;
|
||||||
|
permissionModes: string[];
|
||||||
|
defaultPermissionMode: string;
|
||||||
|
supportsImages: boolean;
|
||||||
|
supportsAbort: boolean;
|
||||||
|
supportsPermissionRequests: boolean;
|
||||||
|
supportsTokenUsage: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderCapabilitiesApiResponse = {
|
||||||
|
success?: boolean;
|
||||||
|
data?: {
|
||||||
|
providers?: ProviderCapabilities[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UseChatProviderStateArgs {
|
interface UseChatProviderStateArgs {
|
||||||
@@ -76,6 +94,17 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
|
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend-owned capability matrix keyed by provider. Drives the permission
|
||||||
|
* mode picker (and is the extension point for future per-provider UI
|
||||||
|
* differences) so the frontend stays free of hardcoded provider branching.
|
||||||
|
* Null until `/api/providers/capabilities` resolves; the static fallback
|
||||||
|
* map covers that window.
|
||||||
|
*/
|
||||||
|
const [providerCapabilities, setProviderCapabilities] = useState<
|
||||||
|
Partial<Record<LLMProvider, ProviderCapabilities>> | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const [providerModelCatalog, setProviderModelCatalog] = useState<
|
const [providerModelCatalog, setProviderModelCatalog] = useState<
|
||||||
Partial<Record<LLMProvider, ProviderModelsDefinition>>
|
Partial<Record<LLMProvider, ProviderModelsDefinition>>
|
||||||
>({});
|
>({});
|
||||||
@@ -181,6 +210,41 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
void loadProviderModels();
|
void loadProviderModels();
|
||||||
}, [loadProviderModels]);
|
}, [loadProviderModels]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadCapabilities = async () => {
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch('/api/providers/capabilities');
|
||||||
|
const body = (await response.json()) as ProviderCapabilitiesApiResponse;
|
||||||
|
if (cancelled || !body.success || !Array.isArray(body.data?.providers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byProvider: Partial<Record<LLMProvider, ProviderCapabilities>> = {};
|
||||||
|
for (const capabilities of body.data.providers) {
|
||||||
|
byProvider[capabilities.provider] = capabilities;
|
||||||
|
}
|
||||||
|
setProviderCapabilities(byProvider);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading provider capabilities:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadCapabilities();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getPermissionModesForProvider = useCallback((targetProvider: LLMProvider): PermissionMode[] => {
|
||||||
|
const capabilityModes = providerCapabilities?.[targetProvider]?.permissionModes;
|
||||||
|
if (capabilityModes && capabilityModes.length > 0) {
|
||||||
|
return capabilityModes as PermissionMode[];
|
||||||
|
}
|
||||||
|
return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default'];
|
||||||
|
}, [providerCapabilities]);
|
||||||
|
|
||||||
const pickStoredOrCurrent = (
|
const pickStoredOrCurrent = (
|
||||||
storageKey: string,
|
storageKey: string,
|
||||||
current: string,
|
current: string,
|
||||||
@@ -269,7 +333,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
|
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
|
||||||
const validModes = getPermissionModesForProvider(provider);
|
const validModes = getPermissionModesForProvider(provider);
|
||||||
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
|
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
|
||||||
}, [selectedSession?.id, provider]);
|
}, [selectedSession?.id, provider, getPermissionModesForProvider]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
|
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
|
||||||
@@ -327,7 +391,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
if (selectedSession?.id) {
|
if (selectedSession?.id) {
|
||||||
localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
|
localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
|
||||||
}
|
}
|
||||||
}, [permissionMode, provider, selectedSession?.id]);
|
}, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]);
|
||||||
|
|
||||||
const selectProviderModel = useCallback(async (
|
const selectProviderModel = useCallback(async (
|
||||||
targetProvider: LLMProvider,
|
targetProvider: LLMProvider,
|
||||||
|
|||||||
@@ -1,71 +1,34 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect } from 'react';
|
||||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||||
|
|
||||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
import type { ServerEvent } from '../../../contexts/WebSocketContext';
|
||||||
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
|
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
|
||||||
|
import { playChatCompletionSound } from '../../../utils/notificationSound';
|
||||||
|
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
||||||
|
import type { PendingPermissionRequest } from '../types/types';
|
||||||
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
import type { 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?: string;
|
|
||||||
kind?: string;
|
|
||||||
data?: any;
|
|
||||||
message?: any;
|
|
||||||
delta?: string;
|
|
||||||
sessionId?: string;
|
|
||||||
session_id?: string;
|
|
||||||
requestId?: string;
|
|
||||||
toolName?: string;
|
|
||||||
input?: unknown;
|
|
||||||
context?: unknown;
|
|
||||||
error?: string;
|
|
||||||
tool?: any;
|
|
||||||
toolId?: string;
|
|
||||||
result?: any;
|
|
||||||
exitCode?: number;
|
|
||||||
isProcessing?: boolean;
|
|
||||||
actualSessionId?: string;
|
|
||||||
event?: string;
|
|
||||||
status?: any;
|
|
||||||
isNewSession?: boolean;
|
|
||||||
resultText?: string;
|
|
||||||
isError?: boolean;
|
|
||||||
success?: boolean;
|
|
||||||
reason?: string;
|
|
||||||
provider?: string;
|
|
||||||
content?: string;
|
|
||||||
text?: string;
|
|
||||||
tokens?: number;
|
|
||||||
canInterrupt?: boolean;
|
|
||||||
tokenBudget?: unknown;
|
|
||||||
newSessionId?: string;
|
|
||||||
aborted?: boolean;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UseChatRealtimeHandlersArgs {
|
interface UseChatRealtimeHandlersArgs {
|
||||||
latestMessage: LatestChatMessage | null;
|
subscribe: (listener: (event: ServerEvent) => void) => () => void;
|
||||||
provider: LLMProvider;
|
provider: LLMProvider;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
setCurrentSessionId: (sessionId: string | null) => void;
|
|
||||||
setIsLoading: (loading: boolean) => void;
|
|
||||||
setCanAbortSession: (canAbort: boolean) => void;
|
|
||||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
|
||||||
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
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>;
|
||||||
onSessionInactive?: (sessionId?: string | null) => void;
|
/**
|
||||||
onSessionActive?: (sessionId?: string | null) => void;
|
* Highest live `seq` observed per session. Essential for reconnect catch-up:
|
||||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
* `chat.subscribe` sends this value as `lastSeq` so the server replays only
|
||||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
* the events this client actually missed. Written here on every sequenced
|
||||||
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
|
* frame; read wherever a `chat.subscribe` is sent (session open, reconnect).
|
||||||
|
*/
|
||||||
|
lastSeqRef: MutableRefObject<Map<string, number>>;
|
||||||
|
/** When each session's `chat.subscribe` was last sent; guards stale idle acks. */
|
||||||
|
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
|
||||||
|
onSessionProcessing?: MarkSessionProcessing;
|
||||||
|
onSessionIdle?: MarkSessionIdle;
|
||||||
onWebSocketReconnect?: () => void;
|
onWebSocketReconnect?: () => void;
|
||||||
sessionStore: SessionStore;
|
sessionStore: SessionStore;
|
||||||
}
|
}
|
||||||
@@ -74,321 +37,263 @@ interface UseChatRealtimeHandlersArgs {
|
|||||||
/* Hook */
|
/* Hook */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes server events into the session store and processing-state map.
|
||||||
|
*
|
||||||
|
* This is intentionally a thin reducer over the unified `kind`-based
|
||||||
|
* protocol: every frame is keyed by the stable app session id, so there is
|
||||||
|
* no session-id handoff, no provider branching, and no navigation here.
|
||||||
|
* Sidebar events (`session_upserted`, `loading_progress`) are handled by
|
||||||
|
* `useProjectsState`, not in this hook.
|
||||||
|
*/
|
||||||
export function useChatRealtimeHandlers({
|
export function useChatRealtimeHandlers({
|
||||||
latestMessage,
|
subscribe,
|
||||||
provider,
|
provider,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
|
||||||
setIsLoading,
|
|
||||||
setCanAbortSession,
|
|
||||||
setClaudeStatus,
|
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
onSessionInactive,
|
lastSeqRef,
|
||||||
onSessionActive,
|
statusCheckSentAtRef,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
onNavigateToSession,
|
|
||||||
onWebSocketReconnect,
|
onWebSocketReconnect,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
}: UseChatRealtimeHandlersArgs) {
|
}: UseChatRealtimeHandlersArgs) {
|
||||||
const paletteOps = usePaletteOps();
|
|
||||||
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!latestMessage) return;
|
const handleEvent = (msg: ServerEvent) => {
|
||||||
if (lastProcessedMessageRef.current === latestMessage) return;
|
if (!msg.kind) {
|
||||||
lastProcessedMessageRef.current = latestMessage;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const activeViewSessionId =
|
const activeViewSessionId = selectedSession?.id || currentSessionId || null;
|
||||||
selectedSession?.id || currentSessionId || null;
|
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
// Record replay progress for every sequenced live event.
|
||||||
/* Legacy messages (no `kind` field) — handle and return */
|
if (sid && typeof msg.seq === 'number') {
|
||||||
/* ---------------------------------------------------------------- */
|
const known = lastSeqRef.current.get(sid) ?? 0;
|
||||||
|
if (msg.seq > known) {
|
||||||
|
lastSeqRef.current.set(sid, msg.seq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const msg = latestMessage as any;
|
switch (msg.kind) {
|
||||||
|
case 'websocket_reconnected':
|
||||||
if (!msg.kind) {
|
|
||||||
const messageType = String(msg.type || '');
|
|
||||||
|
|
||||||
switch (messageType) {
|
|
||||||
case 'websocket-reconnected':
|
|
||||||
onWebSocketReconnect?.();
|
onWebSocketReconnect?.();
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case 'pending-permissions-response': {
|
case 'chat_subscribed': {
|
||||||
const permSessionId = msg.sessionId;
|
// Ack for chat.subscribe: authoritative processing state plus any
|
||||||
const isCurrentPermSession =
|
// pending tool-permission prompts for the run.
|
||||||
permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id);
|
if (!sid) return;
|
||||||
if (permSessionId && !isCurrentPermSession) return;
|
|
||||||
setPendingPermissionRequests(msg.data || []);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'session-status': {
|
|
||||||
const statusSessionId = msg.sessionId;
|
|
||||||
if (!statusSessionId) return;
|
|
||||||
|
|
||||||
const status = msg.status;
|
|
||||||
if (status) {
|
|
||||||
const statusInfo = {
|
|
||||||
text: status.text || 'Working...',
|
|
||||||
tokens: status.tokens || 0,
|
|
||||||
can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
|
|
||||||
};
|
|
||||||
setClaudeStatus(statusInfo);
|
|
||||||
setIsLoading(true);
|
|
||||||
setCanAbortSession(statusInfo.can_interrupt);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy isProcessing format from check-session-status
|
|
||||||
const isCurrentSession =
|
|
||||||
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
|
|
||||||
|
|
||||||
if (msg.isProcessing) {
|
if (msg.isProcessing) {
|
||||||
onSessionActive?.(statusSessionId);
|
onSessionProcessing?.(sid);
|
||||||
onSessionProcessing?.(statusSessionId);
|
} else {
|
||||||
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
|
// Idle ack: ignore it if a newer request started after the
|
||||||
return;
|
// subscribe was sent — the ack describes the older state.
|
||||||
|
onSessionIdle?.(sid, {
|
||||||
|
ifStartedBefore: statusCheckSentAtRef.current.get(sid),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onSessionInactive?.(statusSessionId);
|
const isViewedSession = sid === activeViewSessionId;
|
||||||
onSessionNotProcessing?.(statusSessionId);
|
if (isViewedSession && Array.isArray(msg.pendingPermissions)) {
|
||||||
if (isCurrentSession) {
|
setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]);
|
||||||
setIsLoading(false);
|
|
||||||
setCanAbortSession(false);
|
|
||||||
setClaudeStatus(null);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'protocol_error': {
|
||||||
|
console.error('[Chat] Protocol error:', msg.code, msg.error);
|
||||||
|
if (sid) {
|
||||||
|
// Surface the failure in the conversation and stop the spinner —
|
||||||
|
// the run never started (or was rejected), so no `complete` follows.
|
||||||
|
onSessionIdle?.(sid);
|
||||||
|
sessionStore.appendRealtime(sid, {
|
||||||
|
id: `protocol_error_${Date.now()}`,
|
||||||
|
sessionId: sid,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider,
|
||||||
|
kind: 'error',
|
||||||
|
content: String(msg.error || 'Request failed'),
|
||||||
|
} as NormalizedMessage);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidebar/global events — owned by useProjectsState.
|
||||||
|
case 'session_upserted':
|
||||||
|
case 'loading_progress':
|
||||||
|
return;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Unknown legacy message type — ignore
|
break;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* -------------------------------------------------------------- */
|
||||||
/* NormalizedMessage handling (has `kind` field) */
|
/* Provider NormalizedMessage handling */
|
||||||
/* ---------------------------------------------------------------- */
|
/* -------------------------------------------------------------- */
|
||||||
|
|
||||||
const sid = msg.sessionId || activeViewSessionId;
|
// --- Streaming: buffer for performance ---
|
||||||
|
if (msg.kind === 'stream_delta') {
|
||||||
// --- Streaming: buffer for performance ---
|
const text = (msg.content as string) || '';
|
||||||
if (msg.kind === 'stream_delta') {
|
if (!text) return;
|
||||||
const text = msg.content || '';
|
accumulatedStreamRef.current += text;
|
||||||
if (!text) return;
|
if (!streamTimerRef.current) {
|
||||||
accumulatedStreamRef.current += text;
|
streamTimerRef.current = window.setTimeout(() => {
|
||||||
if (!streamTimerRef.current) {
|
streamTimerRef.current = null;
|
||||||
streamTimerRef.current = window.setTimeout(() => {
|
if (sid) {
|
||||||
streamTimerRef.current = null;
|
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
||||||
if (sid) {
|
}
|
||||||
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
}, 100);
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
// Also route to store for non-active sessions
|
|
||||||
if (sid && sid !== activeViewSessionId) {
|
|
||||||
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.kind === 'stream_end') {
|
|
||||||
if (streamTimerRef.current) {
|
|
||||||
clearTimeout(streamTimerRef.current);
|
|
||||||
streamTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (sid) {
|
|
||||||
if (accumulatedStreamRef.current) {
|
|
||||||
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
|
||||||
}
|
}
|
||||||
sessionStore.finalizeStreaming(sid);
|
// Also route to store for non-active sessions
|
||||||
}
|
if (sid && sid !== activeViewSessionId) {
|
||||||
accumulatedStreamRef.current = '';
|
sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- All other messages: route to store ---
|
|
||||||
const shouldPersist =
|
|
||||||
msg.kind !== 'session_created'
|
|
||||||
&& msg.kind !== 'complete'
|
|
||||||
&& msg.kind !== 'status'
|
|
||||||
&& msg.kind !== 'permission_request'
|
|
||||||
&& msg.kind !== 'permission_cancelled';
|
|
||||||
|
|
||||||
if (sid && shouldPersist) {
|
|
||||||
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- UI side effects for specific kinds ---
|
|
||||||
switch (msg.kind) {
|
|
||||||
case 'session_created': {
|
|
||||||
const newSessionId = msg.newSessionId;
|
|
||||||
if (!newSessionId) break;
|
|
||||||
|
|
||||||
// We no longer synthesize client-side placeholder IDs. Until the provider
|
|
||||||
// announces `session_created`, the active id is expected to be null.
|
|
||||||
if (!currentSessionId) {
|
|
||||||
console.log('Session created with ID:', newSessionId);
|
|
||||||
console.log('Existing session ID:', currentSessionId);
|
|
||||||
setCurrentSessionId(newSessionId);
|
|
||||||
setPendingPermissionRequests((prev) =>
|
|
||||||
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
pendingViewSessionRef.current = null;
|
return;
|
||||||
onSessionActive?.(newSessionId);
|
|
||||||
onSessionProcessing?.(newSessionId);
|
|
||||||
setIsLoading(true);
|
|
||||||
setCanAbortSession(true);
|
|
||||||
setClaudeStatus({
|
|
||||||
text: 'Processing',
|
|
||||||
tokens: 0,
|
|
||||||
can_interrupt: true,
|
|
||||||
});
|
|
||||||
onNavigateToSession?.(newSessionId);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'complete': {
|
if (msg.kind === 'stream_end') {
|
||||||
// Flush any remaining streaming state
|
|
||||||
if (streamTimerRef.current) {
|
if (streamTimerRef.current) {
|
||||||
clearTimeout(streamTimerRef.current);
|
clearTimeout(streamTimerRef.current);
|
||||||
streamTimerRef.current = null;
|
streamTimerRef.current = null;
|
||||||
}
|
}
|
||||||
if (sid && accumulatedStreamRef.current) {
|
if (sid) {
|
||||||
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
if (accumulatedStreamRef.current) {
|
||||||
|
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
||||||
|
}
|
||||||
sessionStore.finalizeStreaming(sid);
|
sessionStore.finalizeStreaming(sid);
|
||||||
}
|
}
|
||||||
accumulatedStreamRef.current = '';
|
accumulatedStreamRef.current = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
// --- All other messages: route to store ---
|
||||||
setCanAbortSession(false);
|
const shouldPersist =
|
||||||
setClaudeStatus(null);
|
msg.kind !== 'complete'
|
||||||
setPendingPermissionRequests([]);
|
&& msg.kind !== 'status'
|
||||||
onSessionInactive?.(sid);
|
&& msg.kind !== 'permission_request'
|
||||||
onSessionNotProcessing?.(sid);
|
&& msg.kind !== 'permission_cancelled';
|
||||||
pendingViewSessionRef.current = null;
|
|
||||||
|
if (sid && shouldPersist) {
|
||||||
|
sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UI side effects for specific kinds ---
|
||||||
|
switch (msg.kind) {
|
||||||
|
case 'complete': {
|
||||||
|
// Flush any remaining streaming state
|
||||||
|
if (streamTimerRef.current) {
|
||||||
|
clearTimeout(streamTimerRef.current);
|
||||||
|
streamTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (sid && accumulatedStreamRef.current) {
|
||||||
|
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
||||||
|
sessionStore.finalizeStreaming(sid);
|
||||||
|
}
|
||||||
|
accumulatedStreamRef.current = '';
|
||||||
|
|
||||||
|
// `complete` is the unified terminal event — every provider run ends
|
||||||
|
// with exactly one, regardless of success, failure, or abort. The
|
||||||
|
// indicator derives from the processing map, so deleting the entry
|
||||||
|
// hides it immediately and atomically.
|
||||||
|
onSessionIdle?.(sid);
|
||||||
|
if (sid === activeViewSessionId) {
|
||||||
|
setPendingPermissionRequests([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.aborted) {
|
||||||
|
// Abort was requested — the complete event confirms it. No
|
||||||
|
// further UI action is needed beyond clearing the entry above.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Celebrate only successful runs (failed runs end with success: false).
|
||||||
|
if (msg.success !== false) {
|
||||||
|
showCompletionTitleIndicator();
|
||||||
|
void playChatCompletionSound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session id is stable for the whole conversation (allocated
|
||||||
|
// before the first send), so the only follow-up is syncing the
|
||||||
|
// viewed conversation with the now-persisted transcript.
|
||||||
|
if (sid && sid === activeViewSessionId) {
|
||||||
|
void sessionStore.refreshFromServer(sid);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle aborted case
|
|
||||||
if (msg.aborted) {
|
|
||||||
// Abort was requested — the complete event confirms it
|
|
||||||
// No special UI action needed beyond clearing loading state above
|
|
||||||
// The backend already sent any abort-related messages
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualSessionId =
|
// 'error' is an informational message row, not a terminal event —
|
||||||
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
// providers emit it for mid-run stderr output too. Run teardown is
|
||||||
? msg.actualSessionId
|
// always signalled by the unified 'complete' that follows.
|
||||||
: null;
|
|
||||||
const isVisibleSession =
|
|
||||||
Boolean(
|
|
||||||
sid
|
|
||||||
&& sid === activeViewSessionId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (actualSessionId && sid && actualSessionId !== sid) {
|
case 'permission_request': {
|
||||||
sessionStore.replaceSessionId(sid, actualSessionId);
|
if (!msg.requestId) break;
|
||||||
|
if (sid === activeViewSessionId) {
|
||||||
if (isVisibleSession) {
|
setPendingPermissionRequests((prev) => {
|
||||||
setCurrentSessionId(actualSessionId);
|
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
|
||||||
|
return [...prev, {
|
||||||
|
requestId: msg.requestId as string,
|
||||||
|
toolName: (msg.toolName as string) || 'UnknownTool',
|
||||||
|
input: msg.input,
|
||||||
|
context: msg.context,
|
||||||
|
sessionId: sid || null,
|
||||||
|
receivedAt: new Date(),
|
||||||
|
}];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
if (sid) {
|
||||||
if (isVisibleSession) {
|
onSessionProcessing?.(sid);
|
||||||
onNavigateToSession?.(actualSessionId, { replace: true });
|
|
||||||
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
case 'permission_cancelled': {
|
||||||
}
|
if (msg.requestId && sid === activeViewSessionId) {
|
||||||
|
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
|
||||||
case 'error': {
|
}
|
||||||
setIsLoading(false);
|
break;
|
||||||
setCanAbortSession(false);
|
|
||||||
setClaudeStatus(null);
|
|
||||||
onSessionInactive?.(sid);
|
|
||||||
onSessionNotProcessing?.(sid);
|
|
||||||
pendingViewSessionRef.current = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'permission_request': {
|
|
||||||
if (!msg.requestId) break;
|
|
||||||
setPendingPermissionRequests((prev) => {
|
|
||||||
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
|
|
||||||
return [...prev, {
|
|
||||||
requestId: msg.requestId,
|
|
||||||
toolName: msg.toolName || 'UnknownTool',
|
|
||||||
input: msg.input,
|
|
||||||
context: msg.context,
|
|
||||||
sessionId: sid || null,
|
|
||||||
receivedAt: new Date(),
|
|
||||||
}];
|
|
||||||
});
|
|
||||||
setIsLoading(true);
|
|
||||||
setCanAbortSession(true);
|
|
||||||
setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'permission_cancelled': {
|
|
||||||
if (msg.requestId) {
|
|
||||||
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
|
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'status': {
|
case 'status': {
|
||||||
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 && sid) {
|
||||||
setClaudeStatus({
|
onSessionProcessing?.(sid, {
|
||||||
text: msg.text,
|
statusText: msg.text as string,
|
||||||
tokens: msg.tokens || 0,
|
canInterrupt: msg.canInterrupt !== false,
|
||||||
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
|
});
|
||||||
});
|
}
|
||||||
setIsLoading(true);
|
break;
|
||||||
setCanAbortSession(msg.canInterrupt !== false);
|
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// text, tool_use, tool_result, thinking, interactive_prompt, task_notification
|
// text, tool_use, tool_result, thinking, interactive_prompt, task_notification
|
||||||
// → already routed to store above, no UI side effects needed
|
// → already routed to store above, no UI side effects needed
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return subscribe(handleEvent);
|
||||||
}, [
|
}, [
|
||||||
latestMessage,
|
subscribe,
|
||||||
provider,
|
provider,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
|
||||||
setIsLoading,
|
|
||||||
setCanAbortSession,
|
|
||||||
setClaudeStatus,
|
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
onSessionInactive,
|
lastSeqRef,
|
||||||
onSessionActive,
|
statusCheckSentAtRef,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
onNavigateToSession,
|
|
||||||
onWebSocketReconnect,
|
onWebSocketReconnect,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
paletteOps,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ 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 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 } from '../types/types';
|
||||||
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
|
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
|
||||||
|
|
||||||
import { normalizedToChatMessages } from './useChatMessages';
|
import { normalizedToChatMessages } from './useChatMessages';
|
||||||
@@ -12,10 +13,6 @@ 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;
|
||||||
@@ -24,9 +21,13 @@ interface UseChatSessionStateArgs {
|
|||||||
autoScrollToBottom?: boolean;
|
autoScrollToBottom?: boolean;
|
||||||
externalMessageUpdate?: number;
|
externalMessageUpdate?: number;
|
||||||
newSessionTrigger?: number;
|
newSessionTrigger?: number;
|
||||||
processingSessions?: Set<string>;
|
processingSessions?: SessionActivityMap;
|
||||||
|
onSessionIdle?: MarkSessionIdle;
|
||||||
resetStreamingState: () => void;
|
resetStreamingState: () => void;
|
||||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
/** When each session's `chat.subscribe` was last sent; guards stale idle acks. */
|
||||||
|
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
|
||||||
|
/** Highest live seq observed per session; sent as `lastSeq` on subscribe. */
|
||||||
|
lastSeqRef: MutableRefObject<Map<string, number>>;
|
||||||
sessionStore: SessionStore;
|
sessionStore: SessionStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,21 +100,20 @@ export function useChatSessionState({
|
|||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
|
onSessionIdle,
|
||||||
resetStreamingState,
|
resetStreamingState,
|
||||||
pendingViewSessionRef,
|
statusCheckSentAtRef,
|
||||||
|
lastSeqRef,
|
||||||
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);
|
||||||
@@ -170,13 +170,8 @@ export function useChatSessionState({
|
|||||||
* - No coupling to unrelated external update signals.
|
* - No coupling to unrelated external update signals.
|
||||||
*/
|
*/
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
pendingViewSessionRef.current = null;
|
|
||||||
setClaudeStatus(null);
|
|
||||||
setCanAbortSession(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
setPendingUserMessage(null);
|
setPendingUserMessage(null);
|
||||||
sessionStorage.removeItem('cursorSessionId');
|
|
||||||
messagesOffsetRef.current = 0;
|
messagesOffsetRef.current = 0;
|
||||||
setHasMoreMessages(false);
|
setHasMoreMessages(false);
|
||||||
setTotalMessages(0);
|
setTotalMessages(0);
|
||||||
@@ -204,13 +199,30 @@ export function useChatSessionState({
|
|||||||
clearTimeout(loadAllFinishedTimerRef.current);
|
clearTimeout(loadAllFinishedTimerRef.current);
|
||||||
loadAllFinishedTimerRef.current = null;
|
loadAllFinishedTimerRef.current = null;
|
||||||
}
|
}
|
||||||
}, [newSessionTrigger, pendingViewSessionRef, resetStreamingState]);
|
}, [newSessionTrigger, onSessionIdle, resetStreamingState]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Derive processing state for the viewed session */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const activeSessionId = selectedSession?.id || currentSessionId || null;
|
||||||
|
|
||||||
|
// The activity indicator always reflects the latest status of the session
|
||||||
|
// being viewed — never stale local UI state from the last time it was
|
||||||
|
// open. Session ids are concrete before any send, so no pending
|
||||||
|
// placeholder entry exists anymore.
|
||||||
|
const sessionActivity = (activeSessionId && processingSessions?.get(activeSessionId)) || null;
|
||||||
|
const isProcessing = sessionActivity !== null;
|
||||||
|
const canAbortSession = isProcessing && sessionActivity.canInterrupt;
|
||||||
|
|
||||||
|
// Ref mirror so effects can read the latest map without re-running on
|
||||||
|
// every activity transition.
|
||||||
|
const processingSessionsRef = useRef(processingSessions);
|
||||||
|
processingSessionsRef.current = processingSessions;
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Derive chatMessages from the store */
|
/* 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);
|
||||||
|
|
||||||
@@ -316,18 +328,12 @@ export function useChatSessionState({
|
|||||||
if (allMessagesLoadedRef.current) return false;
|
if (allMessagesLoadedRef.current) return false;
|
||||||
if (!hasMoreMessages || !selectedSession || !selectedProject) return false;
|
if (!hasMoreMessages || !selectedSession || !selectedProject) return false;
|
||||||
|
|
||||||
const sessionProvider = selectedSession.__provider || 'claude';
|
|
||||||
|
|
||||||
isLoadingMoreRef.current = true;
|
isLoadingMoreRef.current = true;
|
||||||
const previousScrollHeight = container.scrollHeight;
|
const previousScrollHeight = container.scrollHeight;
|
||||||
const previousScrollTop = container.scrollTop;
|
const previousScrollTop = container.scrollTop;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const slot = await sessionStore.fetchMore(selectedSession.id, {
|
const slot = await sessionStore.fetchMore(selectedSession.id, {
|
||||||
provider: sessionProvider as LLMProvider,
|
|
||||||
// DB-assigned projectId replaces the legacy folder-derived name.
|
|
||||||
projectId: selectedProject.projectId,
|
|
||||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
|
||||||
limit: MESSAGES_PER_PAGE,
|
limit: MESSAGES_PER_PAGE,
|
||||||
});
|
});
|
||||||
if (!slot || slot.serverMessages.length === 0) return false;
|
if (!slot || slot.serverMessages.length === 0) return false;
|
||||||
@@ -383,30 +389,61 @@ export function useChatSessionState({
|
|||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
}, [selectedProject?.projectId, selectedSession?.id]);
|
}, [selectedProject?.projectId, selectedSession?.id]);
|
||||||
|
|
||||||
// Initial scroll to bottom
|
// Initial scroll to bottom — robust to lazy content reflow.
|
||||||
|
// The previous implementation fired one scrollToBottom() at +200ms and
|
||||||
|
// cleared the pending flag. When markdown blocks, code highlighting, or
|
||||||
|
// images finished rendering after that window, scrollHeight grew but
|
||||||
|
// nothing re-anchored the viewport, leaving the chat tab visually
|
||||||
|
// "scrolled way up" with the latest assistant message off-screen.
|
||||||
|
//
|
||||||
|
// This version re-scrolls every animation frame while scrollHeight is
|
||||||
|
// still growing, capped at ~1s (60 frames) or 3 consecutive stable
|
||||||
|
// frames. Cancels cleanly on session change via the pending flag.
|
||||||
useEffect(() => {
|
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; }
|
||||||
pendingInitialScrollRef.current = false;
|
if (searchScrollActiveRef.current) { pendingInitialScrollRef.current = false; return; }
|
||||||
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 freshly created session can be mid-run before the router has a
|
||||||
// selectedSession. Keep the processing banner alive until complete/error.
|
// canonical selectedSession (the URL effect synthesizes one on the
|
||||||
if (pendingViewSessionRef.current) {
|
// next render). Keep the active view intact instead of wiping it.
|
||||||
|
if (currentSessionId && processingSessionsRef.current?.has(currentSessionId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
pendingViewSessionRef.current = null;
|
|
||||||
setClaudeStatus(null);
|
|
||||||
setCanAbortSession(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
sessionStorage.removeItem('cursorSessionId');
|
|
||||||
messagesOffsetRef.current = 0;
|
messagesOffsetRef.current = 0;
|
||||||
setHasMoreMessages(false);
|
setHasMoreMessages(false);
|
||||||
setTotalMessages(0);
|
setTotalMessages(0);
|
||||||
@@ -415,20 +452,33 @@ export function useChatSessionState({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude';
|
const selectedSessionId = selectedSession.id;
|
||||||
const sessionKey = `${selectedSession.id}:${selectedProject.projectId}:${provider}`;
|
const sessionKey = `${selectedSessionId}:${selectedProject.projectId}`;
|
||||||
|
|
||||||
|
const subscribeToSelectedSession = () => {
|
||||||
|
if (!ws) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCheckSentAtRef.current.set(selectedSessionId, Date.now());
|
||||||
|
sendMessage({
|
||||||
|
type: 'chat.subscribe',
|
||||||
|
sessions: [{
|
||||||
|
sessionId: selectedSessionId,
|
||||||
|
lastSeq: lastSeqRef.current.get(selectedSessionId) ?? 0,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Skip if already loaded and fresh
|
// Skip if already loaded and fresh
|
||||||
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) {
|
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSessionId) && !sessionStore.isStale(selectedSessionId)) {
|
||||||
|
subscribeToSelectedSession();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSessionId;
|
||||||
if (sessionChanged) {
|
if (sessionChanged) {
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
pendingViewSessionRef.current = null;
|
|
||||||
setClaudeStatus(null);
|
|
||||||
setCanAbortSession(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset pagination/scroll state
|
// Reset pagination/scroll state
|
||||||
@@ -447,27 +497,22 @@ export function useChatSessionState({
|
|||||||
|
|
||||||
if (sessionChanged) {
|
if (sessionChanged) {
|
||||||
setTokenBudget(null);
|
setTokenBudget(null);
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentSessionId(selectedSession.id);
|
setCurrentSessionId(selectedSessionId);
|
||||||
if (provider === 'cursor') {
|
|
||||||
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check session status
|
// Subscribe to the session's live run (if any): the ack reconciles the
|
||||||
if (ws) {
|
// processing indicator, re-attaches a mid-flight stream to this socket,
|
||||||
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });
|
// and replays any live events missed since `lastSeq`. Recording the send
|
||||||
}
|
// time lets the ack handler discard idle acks that a newer request has
|
||||||
|
// since outdated.
|
||||||
|
subscribeToSelectedSession();
|
||||||
|
|
||||||
lastLoadedSessionKeyRef.current = sessionKey;
|
lastLoadedSessionKeyRef.current = sessionKey;
|
||||||
|
|
||||||
// Fetch from server → store updates → chatMessages re-derives automatically
|
// Fetch from server → store updates → chatMessages re-derives automatically
|
||||||
setIsLoadingSessionMessages(true);
|
setIsLoadingSessionMessages(true);
|
||||||
sessionStore.fetchFromServer(selectedSession.id, {
|
sessionStore.fetchFromServer(selectedSessionId, {
|
||||||
provider: (selectedSession.__provider || provider) as LLMProvider,
|
|
||||||
projectId: selectedProject.projectId,
|
|
||||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
|
||||||
limit: MESSAGES_PER_PAGE,
|
limit: MESSAGES_PER_PAGE,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
}).then(slot => {
|
}).then(slot => {
|
||||||
@@ -481,11 +526,12 @@ export function useChatSessionState({
|
|||||||
setIsLoadingSessionMessages(false);
|
setIsLoadingSessionMessages(false);
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
pendingViewSessionRef,
|
|
||||||
resetStreamingState,
|
resetStreamingState,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession?.id,
|
selectedSession?.id,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
statusCheckSentAtRef,
|
||||||
|
lastSeqRef,
|
||||||
ws,
|
ws,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
]);
|
]);
|
||||||
@@ -496,15 +542,9 @@ export function useChatSessionState({
|
|||||||
|
|
||||||
const reloadExternalMessages = async () => {
|
const reloadExternalMessages = async () => {
|
||||||
try {
|
try {
|
||||||
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
|
||||||
|
|
||||||
// Skip store refresh during active streaming
|
// Skip store refresh during active streaming
|
||||||
if (!isLoading) {
|
if (!isProcessing) {
|
||||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
await sessionStore.refreshFromServer(selectedSession.id);
|
||||||
provider: (selectedSession.__provider || provider) as LLMProvider,
|
|
||||||
projectId: selectedProject.projectId,
|
|
||||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Boolean(autoScrollToBottom) && isNearBottom()) {
|
if (Boolean(autoScrollToBottom) && isNearBottom()) {
|
||||||
setTimeout(() => scrollToBottom(), 200);
|
setTimeout(() => scrollToBottom(), 200);
|
||||||
@@ -524,7 +564,7 @@ export function useChatSessionState({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
isLoading,
|
isProcessing,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Search navigation target
|
// Search navigation target
|
||||||
@@ -550,13 +590,9 @@ export function useChatSessionState({
|
|||||||
|
|
||||||
const scrollToTarget = async () => {
|
const scrollToTarget = async () => {
|
||||||
if (!allMessagesLoadedRef.current && selectedSession && selectedProject) {
|
if (!allMessagesLoadedRef.current && selectedSession && selectedProject) {
|
||||||
const sessionProvider = selectedSession.__provider || 'claude';
|
|
||||||
try {
|
try {
|
||||||
// Load all messages into the store for search navigation
|
// Load all messages into the store for search navigation
|
||||||
const slot = await sessionStore.fetchFromServer(selectedSession.id, {
|
const slot = await sessionStore.fetchFromServer(selectedSession.id, {
|
||||||
provider: sessionProvider as LLMProvider,
|
|
||||||
projectId: selectedProject.projectId,
|
|
||||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
|
||||||
limit: null,
|
limit: null,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
});
|
});
|
||||||
@@ -630,17 +666,10 @@ export function useChatSessionState({
|
|||||||
setTokenBudget(null);
|
setTokenBudget(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sessionProvider = selectedSession.__provider || 'claude';
|
|
||||||
if (sessionProvider !== 'claude' && sessionProvider !== 'codex' && sessionProvider !== 'gemini' && sessionProvider !== 'opencode') {
|
|
||||||
setTokenBudget(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchInitialTokenUsage = async () => {
|
const fetchInitialTokenUsage = async () => {
|
||||||
try {
|
try {
|
||||||
// Token usage endpoint is now keyed by the DB projectId.
|
// The backend resolves the provider from the indexed session row.
|
||||||
const params = new URLSearchParams({ provider: sessionProvider });
|
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`;
|
||||||
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage?${params.toString()}`;
|
|
||||||
const response = await authenticatedFetch(url);
|
const response = await authenticatedFetch(url);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setTokenBudget(await response.json());
|
setTokenBudget(await response.json());
|
||||||
@@ -652,7 +681,7 @@ export function useChatSessionState({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchInitialTokenUsage();
|
fetchInitialTokenUsage();
|
||||||
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
|
}, [selectedProject, selectedSession?.id]);
|
||||||
|
|
||||||
const visibleMessages = useMemo(() => {
|
const visibleMessages = useMemo(() => {
|
||||||
if (chatMessages.length <= visibleMessageCount) return chatMessages;
|
if (chatMessages.length <= visibleMessageCount) return chatMessages;
|
||||||
@@ -691,16 +720,6 @@ 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(() => {
|
||||||
@@ -722,8 +741,6 @@ export function useChatSessionState({
|
|||||||
const loadAllMessages = useCallback(async () => {
|
const loadAllMessages = useCallback(async () => {
|
||||||
if (!selectedSession || !selectedProject) return;
|
if (!selectedSession || !selectedProject) return;
|
||||||
if (isLoadingAllMessages) return;
|
if (isLoadingAllMessages) return;
|
||||||
const sessionProvider = selectedSession.__provider || 'claude';
|
|
||||||
|
|
||||||
const requestSessionId = selectedSession.id;
|
const requestSessionId = selectedSession.id;
|
||||||
allMessagesLoadedRef.current = true;
|
allMessagesLoadedRef.current = true;
|
||||||
isLoadingMoreRef.current = true;
|
isLoadingMoreRef.current = true;
|
||||||
@@ -736,9 +753,6 @@ export function useChatSessionState({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const slot = await sessionStore.fetchFromServer(requestSessionId, {
|
const slot = await sessionStore.fetchFromServer(requestSessionId, {
|
||||||
provider: sessionProvider as LLMProvider,
|
|
||||||
projectId: selectedProject.projectId,
|
|
||||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
|
||||||
limit: null,
|
limit: null,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
});
|
});
|
||||||
@@ -782,16 +796,15 @@ export function useChatSessionState({
|
|||||||
addMessage,
|
addMessage,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
rewindMessages,
|
rewindMessages,
|
||||||
isLoading,
|
sessionActivity,
|
||||||
setIsLoading,
|
isProcessing,
|
||||||
|
canAbortSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
isLoadingSessionMessages,
|
isLoadingSessionMessages,
|
||||||
isLoadingMoreMessages,
|
isLoadingMoreMessages,
|
||||||
hasMoreMessages,
|
hasMoreMessages,
|
||||||
totalMessages,
|
totalMessages,
|
||||||
canAbortSession,
|
|
||||||
setCanAbortSession,
|
|
||||||
isUserScrolledUp,
|
isUserScrolledUp,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
@@ -804,8 +817,6 @@ export function useChatSessionState({
|
|||||||
isLoadingAllMessages,
|
isLoadingAllMessages,
|
||||||
loadAllJustFinished,
|
loadAllJustFinished,
|
||||||
showLoadAllOverlay,
|
showLoadAllOverlay,
|
||||||
claudeStatus,
|
|
||||||
setClaudeStatus,
|
|
||||||
createDiff,
|
createDiff,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -102,20 +107,24 @@ export type SessionNavigationOptions = {
|
|||||||
replace?: boolean;
|
replace?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SessionEstablishedContext = {
|
||||||
|
provider: LLMProvider;
|
||||||
|
project: Project;
|
||||||
|
summary?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ChatInterfaceProps {
|
export interface ChatInterfaceProps {
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
ws: WebSocket | null;
|
ws: WebSocket | null;
|
||||||
sendMessage: (message: unknown) => void;
|
sendMessage: (message: unknown) => void;
|
||||||
latestMessage: any;
|
|
||||||
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
||||||
onInputFocusChange?: (focused: boolean) => void;
|
onInputFocusChange?: (focused: boolean) => void;
|
||||||
onSessionActive?: (sessionId?: string | null) => void;
|
onSessionProcessing?: MarkSessionProcessing;
|
||||||
onSessionInactive?: (sessionId?: string | null) => void;
|
onSessionIdle?: MarkSessionIdle;
|
||||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
processingSessions?: SessionActivityMap;
|
||||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
|
||||||
processingSessions?: Set<string>;
|
|
||||||
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||||
|
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
autoExpandTools?: boolean;
|
autoExpandTools?: boolean;
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||||
|
import { useWebSocket } from '../../../contexts/WebSocketContext';
|
||||||
import PermissionContext from '../../../contexts/PermissionContext';
|
import PermissionContext from '../../../contexts/PermissionContext';
|
||||||
import { QuickSettingsPanel } from '../../quick-settings-panel';
|
import { QuickSettingsPanel } from '../../quick-settings-panel';
|
||||||
import type { ChatInterfaceProps, Provider } from '../types/types';
|
import type { ChatInterfaceProps, Provider } from '../types/types';
|
||||||
import type { LLMProvider } from '../../../types/app';
|
|
||||||
import { useChatProviderState } from '../hooks/useChatProviderState';
|
import { useChatProviderState } from '../hooks/useChatProviderState';
|
||||||
import { useChatSessionState } from '../hooks/useChatSessionState';
|
import { useChatSessionState } from '../hooks/useChatSessionState';
|
||||||
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
|
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
|
||||||
@@ -17,24 +17,18 @@ 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,
|
||||||
ws,
|
ws,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
latestMessage,
|
|
||||||
onFileOpen,
|
onFileOpen,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
onSessionActive,
|
|
||||||
onSessionInactive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
|
onSessionEstablished,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
autoExpandTools,
|
autoExpandTools,
|
||||||
showRawParameters,
|
showRawParameters,
|
||||||
@@ -46,12 +40,19 @@ function ChatInterface({
|
|||||||
onShowAllTasks,
|
onShowAllTasks,
|
||||||
}: ChatInterfaceProps) {
|
}: ChatInterfaceProps) {
|
||||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
||||||
|
const { subscribe } = useWebSocket();
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
|
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
const streamTimerRef = useRef<number | null>(null);
|
const streamTimerRef = useRef<number | null>(null);
|
||||||
const accumulatedStreamRef = useRef('');
|
const accumulatedStreamRef = useRef('');
|
||||||
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
|
// When each session's `chat.subscribe` was last sent; idle acks older than
|
||||||
|
// a later local request are discarded as stale.
|
||||||
|
const statusCheckSentAtRef = useRef(new Map<string, number>());
|
||||||
|
// Highest live `seq` observed per session. Written by the realtime handler
|
||||||
|
// on every sequenced frame, read whenever a `chat.subscribe` is sent so the
|
||||||
|
// server replays only the events this client actually missed.
|
||||||
|
const lastSeqRef = useRef(new Map<string, number>());
|
||||||
|
|
||||||
const resetStreamingState = useCallback(() => {
|
const resetStreamingState = useCallback(() => {
|
||||||
if (streamTimerRef.current) {
|
if (streamTimerRef.current) {
|
||||||
@@ -92,16 +93,15 @@ function ChatInterface({
|
|||||||
const {
|
const {
|
||||||
chatMessages,
|
chatMessages,
|
||||||
addMessage,
|
addMessage,
|
||||||
isLoading,
|
sessionActivity,
|
||||||
setIsLoading,
|
isProcessing,
|
||||||
|
canAbortSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
isLoadingSessionMessages,
|
isLoadingSessionMessages,
|
||||||
isLoadingMoreMessages,
|
isLoadingMoreMessages,
|
||||||
hasMoreMessages,
|
hasMoreMessages,
|
||||||
totalMessages,
|
totalMessages,
|
||||||
canAbortSession,
|
|
||||||
setCanAbortSession,
|
|
||||||
isUserScrolledUp,
|
isUserScrolledUp,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
@@ -114,8 +114,6 @@ function ChatInterface({
|
|||||||
isLoadingAllMessages,
|
isLoadingAllMessages,
|
||||||
loadAllJustFinished,
|
loadAllJustFinished,
|
||||||
showLoadAllOverlay,
|
showLoadAllOverlay,
|
||||||
claudeStatus,
|
|
||||||
setClaudeStatus,
|
|
||||||
createDiff,
|
createDiff,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
@@ -130,11 +128,22 @@ function ChatInterface({
|
|||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
|
onSessionIdle,
|
||||||
resetStreamingState,
|
resetStreamingState,
|
||||||
pendingViewSessionRef,
|
statusCheckSentAtRef,
|
||||||
|
lastSeqRef,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Brand-new conversation: the composer allocated a stable session id via
|
||||||
|
// the session gateway before the first send. Record it locally and put it
|
||||||
|
// in the URL — this id never changes again, so there is no later handoff.
|
||||||
|
const handleSessionEstablished = useCallback<NonNullable<ChatInterfaceProps['onSessionEstablished']>>((sessionId, context) => {
|
||||||
|
setCurrentSessionId(sessionId);
|
||||||
|
onSessionEstablished?.(sessionId, context);
|
||||||
|
onNavigateToSession?.(sessionId);
|
||||||
|
}, [setCurrentSessionId, onSessionEstablished, onNavigateToSession]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
input,
|
input,
|
||||||
setInput,
|
setInput,
|
||||||
@@ -191,66 +200,58 @@ function ChatInterface({
|
|||||||
codexModel,
|
codexModel,
|
||||||
geminiModel,
|
geminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
isLoading,
|
isLoading: isProcessing,
|
||||||
canAbortSession,
|
canAbortSession,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
onSessionActive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
|
onSessionEstablished: handleSessionEstablished,
|
||||||
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 server
|
// On WebSocket reconnect, re-fetch the current session's messages from the
|
||||||
// so missed streaming events are shown. Also reset isLoading.
|
// server so missed streaming events are shown, then re-subscribe — the
|
||||||
|
// `chat_subscribed` ack restores or clears the activity indicator, replays
|
||||||
|
// missed live events, and re-attaches a still-running stream to this socket.
|
||||||
const handleWebSocketReconnect = useCallback(async () => {
|
const handleWebSocketReconnect = useCallback(async () => {
|
||||||
if (!selectedProject || !selectedSession) return;
|
if (!selectedProject || !selectedSession) return;
|
||||||
const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
|
await sessionStore.refreshFromServer(selectedSession.id);
|
||||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
|
||||||
provider: (selectedSession.__provider || providerVal) as LLMProvider,
|
sendMessage({
|
||||||
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
|
type: 'chat.subscribe',
|
||||||
projectId: selectedProject.projectId,
|
sessions: [{
|
||||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
sessionId: selectedSession.id,
|
||||||
|
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
setIsLoading(false);
|
}, [selectedProject, selectedSession, sendMessage, sessionStore]);
|
||||||
setCanAbortSession(false);
|
|
||||||
}, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]);
|
|
||||||
|
|
||||||
useChatRealtimeHandlers({
|
useChatRealtimeHandlers({
|
||||||
latestMessage,
|
subscribe,
|
||||||
provider,
|
provider,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
|
||||||
setIsLoading,
|
|
||||||
setCanAbortSession,
|
|
||||||
setClaudeStatus,
|
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
onSessionInactive,
|
lastSeqRef,
|
||||||
onSessionActive,
|
statusCheckSentAtRef,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
onNavigateToSession,
|
|
||||||
onWebSocketReconnect: handleWebSocketReconnect,
|
onWebSocketReconnect: handleWebSocketReconnect,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading || !canAbortSession) {
|
if (!canAbortSession) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +268,7 @@ function ChatInterface({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
|
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
|
||||||
};
|
};
|
||||||
}, [canAbortSession, handleAbortSession, isLoading]);
|
}, [canAbortSession, handleAbortSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -314,6 +315,7 @@ function ChatInterface({
|
|||||||
onWheel={handleScroll}
|
onWheel={handleScroll}
|
||||||
onTouchMove={handleScroll}
|
onTouchMove={handleScroll}
|
||||||
isLoadingSessionMessages={isLoadingSessionMessages}
|
isLoadingSessionMessages={isLoadingSessionMessages}
|
||||||
|
isProcessing={isProcessing}
|
||||||
chatMessages={chatMessages}
|
chatMessages={chatMessages}
|
||||||
selectedSession={selectedSession}
|
selectedSession={selectedSession}
|
||||||
currentSessionId={currentSessionId}
|
currentSessionId={currentSessionId}
|
||||||
@@ -362,10 +364,9 @@ function ChatInterface({
|
|||||||
pendingPermissionRequests={pendingPermissionRequests}
|
pendingPermissionRequests={pendingPermissionRequests}
|
||||||
handlePermissionDecision={handlePermissionDecision}
|
handlePermissionDecision={handlePermissionDecision}
|
||||||
handleGrantToolPermission={handleGrantToolPermission}
|
handleGrantToolPermission={handleGrantToolPermission}
|
||||||
claudeStatus={claudeStatus}
|
activity={sessionActivity}
|
||||||
isLoading={isLoading}
|
isLoading={isProcessing}
|
||||||
onAbortSession={handleAbortSession}
|
onAbortSession={handleAbortSession}
|
||||||
provider={provider}
|
|
||||||
permissionMode={permissionMode}
|
permissionMode={permissionMode}
|
||||||
onModeSwitch={cyclePermissionMode}
|
onModeSwitch={cyclePermissionMode}
|
||||||
tokenBudget={tokenBudget}
|
tokenBudget={tokenBudget}
|
||||||
|
|||||||
80
src/components/chat/view/subcomponents/ActivityIndicator.tsx
Normal file
80
src/components/chat/view/subcomponents/ActivityIndicator.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Shimmer } from '../../../../shared/view/ui';
|
||||||
|
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
||||||
|
|
||||||
|
type ActivityIndicatorProps = {
|
||||||
|
activity: SessionActivity | null;
|
||||||
|
onAbort?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_KEYS = [
|
||||||
|
'claudeStatus.actions.thinking',
|
||||||
|
'claudeStatus.actions.processing',
|
||||||
|
'claudeStatus.actions.analyzing',
|
||||||
|
'claudeStatus.actions.working',
|
||||||
|
'claudeStatus.actions.computing',
|
||||||
|
'claudeStatus.actions.reasoning',
|
||||||
|
];
|
||||||
|
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal response-in-progress indicator, in the spirit of the inline status
|
||||||
|
* lines in Claude Code / Codex / OpenCode: a shimmering activity label, the
|
||||||
|
* elapsed time, and an interrupt affordance. Rendered only while the viewed
|
||||||
|
* session has an entry in the processing map; it disappears the instant that
|
||||||
|
* entry is removed.
|
||||||
|
*/
|
||||||
|
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
const startedAt = activity?.startedAt ?? null;
|
||||||
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (startedAt === null) return;
|
||||||
|
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
|
||||||
|
update();
|
||||||
|
const timer = setInterval(update, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [startedAt]);
|
||||||
|
|
||||||
|
if (!activity) return null;
|
||||||
|
|
||||||
|
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
||||||
|
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
|
||||||
|
.replace(/\.+$/, '');
|
||||||
|
|
||||||
|
const minutes = Math.floor(elapsedSeconds / 60);
|
||||||
|
const seconds = elapsedSeconds % 60;
|
||||||
|
const elapsedLabel = minutes < 1
|
||||||
|
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
|
||||||
|
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-in fade-in mb-2 w-full duration-300">
|
||||||
|
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1">
|
||||||
|
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
|
||||||
|
<Shimmer className="text-xs font-medium">{`${label}…`}</Shimmer>
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
|
||||||
|
|
||||||
|
{activity.canInterrupt && onAbort && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAbort}
|
||||||
|
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
|
||||||
|
>
|
||||||
|
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
|
||||||
|
<rect x="5" y="5" width="14" height="14" rx="2" />
|
||||||
|
</svg>
|
||||||
|
<span>{t('claudeStatus.stop', { defaultValue: 'Stop' })}</span>
|
||||||
|
<kbd className="hidden rounded border border-border/60 px-1 text-[10px] text-muted-foreground/70 sm:inline-block">
|
||||||
|
esc
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ import type {
|
|||||||
} 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 type { PendingPermissionRequest, PermissionMode } from '../../types/types';
|
||||||
import {
|
import {
|
||||||
PromptInput,
|
PromptInput,
|
||||||
PromptInputHeader,
|
PromptInputHeader,
|
||||||
@@ -24,7 +25,7 @@ import {
|
|||||||
} from '../../../../shared/view/ui';
|
} from '../../../../shared/view/ui';
|
||||||
|
|
||||||
import CommandMenu from './CommandMenu';
|
import CommandMenu from './CommandMenu';
|
||||||
import ClaudeStatus from './ClaudeStatus';
|
import ActivityIndicator from './ActivityIndicator';
|
||||||
import ImageAttachment from './ImageAttachment';
|
import ImageAttachment from './ImageAttachment';
|
||||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||||
import TokenUsageSummary from './TokenUsageSummary';
|
import TokenUsageSummary from './TokenUsageSummary';
|
||||||
@@ -51,10 +52,9 @@ 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 };
|
||||||
claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
|
activity: SessionActivity | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onAbortSession: () => void;
|
onAbortSession: () => void;
|
||||||
provider: Provider | string;
|
|
||||||
permissionMode: PermissionMode | string;
|
permissionMode: PermissionMode | string;
|
||||||
onModeSwitch: () => void;
|
onModeSwitch: () => void;
|
||||||
tokenBudget: Record<string, unknown> | null;
|
tokenBudget: Record<string, unknown> | null;
|
||||||
@@ -105,10 +105,9 @@ export default function ChatComposer({
|
|||||||
pendingPermissionRequests,
|
pendingPermissionRequests,
|
||||||
handlePermissionDecision,
|
handlePermissionDecision,
|
||||||
handleGrantToolPermission,
|
handleGrantToolPermission,
|
||||||
claudeStatus,
|
activity,
|
||||||
isLoading,
|
isLoading,
|
||||||
onAbortSession,
|
onAbortSession,
|
||||||
provider,
|
|
||||||
permissionMode,
|
permissionMode,
|
||||||
onModeSwitch,
|
onModeSwitch,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
@@ -173,12 +172,7 @@ 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 && (
|
||||||
<ClaudeStatus
|
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
|
||||||
status={claudeStatus}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onAbort={onAbortSession}
|
|
||||||
provider={provider}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pendingPermissionRequests.length > 0 && (
|
{pendingPermissionRequests.length > 0 && (
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ interface ChatMessagesPaneProps {
|
|||||||
onWheel: () => void;
|
onWheel: () => void;
|
||||||
onTouchMove: () => void;
|
onTouchMove: () => void;
|
||||||
isLoadingSessionMessages: boolean;
|
isLoadingSessionMessages: boolean;
|
||||||
|
/** True while the viewed session has an active provider run in flight. */
|
||||||
|
isProcessing?: boolean;
|
||||||
chatMessages: ChatMessage[];
|
chatMessages: ChatMessage[];
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
@@ -68,6 +70,7 @@ export default function ChatMessagesPane({
|
|||||||
onWheel,
|
onWheel,
|
||||||
onTouchMove,
|
onTouchMove,
|
||||||
isLoadingSessionMessages,
|
isLoadingSessionMessages,
|
||||||
|
isProcessing = false,
|
||||||
chatMessages,
|
chatMessages,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
@@ -147,7 +150,7 @@ export default function ChatMessagesPane({
|
|||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
|
className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
|
||||||
>
|
>
|
||||||
{isLoadingSessionMessages && chatMessages.length === 0 ? (
|
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
|
||||||
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
|
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<div className="flex items-center justify-center space-x-2">
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-gray-400" />
|
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-gray-400" />
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { cn } from '../../../../lib/utils';
|
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
|
||||||
|
|
||||||
type ClaudeStatusProps = {
|
|
||||||
status: {
|
|
||||||
text?: string;
|
|
||||||
tokens?: number;
|
|
||||||
can_interrupt?: boolean;
|
|
||||||
} | null;
|
|
||||||
onAbort?: () => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
provider?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ACTION_KEYS = [
|
|
||||||
'claudeStatus.actions.thinking',
|
|
||||||
'claudeStatus.actions.processing',
|
|
||||||
'claudeStatus.actions.analyzing',
|
|
||||||
'claudeStatus.actions.working',
|
|
||||||
'claudeStatus.actions.computing',
|
|
||||||
'claudeStatus.actions.reasoning',
|
|
||||||
];
|
|
||||||
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
|
||||||
|
|
||||||
const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
|
||||||
claude: 'messageTypes.claude',
|
|
||||||
codex: 'messageTypes.codex',
|
|
||||||
cursor: 'messageTypes.cursor',
|
|
||||||
gemini: 'messageTypes.gemini',
|
|
||||||
opencode: 'messageTypes.opencode',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatElapsedTime(totalSeconds: number) {
|
|
||||||
const mins = Math.floor(totalSeconds / 60);
|
|
||||||
const secs = totalSeconds % 60;
|
|
||||||
return mins < 1 ? `${secs}s` : `${mins}m ${secs}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ClaudeStatus({
|
|
||||||
status,
|
|
||||||
onAbort,
|
|
||||||
isLoading,
|
|
||||||
provider = 'claude',
|
|
||||||
}: ClaudeStatusProps) {
|
|
||||||
const { t } = useTranslation('chat');
|
|
||||||
const [elapsedTime, setElapsedTime] = useState(0);
|
|
||||||
const [dots, setDots] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoading) {
|
|
||||||
setElapsedTime(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const startTime = Date.now();
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
|
|
||||||
}, 1000);
|
|
||||||
const dotTimer = setInterval(() => {
|
|
||||||
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(timer);
|
|
||||||
clearInterval(dotTimer);
|
|
||||||
};
|
|
||||||
}, [isLoading]);
|
|
||||||
|
|
||||||
if (!isLoading && !status) return null;
|
|
||||||
|
|
||||||
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
|
||||||
const statusText = (status?.text || actionWords[Math.floor(elapsedTime / 3) % actionWords.length]).replace(/[.]+$/, '');
|
|
||||||
|
|
||||||
const providerLabel = t(PROVIDER_LABEL_KEYS[provider] || 'claudeStatus.providers.assistant', { defaultValue: 'Assistant' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-2 mb-3 w-full duration-500">
|
|
||||||
<div className="mx-auto flex max-w-4xl items-center justify-between gap-3 overflow-hidden rounded-full border border-border/50 bg-slate-100 px-3 py-1.5 shadow-sm backdrop-blur-md dark:bg-slate-900">
|
|
||||||
|
|
||||||
{/* Left Side: Identity & Status */}
|
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
|
||||||
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/20 ring-1 ring-primary/10">
|
|
||||||
<SessionProviderLogo provider={provider} className="h-3.5 w-3.5" />
|
|
||||||
{isLoading && (
|
|
||||||
<span className="absolute inset-0 animate-pulse rounded-full ring-2 ring-emerald-500/20" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col sm:flex-row sm:items-center sm:gap-2">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70">
|
|
||||||
{providerLabel}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className={cn("h-1.5 w-1.5 rounded-full", isLoading ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
|
||||||
<p className="truncate text-xs font-medium text-foreground">
|
|
||||||
{statusText}<span className="inline-block w-4 text-primary">{isLoading ? dots : ''}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Side: Metrics & Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isLoading && status?.can_interrupt !== false && onAbort && (
|
|
||||||
<>
|
|
||||||
<div className="hidden items-center rounded-md bg-muted/50 px-2 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground sm:flex">
|
|
||||||
{formatElapsedTime(elapsedTime)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onAbort}
|
|
||||||
className="group flex items-center gap-1.5 rounded-full bg-destructive/10 px-2.5 py-1 text-[10px] font-bold text-destructive transition-all hover:bg-destructive hover:text-destructive-foreground"
|
|
||||||
>
|
|
||||||
<svg className="h-3 w-3 fill-current" viewBox="0 0 24 24">
|
|
||||||
<path d="M6 6h12v12H6z" />
|
|
||||||
</svg>
|
|
||||||
<span className="hidden sm:inline">STOP</span>
|
|
||||||
<kbd className="hidden rounded bg-black/10 px-1 text-[9px] group-hover:bg-white/20 sm:block">
|
|
||||||
ESC
|
|
||||||
</kbd>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 flex-shrink-0 ${editorExpanded ? 'flex-1' : ''}`}>
|
<div ref={containerRef} className={`flex h-full min-w-0 ${editorExpanded ? 'flex-1' : ''}`}>
|
||||||
{!editorExpanded && (
|
{!editorExpanded && (
|
||||||
<div
|
<div
|
||||||
ref={resizeHandleRef}
|
ref={resizeHandleRef}
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ export type SessionResult = {
|
|||||||
|
|
||||||
interface SessionsResponse {
|
interface SessionsResponse {
|
||||||
sessions?: ProjectSession[];
|
sessions?: ProjectSession[];
|
||||||
cursorSessions?: ProjectSession[];
|
|
||||||
codexSessions?: ProjectSession[];
|
|
||||||
geminiSessions?: ProjectSession[];
|
|
||||||
opencodeSessions?: ProjectSession[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSessionsSource(projectId: string | undefined, enabled: boolean) {
|
export function useSessionsSource(projectId: string | undefined, enabled: boolean) {
|
||||||
@@ -29,17 +25,10 @@ export function useSessionsSource(projectId: string | undefined, enabled: boolea
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
parse: (data) => {
|
parse: (data) => {
|
||||||
const all: ProjectSession[] = [
|
return (data.sessions ?? []).map<SessionResult>((s) => ({
|
||||||
...(data.sessions ?? []),
|
|
||||||
...(data.cursorSessions ?? []),
|
|
||||||
...(data.codexSessions ?? []),
|
|
||||||
...(data.geminiSessions ?? []),
|
|
||||||
...(data.opencodeSessions ?? []),
|
|
||||||
];
|
|
||||||
return all.map<SessionResult>((s) => ({
|
|
||||||
id: s.id,
|
id: s.id,
|
||||||
label: (s.title || s.summary || s.name || s.id) as string,
|
label: (s.title || s.summary || s.name || s.id) as string,
|
||||||
provider: s.__provider,
|
provider: (s.__provider || s.provider) as LLMProvider | undefined,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
type ClaudeLogoProps = {
|
type ClaudeLogoProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => {
|
const ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => (
|
||||||
return (
|
<svg
|
||||||
<img src="/icons/claude-ai-icon.svg" alt="Claude" className={className} />
|
viewBox="0 0 512 509.64"
|
||||||
);
|
role="img"
|
||||||
};
|
aria-label="Claude"
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#D77655"
|
||||||
|
d="M115.612 0h280.775C459.974 0 512 52.026 512 115.612v278.415c0 63.587-52.026 115.612-115.613 115.612H115.612C52.026 509.639 0 457.614 0 394.027V115.612C0 52.026 52.026 0 115.612 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FCF2EE"
|
||||||
|
fillRule="nonzero"
|
||||||
|
d="M142.27 316.619l73.655-41.326 1.238-3.589-1.238-1.996-3.589-.001-12.31-.759-42.084-1.138-36.498-1.516-35.361-1.896-8.897-1.895-8.34-10.995.859-5.484 7.482-5.03 10.717.935 23.683 1.617 35.537 2.452 25.782 1.517 38.193 3.968h6.064l.86-2.451-2.073-1.517-1.618-1.517-36.776-24.922-39.81-26.338-20.852-15.166-11.273-7.683-5.687-7.204-2.451-15.721 10.237-11.273 13.75.935 3.513.936 13.928 10.716 29.749 23.027 38.848 28.612 5.687 4.727 2.275-1.617.278-1.138-2.553-4.271-21.13-38.193-22.546-38.848-10.035-16.101-2.654-9.655c-.935-3.968-1.617-7.304-1.617-11.374l11.652-15.823 6.445-2.073 15.545 2.073 6.547 5.687 9.655 22.092 15.646 34.78 24.265 47.291 7.103 14.028 3.791 12.992 1.416 3.968 2.449-.001v-2.275l1.997-26.641 3.69-32.707 3.589-42.084 1.239-11.854 5.863-14.206 11.652-7.683 9.099 4.348 7.482 10.716-1.036 6.926-4.449 28.915-8.72 45.294-5.687 30.331h3.313l3.792-3.791 15.342-20.372 25.782-32.227 11.374-12.789 13.27-14.129 8.517-6.724 16.1-.001 11.854 17.617-5.307 18.199-16.581 21.029-13.75 17.819-19.716 26.54-12.309 21.231 1.138 1.694 2.932-.278 44.536-9.479 24.062-4.347 28.714-4.928 12.992 6.066 1.416 6.167-5.106 12.613-30.71 7.583-36.018 7.204-53.636 12.689-.657.48.758.935 24.164 2.275 10.337.556h25.301l47.114 3.514 12.309 8.139 7.381 9.959-1.238 7.583-18.957 9.655-25.579-6.066-59.702-14.205-20.474-5.106-2.83-.001v1.694l17.061 16.682 31.266 28.233 39.152 36.397 1.997 8.999-5.03 7.102-5.307-.758-34.401-25.883-13.27-11.651-30.053-25.302-1.996-.001v2.654l6.926 10.136 36.574 54.975 1.895 16.859-2.653 5.485-9.479 3.311-10.414-1.895-21.408-30.054-22.092-33.844-17.819-30.331-2.173 1.238-10.515 113.261-4.929 5.788-11.374 4.348-9.478-7.204-5.03-11.652 5.03-23.027 6.066-30.052 4.928-23.886 4.449-29.674 2.654-9.858-.177-.657-2.173.278-22.37 30.71-34.021 45.977-26.919 28.815-6.445 2.553-11.173-5.789 1.037-10.337 6.243-9.2 37.257-47.392 22.47-29.371 14.508-16.961-.101-2.451h-.859l-98.954 64.251-17.618 2.275-7.583-7.103.936-11.652 3.589-3.791 29.749-20.474-.101.102.024.101z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default ClaudeLogo;
|
export default ClaudeLogo;
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
|
||||||
|
|
||||||
type CodexLogoProps = {
|
type CodexLogoProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CodexLogo = ({ className = 'w-5 h-5' }: CodexLogoProps) => {
|
const CodexLogo = ({ className = 'w-5 h-5' }: CodexLogoProps) => (
|
||||||
const { isDarkMode } = useTheme();
|
<svg
|
||||||
|
viewBox="100 100 520 520"
|
||||||
return (
|
role="img"
|
||||||
<img
|
aria-label="Codex"
|
||||||
src={isDarkMode ? "/icons/codex-white.svg" : "/icons/codex.svg"}
|
className={className}
|
||||||
alt="Codex"
|
fill="none"
|
||||||
className={className}
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M304.246 294.611V249.028C304.246 245.189 305.687 242.309 309.044 240.392L400.692 187.612C413.167 180.415 428.042 177.058 443.394 177.058C500.971 177.058 537.44 221.682 537.44 269.182C537.44 272.54 537.44 276.379 536.959 280.218L441.954 224.558C436.197 221.201 430.437 221.201 424.68 224.558L304.246 294.611ZM518.245 472.145V363.224C518.245 356.505 515.364 351.707 509.608 348.349L389.174 278.296L428.519 255.743C431.877 253.826 434.757 253.826 438.115 255.743L529.762 308.523C556.154 323.879 573.905 356.505 573.905 388.171C573.905 424.636 552.315 458.225 518.245 472.141V472.145ZM275.937 376.182L236.592 353.152C233.235 351.235 231.794 348.354 231.794 344.515V238.956C231.794 187.617 271.139 148.749 324.4 148.749C344.555 148.749 363.264 155.468 379.102 167.463L284.578 222.164C278.822 225.521 275.942 230.319 275.942 237.039V376.186L275.937 376.182ZM360.626 425.122L304.246 393.455V326.283L360.626 294.616L417.002 326.283V393.455L360.626 425.122ZM396.852 570.989C376.698 570.989 357.989 564.27 342.151 552.276L436.674 497.574C442.431 494.217 445.311 489.419 445.311 482.699V343.552L485.138 366.582C488.495 368.499 489.936 371.379 489.936 375.219V480.778C489.936 532.117 450.109 570.985 396.852 570.985V570.989ZM283.134 463.99L191.486 411.211C165.094 395.854 147.343 363.229 147.343 331.562C147.343 294.616 169.415 261.509 203.48 247.593V356.991C203.48 363.71 206.361 368.508 212.117 371.866L332.074 441.437L292.729 463.99C289.372 465.907 286.491 465.907 283.134 463.99ZM277.859 542.68C223.639 542.68 183.813 501.895 183.813 451.514C183.813 447.675 184.294 443.836 184.771 439.997L279.295 494.698C285.051 498.056 290.812 498.056 296.568 494.698L417.002 425.127V470.71C417.002 474.549 415.562 477.429 412.204 479.346L320.557 532.126C308.081 539.323 293.206 542.68 277.854 542.68H277.859ZM396.852 599.776C454.911 599.776 503.37 558.513 514.41 503.812C568.149 489.896 602.696 439.515 602.696 388.176C602.696 354.587 588.303 321.962 562.392 298.45C564.791 288.373 566.231 278.296 566.231 268.224C566.231 199.611 510.571 148.267 446.274 148.267C433.322 148.267 420.846 150.184 408.37 154.505C386.775 133.392 357.026 119.958 324.4 119.958C266.342 119.958 217.883 161.22 206.843 215.921C153.104 229.837 118.557 280.218 118.557 331.557C118.557 365.146 132.95 397.771 158.861 421.283C156.462 431.36 155.022 441.437 155.022 451.51C155.022 520.123 210.682 571.466 274.978 571.466C287.931 571.466 300.407 569.549 312.883 565.228C334.473 586.341 364.222 599.776 396.852 599.776Z"
|
||||||
|
fill="currentColor"
|
||||||
/>
|
/>
|
||||||
);
|
</svg>
|
||||||
};
|
);
|
||||||
|
|
||||||
export default CodexLogo;
|
export default CodexLogo;
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
|
||||||
|
|
||||||
type CursorLogoProps = {
|
type CursorLogoProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CursorLogo = ({ className = 'w-5 h-5' }: CursorLogoProps) => {
|
const CursorLogo = ({ className = 'w-5 h-5' }: CursorLogoProps) => (
|
||||||
const { isDarkMode } = useTheme();
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
return (
|
role="img"
|
||||||
<img
|
aria-label="Cursor"
|
||||||
src={isDarkMode ? "/icons/cursor-white.svg" : "/icons/cursor.svg"}
|
className={className}
|
||||||
alt="Cursor"
|
fill="none"
|
||||||
className={className}
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11.925 24l10.425-6-10.425-6L1.5 18l10.425 6z"
|
||||||
|
fill="currentColor"
|
||||||
|
opacity=".39"
|
||||||
/>
|
/>
|
||||||
);
|
<path d="M22.35 18V6L11.925 0v12l10.425 6z" fill="currentColor" opacity=".8" />
|
||||||
};
|
<path d="M11.925 0L1.5 6v12l10.425-6V0z" fill="currentColor" opacity=".6" />
|
||||||
|
<path d="M22.35 6L11.925 24V12L22.35 6z" fill="currentColor" opacity=".72" />
|
||||||
|
<path d="M22.35 6l-10.425 6L1.5 6h20.85z" fill="currentColor" opacity=".95" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default CursorLogo;
|
export default CursorLogo;
|
||||||
|
|||||||
@@ -1,7 +1,263 @@
|
|||||||
const GeminiLogo = ({className = 'w-5 h-5'}) => {
|
import { useId } from 'react';
|
||||||
|
|
||||||
|
type GeminiLogoProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GeminiLogo = ({ className = 'w-5 h-5' }: GeminiLogoProps) => {
|
||||||
|
const id = useId().replace(/:/g, '');
|
||||||
|
const maskId = `${id}-gemini-mask`;
|
||||||
|
const gradientId = `${id}-gemini-gradient`;
|
||||||
|
const filterIds = Array.from({ length: 11 }, (_, index) => `${id}-gemini-filter-${index}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img src="/icons/gemini-ai-icon.svg" alt="Gemini" className={className} />
|
<svg
|
||||||
|
viewBox="0 0 65 65"
|
||||||
|
role="img"
|
||||||
|
aria-label="Gemini"
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<mask
|
||||||
|
id={maskId}
|
||||||
|
style={{ maskType: 'alpha' }}
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="65"
|
||||||
|
height="65"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M32.447 0c.68 0 1.273.465 1.439 1.125a38.904 38.904 0 001.999 5.905c2.152 5 5.105 9.376 8.854 13.125 3.751 3.75 8.126 6.703 13.125 8.855a38.98 38.98 0 005.906 1.999c.66.166 1.124.758 1.124 1.438 0 .68-.464 1.273-1.125 1.439a38.902 38.902 0 00-5.905 1.999c-5 2.152-9.375 5.105-13.125 8.854-3.749 3.751-6.702 8.126-8.854 13.125a38.973 38.973 0 00-2 5.906 1.485 1.485 0 01-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a38.913 38.913 0 00-2-5.905c-2.151-5-5.103-9.375-8.854-13.125-3.75-3.749-8.125-6.702-13.125-8.854a38.973 38.973 0 00-5.905-2A1.485 1.485 0 010 32.448c0-.68.465-1.272 1.125-1.438a38.903 38.903 0 005.905-2c5-2.151 9.376-5.104 13.125-8.854 3.75-3.749 6.703-8.125 8.855-13.125a38.972 38.972 0 001.999-5.905A1.485 1.485 0 0132.447 0z"
|
||||||
|
fill="#000"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M32.447 0c.68 0 1.273.465 1.439 1.125a38.904 38.904 0 001.999 5.905c2.152 5 5.105 9.376 8.854 13.125 3.751 3.75 8.126 6.703 13.125 8.855a38.98 38.98 0 005.906 1.999c.66.166 1.124.758 1.124 1.438 0 .68-.464 1.273-1.125 1.439a38.902 38.902 0 00-5.905 1.999c-5 2.152-9.375 5.105-13.125 8.854-3.749 3.751-6.702 8.126-8.854 13.125a38.973 38.973 0 00-2 5.906 1.485 1.485 0 01-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a38.913 38.913 0 00-2-5.905c-2.151-5-5.103-9.375-8.854-13.125-3.75-3.749-8.125-6.702-13.125-8.854a38.973 38.973 0 00-5.905-2A1.485 1.485 0 010 32.448c0-.68.465-1.272 1.125-1.438a38.903 38.903 0 005.905-2c5-2.151 9.376-5.104 13.125-8.854 3.75-3.749 6.703-8.125 8.855-13.125a38.972 38.972 0 001.999-5.905A1.485 1.485 0 0132.447 0z"
|
||||||
|
fill={`url(#${gradientId})`}
|
||||||
|
/>
|
||||||
|
</mask>
|
||||||
|
<g mask={`url(#${maskId})`}>
|
||||||
|
<g filter={`url(#${filterIds[0]})`}>
|
||||||
|
<path
|
||||||
|
d="M-5.859 50.734c7.498 2.663 16.116-2.33 19.249-11.152 3.133-8.821-.406-18.131-7.904-20.794-7.498-2.663-16.116 2.33-19.25 11.151-3.132 8.822.407 18.132 7.905 20.795z"
|
||||||
|
fill="#FFE432"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g filter={`url(#${filterIds[1]})`}>
|
||||||
|
<path
|
||||||
|
d="M27.433 21.649c10.3 0 18.651-8.535 18.651-19.062 0-10.528-8.35-19.062-18.651-19.062S8.78-7.94 8.78 2.587c0 10.527 8.35 19.062 18.652 19.062z"
|
||||||
|
fill="#FC413D"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g filter={`url(#${filterIds[2]})`}>
|
||||||
|
<path
|
||||||
|
d="M20.184 82.608c10.753-.525 18.918-12.244 18.237-26.174-.68-13.93-9.95-24.797-20.703-24.271C6.965 32.689-1.2 44.407-.519 58.337c.681 13.93 9.95 24.797 20.703 24.271z"
|
||||||
|
fill="#00B95C"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g filter={`url(#${filterIds[3]})`}>
|
||||||
|
<path
|
||||||
|
d="M20.184 82.608c10.753-.525 18.918-12.244 18.237-26.174-.68-13.93-9.95-24.797-20.703-24.271C6.965 32.689-1.2 44.407-.519 58.337c.681 13.93 9.95 24.797 20.703 24.271z"
|
||||||
|
fill="#00B95C"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g filter={`url(#${filterIds[4]})`}>
|
||||||
|
<path
|
||||||
|
d="M30.954 74.181c9.014-5.485 11.427-17.976 5.389-27.9-6.038-9.925-18.241-13.524-27.256-8.04-9.015 5.486-11.428 17.977-5.39 27.902 6.04 9.924 18.242 13.523 27.257 8.038z"
|
||||||
|
fill="#00B95C"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g filter={`url(#${filterIds[5]})`}>
|
||||||
|
<path
|
||||||
|
d="M67.391 42.993c10.132 0 18.346-7.91 18.346-17.666 0-9.757-8.214-17.667-18.346-17.667s-18.346 7.91-18.346 17.667c0 9.757 8.214 17.666 18.346 17.666z"
|
||||||
|
fill="#3186FF"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g filter={`url(#${filterIds[6]})`}>
|
||||||
|
<path
|
||||||
|
d="M-13.065 40.944c9.33 7.094 22.959 4.869 30.442-4.972 7.483-9.84 5.987-23.569-3.343-30.663C4.704-1.786-8.924.439-16.408 10.28c-7.483 9.84-5.986 23.57 3.343 30.664z"
|
||||||
|
fill="#FBBC04"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g filter={`url(#${filterIds[7]})`}>
|
||||||
|
<path
|
||||||
|
d="M34.74 51.43c11.135 7.656 25.896 5.524 32.968-4.764 7.073-10.287 3.779-24.832-7.357-32.488C49.215 6.52 34.455 8.654 27.382 18.94c-7.072 10.288-3.779 24.833 7.357 32.49z"
|
||||||
|
fill="#3186FF"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g filter={`url(#${filterIds[8]})`}>
|
||||||
|
<path
|
||||||
|
d="M54.984-2.336c2.833 3.852-.808 11.34-8.131 16.727-7.324 5.387-15.557 6.631-18.39 2.78-2.833-3.853.807-11.342 8.13-16.728 7.324-5.387 15.558-6.631 18.39-2.78z"
|
||||||
|
fill="#749BFF"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g filter={`url(#${filterIds[9]})`}>
|
||||||
|
<path
|
||||||
|
d="M31.727 16.104C43.053 5.598 46.94-8.626 40.41-15.666c-6.53-7.04-21.006-4.232-32.332 6.274s-15.214 24.73-8.683 31.77c6.53 7.04 21.006 4.232 32.332-6.274z"
|
||||||
|
fill="#FC413D"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g filter={`url(#${filterIds[10]})`}>
|
||||||
|
<path
|
||||||
|
d="M8.51 53.838c6.732 4.818 14.46 5.55 17.262 1.636 2.802-3.915-.384-10.994-7.116-15.812-6.731-4.818-14.46-5.55-17.261-1.636-2.802 3.915.383 10.994 7.115 15.812z"
|
||||||
|
fill="#FFEE48"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
id={filterIds[0]}
|
||||||
|
x="-19.824"
|
||||||
|
y="13.152"
|
||||||
|
width="39.274"
|
||||||
|
height="43.217"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur stdDeviation="2.46" result="effect1_foregroundBlur_2001_67" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id={filterIds[1]}
|
||||||
|
x="-15.001"
|
||||||
|
y="-40.257"
|
||||||
|
width="84.868"
|
||||||
|
height="85.688"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur stdDeviation="11.891" result="effect1_foregroundBlur_2001_67" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id={filterIds[2]}
|
||||||
|
x="-20.776"
|
||||||
|
y="11.927"
|
||||||
|
width="79.454"
|
||||||
|
height="90.916"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id={filterIds[3]}
|
||||||
|
x="-20.776"
|
||||||
|
y="11.927"
|
||||||
|
width="79.454"
|
||||||
|
height="90.916"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id={filterIds[4]}
|
||||||
|
x="-19.845"
|
||||||
|
y="15.459"
|
||||||
|
width="79.731"
|
||||||
|
height="81.505"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id={filterIds[5]}
|
||||||
|
x="29.832"
|
||||||
|
y="-11.552"
|
||||||
|
width="75.117"
|
||||||
|
height="73.758"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur stdDeviation="9.606" result="effect1_foregroundBlur_2001_67" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id={filterIds[6]}
|
||||||
|
x="-38.583"
|
||||||
|
y="-16.253"
|
||||||
|
width="78.135"
|
||||||
|
height="78.758"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur stdDeviation="8.706" result="effect1_foregroundBlur_2001_67" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id={filterIds[7]}
|
||||||
|
x="8.107"
|
||||||
|
y="-5.966"
|
||||||
|
width="78.877"
|
||||||
|
height="77.539"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur stdDeviation="7.775" result="effect1_foregroundBlur_2001_67" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id={filterIds[8]}
|
||||||
|
x="13.587"
|
||||||
|
y="-18.488"
|
||||||
|
width="56.272"
|
||||||
|
height="51.81"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur stdDeviation="6.957" result="effect1_foregroundBlur_2001_67" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id={filterIds[9]}
|
||||||
|
x="-15.526"
|
||||||
|
y="-31.297"
|
||||||
|
width="70.856"
|
||||||
|
height="69.306"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur stdDeviation="5.876" result="effect1_foregroundBlur_2001_67" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id={filterIds[10]}
|
||||||
|
x="-14.168"
|
||||||
|
y="20.964"
|
||||||
|
width="55.501"
|
||||||
|
height="51.571"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur stdDeviation="7.273" result="effect1_foregroundBlur_2001_67" />
|
||||||
|
</filter>
|
||||||
|
<linearGradient id={gradientId} x1="18.447" x2="52.153" y1="43.42" y2="15.004" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor="#4893FC" />
|
||||||
|
<stop offset=".27" stopColor="#4893FC" />
|
||||||
|
<stop offset=".777" stopColor="#969DFF" />
|
||||||
|
<stop offset="1" stopColor="#BD99FE" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GeminiLogo;
|
export default GeminiLogo;
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
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 { SessionNavigationOptions } from '../../chat/types/types';
|
import type {
|
||||||
|
MarkSessionIdle,
|
||||||
export type SessionLifecycleHandler = (sessionId?: string | null) => void;
|
MarkSessionProcessing,
|
||||||
|
SessionActivityMap,
|
||||||
|
} from '../../../hooks/useSessionProtection';
|
||||||
|
import type { SessionEstablishedContext, SessionNavigationOptions } from '../../chat/types/types';
|
||||||
|
import type { SettingsMainTab } from '../../settings/types/types';
|
||||||
|
|
||||||
export type TaskMasterTask = {
|
export type TaskMasterTask = {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@@ -41,18 +45,16 @@ export type MainContentProps = {
|
|||||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||||
ws: WebSocket | null;
|
ws: WebSocket | null;
|
||||||
sendMessage: (message: unknown) => void;
|
sendMessage: (message: unknown) => void;
|
||||||
latestMessage: unknown;
|
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onInputFocusChange: (focused: boolean) => void;
|
onInputFocusChange: (focused: boolean) => void;
|
||||||
onSessionActive: SessionLifecycleHandler;
|
onSessionProcessing: MarkSessionProcessing;
|
||||||
onSessionInactive: SessionLifecycleHandler;
|
onSessionIdle: MarkSessionIdle;
|
||||||
onSessionProcessing: SessionLifecycleHandler;
|
processingSessions: SessionActivityMap;
|
||||||
onSessionNotProcessing: SessionLifecycleHandler;
|
|
||||||
processingSessions: Set<string>;
|
|
||||||
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||||
onShowSettings: () => void;
|
onSessionEstablished: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||||
|
onShowSettings: (tab?: SettingsMainTab) => void;
|
||||||
externalMessageUpdate: number;
|
externalMessageUpdate: number;
|
||||||
newSessionTrigger: number;
|
newSessionTrigger: number;
|
||||||
};
|
};
|
||||||
@@ -63,6 +65,7 @@ export type MainContentHeaderProps = {
|
|||||||
selectedProject: Project;
|
selectedProject: Project;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
shouldShowTasksTab: boolean;
|
shouldShowTasksTab: boolean;
|
||||||
|
shouldShowBrowserTab: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import ChatInterface from '../../chat/view/ChatInterface';
|
import ChatInterface from '../../chat/view/ChatInterface';
|
||||||
import FileTree from '../../file-tree/view/FileTree';
|
import FileTree from '../../file-tree/view/FileTree';
|
||||||
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
|
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
|
||||||
import GitPanel from '../../git-panel/view/GitPanel';
|
import GitPanel from '../../git-panel/view/GitPanel';
|
||||||
import PluginTabContent from '../../plugins/view/PluginTabContent';
|
import PluginTabContent from '../../plugins/view/PluginTabContent';
|
||||||
|
import { BrowserUsePanel } from '../../browser-use';
|
||||||
import type { MainContentProps } from '../types/types';
|
import type { MainContentProps } from '../types/types';
|
||||||
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
||||||
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
|
||||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||||
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
||||||
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
|
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
|
||||||
import EditorSidebar from '../../code-editor/view/EditorSidebar';
|
import EditorSidebar from '../../code-editor/view/EditorSidebar';
|
||||||
import type { Project } from '../../../types/app';
|
import type { Project } from '../../../types/app';
|
||||||
@@ -37,17 +39,15 @@ function MainContent({
|
|||||||
setActiveTab,
|
setActiveTab,
|
||||||
ws,
|
ws,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
latestMessage,
|
|
||||||
isMobile,
|
isMobile,
|
||||||
onMenuClick,
|
onMenuClick,
|
||||||
isLoading,
|
isLoading,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
onSessionActive,
|
|
||||||
onSessionInactive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
|
onSessionEstablished,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
@@ -57,8 +57,10 @@ function MainContent({
|
|||||||
|
|
||||||
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
|
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
|
||||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
|
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
|
||||||
|
const [browserUseEnabled, setBrowserUseEnabled] = useState(false);
|
||||||
|
|
||||||
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
|
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
|
||||||
|
const shouldShowBrowserTab = browserUseEnabled;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
editingFile,
|
editingFile,
|
||||||
@@ -92,6 +94,28 @@ function MainContent({
|
|||||||
}
|
}
|
||||||
}, [shouldShowTasksTab, activeTab, setActiveTab]);
|
}, [shouldShowTasksTab, activeTab, setActiveTab]);
|
||||||
|
|
||||||
|
const loadBrowserUseSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch('/api/browser-use/settings');
|
||||||
|
const data = await response.json();
|
||||||
|
setBrowserUseEnabled(Boolean(response.ok && data?.success !== false && data?.data?.settings?.enabled));
|
||||||
|
} catch {
|
||||||
|
setBrowserUseEnabled(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadBrowserUseSettings();
|
||||||
|
window.addEventListener('browserUseSettingsChanged', loadBrowserUseSettings);
|
||||||
|
return () => window.removeEventListener('browserUseSettingsChanged', loadBrowserUseSettings);
|
||||||
|
}, [loadBrowserUseSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldShowBrowserTab && activeTab === 'browser') {
|
||||||
|
setActiveTab('chat');
|
||||||
|
}
|
||||||
|
}, [shouldShowBrowserTab, activeTab, setActiveTab]);
|
||||||
|
|
||||||
usePaletteOpsRegister({
|
usePaletteOpsRegister({
|
||||||
openFile: (filePath: string) => {
|
openFile: (filePath: string) => {
|
||||||
setActiveTab('files');
|
setActiveTab('files');
|
||||||
@@ -115,6 +139,7 @@ function MainContent({
|
|||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
selectedSession={selectedSession}
|
selectedSession={selectedSession}
|
||||||
shouldShowTasksTab={shouldShowTasksTab}
|
shouldShowTasksTab={shouldShowTasksTab}
|
||||||
|
shouldShowBrowserTab={shouldShowBrowserTab}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onMenuClick={onMenuClick}
|
onMenuClick={onMenuClick}
|
||||||
/>
|
/>
|
||||||
@@ -128,15 +153,13 @@ function MainContent({
|
|||||||
selectedSession={selectedSession}
|
selectedSession={selectedSession}
|
||||||
ws={ws}
|
ws={ws}
|
||||||
sendMessage={sendMessage}
|
sendMessage={sendMessage}
|
||||||
latestMessage={latestMessage}
|
|
||||||
onFileOpen={handleFileOpen}
|
onFileOpen={handleFileOpen}
|
||||||
onInputFocusChange={onInputFocusChange}
|
onInputFocusChange={onInputFocusChange}
|
||||||
onSessionActive={onSessionActive}
|
|
||||||
onSessionInactive={onSessionInactive}
|
|
||||||
onSessionProcessing={onSessionProcessing}
|
onSessionProcessing={onSessionProcessing}
|
||||||
onSessionNotProcessing={onSessionNotProcessing}
|
onSessionIdle={onSessionIdle}
|
||||||
processingSessions={processingSessions}
|
processingSessions={processingSessions}
|
||||||
onNavigateToSession={onNavigateToSession}
|
onNavigateToSession={onNavigateToSession}
|
||||||
|
onSessionEstablished={onSessionEstablished}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
autoExpandTools={autoExpandTools}
|
autoExpandTools={autoExpandTools}
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
@@ -175,7 +198,11 @@ function MainContent({
|
|||||||
|
|
||||||
{shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}
|
{shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}
|
||||||
|
|
||||||
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`} />
|
{shouldShowBrowserTab && activeTab === 'browser' && (
|
||||||
|
<div className="h-full overflow-hidden">
|
||||||
|
<BrowserUsePanel isVisible={activeTab === 'browser'} onShowSettings={onShowSettings} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab.startsWith('plugin:') && (
|
{activeTab.startsWith('plugin:') && (
|
||||||
<div className="h-full overflow-hidden">
|
<div className="h-full overflow-hidden">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default function MainContentHeader({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
shouldShowTasksTab,
|
shouldShowTasksTab,
|
||||||
|
shouldShowBrowserTab,
|
||||||
isMobile,
|
isMobile,
|
||||||
onMenuClick,
|
onMenuClick,
|
||||||
}: MainContentHeaderProps) {
|
}: MainContentHeaderProps) {
|
||||||
@@ -59,6 +60,7 @@ export default function MainContentHeader({
|
|||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
shouldShowTasksTab={shouldShowTasksTab}
|
shouldShowTasksTab={shouldShowTasksTab}
|
||||||
|
shouldShowBrowserTab={shouldShowBrowserTab}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{canScrollRight && (
|
{canScrollRight && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, type LucideIcon } from 'lucide-react';
|
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, MonitorPlay, type LucideIcon } from 'lucide-react';
|
||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Tooltip, PillBar, Pill } from '../../../../shared/view/ui';
|
import { Tooltip, PillBar, Pill } from '../../../../shared/view/ui';
|
||||||
import type { AppTab } from '../../../../types/app';
|
import type { AppTab } from '../../../../types/app';
|
||||||
import { usePlugins } from '../../../../contexts/PluginsContext';
|
import { usePlugins } from '../../../../contexts/PluginsContext';
|
||||||
@@ -10,6 +11,7 @@ type MainContentTabSwitcherProps = {
|
|||||||
activeTab: AppTab;
|
activeTab: AppTab;
|
||||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||||
shouldShowTasksTab: boolean;
|
shouldShowTasksTab: boolean;
|
||||||
|
shouldShowBrowserTab: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BuiltInTab = {
|
type BuiltInTab = {
|
||||||
@@ -36,6 +38,13 @@ const BASE_TABS: BuiltInTab[] = [
|
|||||||
{ kind: 'builtin', id: 'git', labelKey: 'tabs.git', icon: GitBranch },
|
{ kind: 'builtin', id: 'git', labelKey: 'tabs.git', icon: GitBranch },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const BROWSER_TAB: BuiltInTab = {
|
||||||
|
kind: 'builtin',
|
||||||
|
id: 'browser',
|
||||||
|
labelKey: 'tabs.browser',
|
||||||
|
icon: MonitorPlay,
|
||||||
|
};
|
||||||
|
|
||||||
const TASKS_TAB: BuiltInTab = {
|
const TASKS_TAB: BuiltInTab = {
|
||||||
kind: 'builtin',
|
kind: 'builtin',
|
||||||
id: 'tasks',
|
id: 'tasks',
|
||||||
@@ -47,11 +56,16 @@ export default function MainContentTabSwitcher({
|
|||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
shouldShowTasksTab,
|
shouldShowTasksTab,
|
||||||
|
shouldShowBrowserTab,
|
||||||
}: MainContentTabSwitcherProps) {
|
}: MainContentTabSwitcherProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { plugins } = usePlugins();
|
const { plugins } = usePlugins();
|
||||||
|
|
||||||
const builtInTabs: BuiltInTab[] = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
|
const builtInTabs: BuiltInTab[] = [
|
||||||
|
...BASE_TABS,
|
||||||
|
...(shouldShowBrowserTab ? [BROWSER_TAB] : []),
|
||||||
|
...(shouldShowTasksTab ? [TASKS_TAB] : []),
|
||||||
|
];
|
||||||
|
|
||||||
const pluginTabs: PluginTab[] = plugins
|
const pluginTabs: PluginTab[] = plugins
|
||||||
.filter((p) => p.enabled)
|
.filter((p) => p.enabled)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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 { AppTab, Project, ProjectSession } from '../../../../types/app';
|
import type { AppTab, Project, ProjectSession } from '../../../../types/app';
|
||||||
import { usePlugins } from '../../../../contexts/PluginsContext';
|
import { usePlugins } from '../../../../contexts/PluginsContext';
|
||||||
@@ -27,6 +28,10 @@ function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: st
|
|||||||
return 'TaskMaster';
|
return 'TaskMaster';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'browser') {
|
||||||
|
return 'Browser';
|
||||||
|
}
|
||||||
|
|
||||||
return 'Project';
|
return 'Project';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ const getServerKey = (server: ProviderMcpServer): string => (
|
|||||||
`${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}`
|
`${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Servers prefixed with `cloudcli-` are written and removed automatically by a
|
||||||
|
// CloudCLI feature toggle (e.g. the Browser tab), not added by the user. They are
|
||||||
|
// shown read-only so users don't edit/delete them out of sync with the feature.
|
||||||
|
const isManagedServer = (server: ProviderMcpServer): boolean => server.name.startsWith('cloudcli-');
|
||||||
|
|
||||||
function ConfigLine({ label, children }: { label: string; children: string }) {
|
function ConfigLine({ label, children }: { label: string; children: string }) {
|
||||||
if (!children) {
|
if (!children) {
|
||||||
return null;
|
return null;
|
||||||
@@ -177,65 +182,92 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
|
|||||||
<div className="py-8 text-center text-muted-foreground">Loading MCP servers...</div>
|
<div className="py-8 text-center text-muted-foreground">Loading MCP servers...</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{servers.map((server) => (
|
{servers.map((server) => {
|
||||||
<div key={getServerKey(server)} className="rounded-lg border border-border bg-card/50 p-4">
|
const managed = isManagedServer(server);
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
return (
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
<div key={getServerKey(server)} className="rounded-lg border border-border bg-card/50 p-4">
|
||||||
{getTransportIcon(server.transport)}
|
<div className="flex items-start justify-between">
|
||||||
<span className="font-medium text-foreground">{server.name}</span>
|
<div className="min-w-0 flex-1">
|
||||||
<Badge variant="outline" className="text-xs">
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
{server.transport || 'stdio'}
|
{!managed && getTransportIcon(server.transport)}
|
||||||
</Badge>
|
<span className="font-medium text-foreground">{server.name}</span>
|
||||||
<Badge variant="outline" className="text-xs">
|
{!managed && (
|
||||||
{getScopeLabel(server.scope)}
|
<>
|
||||||
</Badge>
|
<Badge variant="outline" className="text-xs">
|
||||||
{server.projectDisplayName && (
|
{server.transport || 'stdio'}
|
||||||
<Badge variant="outline" className="max-w-full truncate text-xs">
|
</Badge>
|
||||||
{server.projectDisplayName}
|
<Badge variant="outline" className="text-xs">
|
||||||
</Badge>
|
{getScopeLabel(server.scope)}
|
||||||
)}
|
</Badge>
|
||||||
|
{server.projectDisplayName && (
|
||||||
|
<Badge variant="outline" className="max-w-full truncate text-xs">
|
||||||
|
{server.projectDisplayName}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{managed && (
|
||||||
|
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground">
|
||||||
|
<Lock className="h-3 w-3" />
|
||||||
|
{t('mcpServers.managed.badge', { defaultValue: 'Managed' })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
|
{!managed && (
|
||||||
|
<>
|
||||||
|
<ConfigLine label={t('mcpServers.config.command')}>{server.command || ''}</ConfigLine>
|
||||||
|
<ConfigLine label={t('mcpServers.config.url')}>{server.url || ''}</ConfigLine>
|
||||||
|
<ConfigLine label={t('mcpServers.config.args')}>{(server.args || []).join(' ')}</ConfigLine>
|
||||||
|
<ConfigLine label="Cwd">{server.cwd || ''}</ConfigLine>
|
||||||
|
{server.env && Object.keys(server.env).length > 0 && (
|
||||||
|
<ConfigLine label={t('mcpServers.config.environment')}>
|
||||||
|
{Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
|
||||||
|
</ConfigLine>
|
||||||
|
)}
|
||||||
|
{server.envVars && server.envVars.length > 0 && (
|
||||||
|
<ConfigLine label="Env Vars">{server.envVars.join(', ')}</ConfigLine>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{managed && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t('mcpServers.managed.hint', {
|
||||||
|
defaultValue: 'Managed by CloudCLI.',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1 text-sm text-muted-foreground">
|
{!managed && (
|
||||||
<ConfigLine label={t('mcpServers.config.command')}>{server.command || ''}</ConfigLine>
|
<div className="ml-4 flex items-center gap-2">
|
||||||
<ConfigLine label={t('mcpServers.config.url')}>{server.url || ''}</ConfigLine>
|
<Button
|
||||||
<ConfigLine label={t('mcpServers.config.args')}>{(server.args || []).join(' ')}</ConfigLine>
|
onClick={() => openForm(server)}
|
||||||
<ConfigLine label="Cwd">{server.cwd || ''}</ConfigLine>
|
variant="ghost"
|
||||||
{server.env && Object.keys(server.env).length > 0 && (
|
size="sm"
|
||||||
<ConfigLine label={t('mcpServers.config.environment')}>
|
className="text-muted-foreground hover:text-foreground"
|
||||||
{Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
|
title={t('mcpServers.actions.edit')}
|
||||||
</ConfigLine>
|
>
|
||||||
)}
|
<Edit3 className="h-4 w-4" />
|
||||||
{server.envVars && server.envVars.length > 0 && (
|
</Button>
|
||||||
<ConfigLine label="Env Vars">{server.envVars.join(', ')}</ConfigLine>
|
<Button
|
||||||
)}
|
onClick={() => deleteServer(server)}
|
||||||
</div>
|
variant="ghost"
|
||||||
</div>
|
size="sm"
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
<div className="ml-4 flex items-center gap-2">
|
title={t('mcpServers.actions.delete')}
|
||||||
<Button
|
>
|
||||||
onClick={() => openForm(server)}
|
<Trash2 className="h-4 w-4" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="sm"
|
</div>
|
||||||
className="text-muted-foreground hover:text-foreground"
|
)}
|
||||||
title={t('mcpServers.actions.edit')}
|
|
||||||
>
|
|
||||||
<Edit3 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => deleteServer(server)}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-red-600 hover:text-red-700"
|
|
||||||
title={t('mcpServers.actions.delete')}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
|
|
||||||
{!isLoading && !isLoadingProjectScopes && servers.length === 0 && (
|
{!isLoading && !isLoadingProjectScopes && servers.length === 0 && (
|
||||||
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Calculator,
|
||||||
Clock,
|
Clock,
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Github,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
ListTodo,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ServerCrash,
|
ServerCrash,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
@@ -27,6 +30,10 @@ const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-term
|
|||||||
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
|
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
|
||||||
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
|
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
|
||||||
const PRISM_CLOUDCLI_PLUGIN_URL = 'https://github.com/jakeefr/cloudcli-plugin-prism';
|
const PRISM_CLOUDCLI_PLUGIN_URL = 'https://github.com/jakeefr/cloudcli-plugin-prism';
|
||||||
|
const SESSION_MANAGER_PLUGIN_URL = 'https://github.com/strykereye2/cloudcli-plugin-session-manager';
|
||||||
|
const TOKEN_COST_CALCULATOR_PLUGIN_URL = 'https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator';
|
||||||
|
const TASK_QUEUE_PLUGIN_URL = 'https://github.com/TadMSTR/cloudcli-plugin-task-queue';
|
||||||
|
const GITHUB_ISSUES_BOARD_PLUGIN_URL = 'https://github.com/szmidtpiotr/claude-github-issue';
|
||||||
|
|
||||||
type PluginRecommendation = {
|
type PluginRecommendation = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -79,8 +86,40 @@ const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
|
|||||||
repoUrl: PRISM_CLOUDCLI_PLUGIN_URL,
|
repoUrl: PRISM_CLOUDCLI_PLUGIN_URL,
|
||||||
installedNames: ['prism'],
|
installedNames: ['prism'],
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
source: 'unofficial'
|
source: 'unofficial',
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
id: 'session-manager',
|
||||||
|
translationKey: 'sessionManagerPlugin',
|
||||||
|
repoUrl: SESSION_MANAGER_PLUGIN_URL,
|
||||||
|
installedNames: ['session-manager'],
|
||||||
|
icon: Activity,
|
||||||
|
source: 'unofficial',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'token-cost-calculator',
|
||||||
|
translationKey: 'tokenCostCalculatorPlugin',
|
||||||
|
repoUrl: TOKEN_COST_CALCULATOR_PLUGIN_URL,
|
||||||
|
installedNames: ['token-cost-calculator'],
|
||||||
|
icon: Calculator,
|
||||||
|
source: 'unofficial',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'task-queue',
|
||||||
|
translationKey: 'taskQueuePlugin',
|
||||||
|
repoUrl: TASK_QUEUE_PLUGIN_URL,
|
||||||
|
installedNames: ['task-queue'],
|
||||||
|
icon: ListTodo,
|
||||||
|
source: 'unofficial',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-github-issue',
|
||||||
|
translationKey: 'githubIssuesBoardPlugin',
|
||||||
|
repoUrl: GITHUB_ISSUES_BOARD_PLUGIN_URL,
|
||||||
|
installedNames: ['claude-github-issue'],
|
||||||
|
icon: Github,
|
||||||
|
source: 'unofficial',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function repoSlug(repoUrl: string) {
|
function repoSlug(repoUrl: string) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
|
MonitorPlay,
|
||||||
Palette,
|
Palette,
|
||||||
Plug,
|
Plug,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -32,6 +33,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
|
|||||||
{ id: 'git', label: 'Git', keywords: 'git github commits', icon: GitBranch },
|
{ id: 'git', label: 'Git', keywords: 'git github commits', icon: GitBranch },
|
||||||
{ id: 'api', label: 'API Tokens', keywords: 'api tokens auth keys', icon: KeyRound },
|
{ id: 'api', label: 'API Tokens', keywords: 'api tokens auth keys', icon: KeyRound },
|
||||||
{ id: 'tasks', label: 'Tasks', keywords: 'tasks taskmaster', icon: ListChecks },
|
{ id: 'tasks', label: 'Tasks', keywords: 'tasks taskmaster', icon: ListChecks },
|
||||||
|
{ id: 'browser', label: 'Browser', keywords: 'browser playwright chromium automation', icon: MonitorPlay },
|
||||||
{ id: 'notifications', label: 'Notifications', keywords: 'notifications alerts push', icon: Bell },
|
{ id: 'notifications', label: 'Notifications', keywords: 'notifications alerts push', icon: Bell },
|
||||||
{ id: 'plugins', label: 'Plugins', keywords: 'plugins extensions integrations', icon: Plug },
|
{ id: 'plugins', label: 'Plugins', keywords: 'plugins extensions integrations', icon: Plug },
|
||||||
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },
|
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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,
|
||||||
@@ -52,7 +54,7 @@ type NotificationPreferencesResponse = {
|
|||||||
|
|
||||||
type ActiveLoginProvider = AgentProvider | '';
|
type ActiveLoginProvider = AgentProvider | '';
|
||||||
|
|
||||||
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications', 'plugins'];
|
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'browser', 'notifications', 'plugins', 'about'];
|
||||||
|
|
||||||
const normalizeMainTab = (tab: string): SettingsMainTab => {
|
const normalizeMainTab = (tab: string): SettingsMainTab => {
|
||||||
// Keep backwards compatibility with older callers that still pass "tools".
|
// Keep backwards compatibility with older callers that still pass "tools".
|
||||||
@@ -107,6 +109,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: true,
|
inApp: true,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
|
sound: true,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
actionRequired: true,
|
actionRequired: true,
|
||||||
@@ -115,6 +118,25 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizeNotificationPreferences = (
|
||||||
|
preferences?: Partial<NotificationPreferencesState> | null,
|
||||||
|
): NotificationPreferencesState => {
|
||||||
|
const defaults = createDefaultNotificationPreferences();
|
||||||
|
|
||||||
|
return {
|
||||||
|
channels: {
|
||||||
|
inApp: preferences?.channels?.inApp ?? defaults.channels.inApp,
|
||||||
|
webPush: preferences?.channels?.webPush ?? defaults.channels.webPush,
|
||||||
|
sound: preferences?.channels?.sound ?? defaults.channels.sound,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
actionRequired: preferences?.events?.actionRequired ?? defaults.events.actionRequired,
|
||||||
|
stop: preferences?.events?.stop ?? defaults.events.stop,
|
||||||
|
error: preferences?.events?.error ?? defaults.events.error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function useSettingsController({ isOpen, initialTab }: UseSettingsControllerArgs) {
|
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);
|
||||||
@@ -186,7 +208,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(notificationData.preferences);
|
setNotificationPreferences(normalizeNotificationPreferences(notificationData.preferences));
|
||||||
} else {
|
} else {
|
||||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||||
}
|
}
|
||||||
@@ -301,6 +323,10 @@ 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,8 +1,9 @@
|
|||||||
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';
|
||||||
|
|
||||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins' | 'about';
|
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about';
|
||||||
export type AgentProvider = LLMProvider;
|
export type AgentProvider = LLMProvider;
|
||||||
export type AgentCategory = 'account' | 'permissions' | 'mcp';
|
export type AgentCategory = 'account' | 'permissions' | 'mcp';
|
||||||
export type ProjectSortOrder = 'name' | 'date';
|
export type ProjectSortOrder = 'name' | 'date';
|
||||||
@@ -29,6 +30,7 @@ export type NotificationPreferencesState = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: boolean;
|
inApp: boolean;
|
||||||
webPush: boolean;
|
webPush: boolean;
|
||||||
|
sound: boolean;
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
actionRequired: boolean;
|
actionRequired: boolean;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
|
|||||||
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
||||||
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
|
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
|
||||||
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
||||||
|
import BrowserUseSettingsTab from '../view/tabs/browser-use-settings/BrowserUseSettingsTab';
|
||||||
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
|
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
|
||||||
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
||||||
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
|
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
|
||||||
@@ -139,17 +140,19 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
|
|
||||||
{activeTab === 'tasks' && <TasksSettingsTab />}
|
{activeTab === 'tasks' && <TasksSettingsTab />}
|
||||||
|
|
||||||
{activeTab === 'notifications' && (
|
{activeTab === 'browser' && <BrowserUseSettingsTab />}
|
||||||
<NotificationsSettingsTab
|
|
||||||
notificationPreferences={notificationPreferences}
|
{activeTab === 'notifications' && (
|
||||||
onNotificationPreferencesChange={setNotificationPreferences}
|
<NotificationsSettingsTab
|
||||||
pushPermission={pushPermission}
|
notificationPreferences={notificationPreferences}
|
||||||
isPushSubscribed={isPushSubscribed}
|
onNotificationPreferencesChange={setNotificationPreferences}
|
||||||
isPushLoading={isPushLoading}
|
pushPermission={pushPermission}
|
||||||
onEnablePush={handleEnablePush}
|
isPushSubscribed={isPushSubscribed}
|
||||||
onDisablePush={handleDisablePush}
|
isPushLoading={isPushLoading}
|
||||||
/>
|
onEnablePush={handleEnablePush}
|
||||||
)}
|
onDisablePush={handleDisablePush}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'api' && <CredentialsSettingsTab />}
|
{activeTab === 'api' && <CredentialsSettingsTab />}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user