mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-15 03:01:58 +08:00
Compare commits
28 Commits
fix/codex-
...
feat/unifi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc717e69e | ||
|
|
6a53c31e90 | ||
|
|
92de0ed613 | ||
|
|
b6a45b3183 | ||
|
|
ce327b6fa9 | ||
|
|
276639099b | ||
|
|
f4f88318c2 | ||
|
|
029d159592 | ||
|
|
7c9ec8fa12 | ||
|
|
1b4d4b7278 | ||
|
|
b1a0afe9e0 | ||
|
|
88eb2009bb | ||
|
|
602e6ad4ac | ||
|
|
4a2453fe32 | ||
|
|
f439a8a3d5 | ||
|
|
23210bc40e | ||
|
|
beae8c6513 | ||
|
|
33a4e72ca4 | ||
|
|
f7c0024fe1 | ||
|
|
ca8fd0ee23 | ||
|
|
b7e6bca2e3 | ||
|
|
84c166c4cb | ||
|
|
231ed04002 | ||
|
|
d70dc077bf | ||
|
|
1faa1a6a00 | ||
|
|
3cd89956ba | ||
|
|
01dbe2a8bf | ||
|
|
c235b05e1d |
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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에서 전체 지원 모델 확인)
|
||||||
|
|
||||||
## 빠른 시작
|
## 빠른 시작
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|||||||
@@ -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ıç
|
||||||
|
|||||||
@@ -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` 接口获取)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
|||||||
@@ -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` 介面取得)
|
||||||
|
|
||||||
## 快速開始
|
## 快速開始
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ http {
|
|||||||
set $cloudcli_upstream http://127.0.0.1:3001;
|
set $cloudcli_upstream http://127.0.0.1:3001;
|
||||||
|
|
||||||
# Allow larger file uploads through the code editor/project file APIs.
|
# Allow larger file uploads through the code editor/project file APIs.
|
||||||
client_max_body_size 100m;
|
client_max_body_size 200m;
|
||||||
|
|
||||||
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
|
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
|
||||||
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
|
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.33.2",
|
"version": "1.34.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.33.2",
|
"version": "1.34.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.33.2",
|
"version": "1.34.0",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist-server/server/index.js",
|
"main": "dist-server/server/index.js",
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
"server/",
|
"server/",
|
||||||
"shared/",
|
"shared/",
|
||||||
"public/api-docs.html",
|
"public/api-docs.html",
|
||||||
"public/modelConstants.js",
|
|
||||||
"dist/",
|
"dist/",
|
||||||
"dist-server/",
|
"dist-server/",
|
||||||
"scripts/",
|
"scripts/",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ const __dirname = getModuleDir(import.meta.url);
|
|||||||
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
||||||
const APP_ROOT = findAppRoot(__dirname);
|
const APP_ROOT = findAppRoot(__dirname);
|
||||||
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
||||||
|
const MAX_FILE_UPLOAD_SIZE_MB = 200;
|
||||||
|
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
|
||||||
|
const MAX_FILE_UPLOAD_COUNT = 20;
|
||||||
|
|
||||||
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
|
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
|
||||||
|
|
||||||
@@ -897,27 +900,27 @@ const uploadFilesHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 50 * 1024 * 1024, // 50MB limit
|
fileSize: MAX_FILE_UPLOAD_SIZE_BYTES,
|
||||||
files: 20 // Max 20 files at once
|
files: MAX_FILE_UPLOAD_COUNT
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use multer middleware
|
// Use multer middleware
|
||||||
uploadMiddleware.array('files', 20)(req, res, async (err) => {
|
uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Multer error:', err);
|
console.error('Multer error:', err);
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
|
return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_UPLOAD_SIZE_MB}MB.` });
|
||||||
}
|
}
|
||||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
if (err.code === 'LIMIT_FILE_COUNT') {
|
||||||
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
|
return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` });
|
||||||
}
|
}
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { projectId } = req.params;
|
const { projectId } = req.params;
|
||||||
const { targetPath, relativePaths } = req.body;
|
const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body;
|
||||||
|
|
||||||
// Parse relative paths if provided (for folder uploads)
|
// Parse relative paths if provided (for folder uploads)
|
||||||
let filePaths = [];
|
let filePaths = [];
|
||||||
@@ -941,6 +944,11 @@ const uploadFilesHandler = async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'No files provided' });
|
return res.status(400).json({ error: 'No files provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsedRequestedFileCount = Number.parseInt(requestedFileCountRaw, 10);
|
||||||
|
const requestedFileCount = Number.isFinite(parsedRequestedFileCount) && parsedRequestedFileCount > 0
|
||||||
|
? parsedRequestedFileCount
|
||||||
|
: req.files.length;
|
||||||
|
|
||||||
// Resolve the project directory through the DB using the new projectId.
|
// Resolve the project directory through the DB using the new projectId.
|
||||||
const projectRoot = await projectsDb.getProjectPathById(projectId);
|
const projectRoot = await projectsDb.getProjectPathById(projectId);
|
||||||
if (!projectRoot) {
|
if (!projectRoot) {
|
||||||
@@ -1019,8 +1027,10 @@ const uploadFilesHandler = async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
files: uploadedFiles,
|
files: uploadedFiles,
|
||||||
|
uploadedCount: uploadedFiles.length,
|
||||||
|
requestedFileCount,
|
||||||
targetPath: resolvedTargetDir,
|
targetPath: resolvedTargetDir,
|
||||||
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
|
message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully`
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading files:', error);
|
console.error('Error uploading files:', error);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -133,9 +133,10 @@ flowchart TD
|
|||||||
|
|
||||||
### Chat Notes
|
### Chat Notes
|
||||||
|
|
||||||
1. `abort-session` returns a normalized `complete` message with `aborted: true`.
|
1. **Unified terminal lifecycle**: every provider run ends with exactly one `complete` message built by `createCompleteMessage()` (`server/shared/utils.ts`), regardless of provider: `{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }`. Failed runs emit an informational `error` message first, then the terminal `complete` with `success: false`. Mid-run `error` messages (e.g. stderr output) are non-terminal; the frontend only treats `complete` as end-of-run.
|
||||||
2. `check-session-status` returns `{ type: "session-status", isProcessing }`.
|
2. `abort-session` sends the terminal `complete` (`aborted: true`) on behalf of the cancelled run; providers detect the abort and skip their own `complete` so the client sees exactly one.
|
||||||
3. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
|
3. `check-session-status` returns `{ type: "session-status", isProcessing }`.
|
||||||
|
4. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
|
||||||
|
|
||||||
## `/shell` Terminal Flow
|
## `/shell` Terminal Flow
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
AuthenticatedWebSocketRequest,
|
AuthenticatedWebSocketRequest,
|
||||||
LLMProvider,
|
LLMProvider,
|
||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js';
|
import { createCompleteMessage, parseIncomingJsonObject } from '@/shared/utils.js';
|
||||||
|
|
||||||
type ChatIncomingMessage = AnyRecord & {
|
type ChatIncomingMessage = AnyRecord & {
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -173,14 +173,14 @@ export function handleChatConnection(
|
|||||||
success = await dependencies.abortClaudeSDKSession(sessionId);
|
success = await dependencies.abortClaudeSDKSession(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terminal complete on behalf of the cancelled run — providers skip
|
||||||
|
// their own complete for aborted runs so the client sees exactly one.
|
||||||
writer.send(
|
writer.send(
|
||||||
createNormalizedMessage({
|
createCompleteMessage({
|
||||||
kind: 'complete',
|
provider,
|
||||||
|
sessionId,
|
||||||
exitCode: success ? 0 : 1,
|
exitCode: success ? 0 : 1,
|
||||||
aborted: true,
|
aborted: true,
|
||||||
success,
|
|
||||||
sessionId,
|
|
||||||
provider,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -202,13 +202,11 @@ export function handleChatConnection(
|
|||||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
||||||
const success = dependencies.abortCursorSession(sessionId);
|
const success = dependencies.abortCursorSession(sessionId);
|
||||||
writer.send(
|
writer.send(
|
||||||
createNormalizedMessage({
|
createCompleteMessage({
|
||||||
kind: 'complete',
|
provider: 'cursor',
|
||||||
|
sessionId,
|
||||||
exitCode: success ? 0 : 1,
|
exitCode: success ? 0 : 1,
|
||||||
aborted: true,
|
aborted: true,
|
||||||
success,
|
|
||||||
sessionId,
|
|
||||||
provider: 'cursor',
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -279,16 +279,6 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
startedAt: new Date().toISOString()
|
startedAt: new Date().toISOString()
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const markSessionFinished = (id) => {
|
|
||||||
if (!id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = activeCodexSessions.get(id);
|
|
||||||
if (session && session.status !== 'aborted') {
|
|
||||||
session.status = 'completed';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Existing sessions can be tracked immediately; new sessions are tracked after thread.started.
|
// Existing sessions can be tracked immediately; new sessions are tracked after thread.started.
|
||||||
if (capturedSessionId) {
|
if (capturedSessionId) {
|
||||||
@@ -334,10 +324,6 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === 'turn.completed' || event.type === 'turn.failed') {
|
|
||||||
markSessionFinished(capturedSessionId || sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const transformed = transformCodexEvent(event);
|
const transformed = transformCodexEvent(event);
|
||||||
|
|
||||||
// Normalize the transformed event into NormalizedMessage(s) via adapter
|
// Normalize the transformed event into NormalizedMessage(s) via adapter
|
||||||
@@ -366,23 +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.
|
||||||
markSessionFinished(capturedSessionId || sessionId);
|
const runSession = capturedSessionId ? activeCodexSessions.get(capturedSessionId) : null;
|
||||||
|
const runAborted = runSession?.status === 'aborted' || abortController.signal.aborted;
|
||||||
sendMessage(ws, createNormalizedMessage({
|
if (!runAborted) {
|
||||||
kind: 'complete',
|
sendMessage(ws, createCompleteMessage({
|
||||||
actualSessionId: capturedSessionId || thread.id || sessionId || null,
|
|
||||||
sessionId: capturedSessionId || sessionId || null,
|
|
||||||
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) {
|
||||||
@@ -402,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) {
|
||||||
@@ -256,13 +259,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 +304,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 +321,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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -346,6 +346,43 @@ export function createNormalizedMessage(fields: NormalizedMessageInput): Normali
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the unified terminal `complete` lifecycle message.
|
||||||
|
*
|
||||||
|
* Contract: every provider run ends with exactly one `complete` (the
|
||||||
|
* abort-session handler emits it on behalf of cancelled runs, so aborted runs
|
||||||
|
* must NOT emit their own). The frontend treats `complete` as the only
|
||||||
|
* terminal signal and never needs provider-specific handling:
|
||||||
|
*
|
||||||
|
* - `sessionId` — the id the client knows this run by ('' if never discovered)
|
||||||
|
* - `actualSessionId` — canonical id after the run; equals `sessionId` unless
|
||||||
|
* the provider rewrote it mid-run
|
||||||
|
* - `exitCode` — 0 on success; a missing/null code (e.g. killed process)
|
||||||
|
* is reported as failure
|
||||||
|
* - `success` — exitCode === 0 and not aborted
|
||||||
|
* - `aborted` — run was cancelled by the user
|
||||||
|
*/
|
||||||
|
export function createCompleteMessage(opts: {
|
||||||
|
provider: NormalizedMessage['provider'];
|
||||||
|
sessionId?: string | null;
|
||||||
|
actualSessionId?: string | null;
|
||||||
|
exitCode?: number | null;
|
||||||
|
aborted?: boolean;
|
||||||
|
}): NormalizedMessage {
|
||||||
|
const exitCode = typeof opts.exitCode === 'number' ? opts.exitCode : 1;
|
||||||
|
const aborted = Boolean(opts.aborted);
|
||||||
|
|
||||||
|
return createNormalizedMessage({
|
||||||
|
kind: 'complete',
|
||||||
|
provider: opts.provider,
|
||||||
|
sessionId: opts.sessionId || null,
|
||||||
|
actualSessionId: opts.actualSessionId || opts.sessionId || null,
|
||||||
|
exitCode,
|
||||||
|
success: exitCode === 0 && !aborted,
|
||||||
|
aborted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
//----------------- MCP CONFIG PARSING UTILITIES ------------
|
//----------------- MCP CONFIG PARSING UTILITIES ------------
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { spawn } from 'child_process';
|
|
||||||
|
import { spawn } from 'cross-spawn';
|
||||||
|
|
||||||
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
|
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
|
||||||
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
||||||
|
|||||||
@@ -7,6 +7,41 @@ const runningPlugins = new Map();
|
|||||||
// Map<pluginName, Promise<port>> — in-flight start operations
|
// Map<pluginName, Promise<port>> — in-flight start operations
|
||||||
const startingPlugins = new Map();
|
const startingPlugins = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the environment handed to a plugin server subprocess.
|
||||||
|
*
|
||||||
|
* Intentionally minimal: only non-secret essentials, never the host's full
|
||||||
|
* environment. On Windows a handful of system variables are required for any
|
||||||
|
* child to bootstrap (Node itself, and any Python or CLI a plugin shells out
|
||||||
|
* to). Without APPDATA a `pip install --user` tool cannot locate its
|
||||||
|
* site-packages and fails to import; SystemRoot, PATHEXT and TEMP are needed to
|
||||||
|
* resolve system DLLs, executable extensions and a temp directory. None of
|
||||||
|
* these carry secrets, so the ones that are set get passed straight through.
|
||||||
|
*/
|
||||||
|
function buildPluginEnv(name) {
|
||||||
|
const env = {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
HOME: process.env.HOME,
|
||||||
|
NODE_ENV: process.env.NODE_ENV || 'production',
|
||||||
|
PLUGIN_NAME: name,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const WINDOWS_ESSENTIALS = [
|
||||||
|
'SystemRoot', 'windir', 'SystemDrive',
|
||||||
|
'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
|
||||||
|
'TEMP', 'TMP', 'PATHEXT',
|
||||||
|
];
|
||||||
|
for (const key of WINDOWS_ESSENTIALS) {
|
||||||
|
if (process.env[key] !== undefined) {
|
||||||
|
env[key] = process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a plugin's server subprocess.
|
* Start a plugin's server subprocess.
|
||||||
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
|
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
|
||||||
@@ -26,15 +61,9 @@ export function startPluginServer(name, pluginDir, serverEntry) {
|
|||||||
|
|
||||||
const serverPath = path.join(pluginDir, serverEntry);
|
const serverPath = path.join(pluginDir, serverEntry);
|
||||||
|
|
||||||
// Restricted env — only essentials, no host secrets
|
|
||||||
const pluginProcess = spawn('node', [serverPath], {
|
const pluginProcess = spawn('node', [serverPath], {
|
||||||
cwd: pluginDir,
|
cwd: pluginDir,
|
||||||
env: {
|
env: buildPluginEnv(name),
|
||||||
PATH: process.env.PATH,
|
|
||||||
HOME: process.env.HOME,
|
|
||||||
NODE_ENV: process.env.NODE_ENV || 'production',
|
|
||||||
PLUGIN_NAME: name,
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,9 @@ function AppContentInner() {
|
|||||||
const wasConnectedRef = useRef(false);
|
const wasConnectedRef = useRef(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeSessions,
|
|
||||||
processingSessions,
|
processingSessions,
|
||||||
markSessionAsActive,
|
markSessionProcessing,
|
||||||
markSessionAsInactive,
|
markSessionIdle,
|
||||||
markSessionAsProcessing,
|
|
||||||
markSessionAsNotProcessing,
|
|
||||||
} = useSessionProtection();
|
} = useSessionProtection();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -57,7 +54,7 @@ function AppContentInner() {
|
|||||||
navigate,
|
navigate,
|
||||||
latestMessage,
|
latestMessage,
|
||||||
isMobile,
|
isMobile,
|
||||||
activeSessions,
|
activeSessions: processingSessions,
|
||||||
});
|
});
|
||||||
|
|
||||||
usePaletteOpsRegister({
|
usePaletteOpsRegister({
|
||||||
@@ -185,10 +182,8 @@ function AppContentInner() {
|
|||||||
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) })
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type {
|
|||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
|
||||||
|
import 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 {
|
||||||
@@ -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,12 @@ 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;
|
|
||||||
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 +170,12 @@ export function useChatComposerState({
|
|||||||
tokenBudget,
|
tokenBudget,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
onSessionActive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
onFileOpen,
|
onFileOpen,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
pendingViewSessionRef,
|
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
addMessage,
|
addMessage,
|
||||||
setIsLoading,
|
|
||||||
setCanAbortSession,
|
|
||||||
setClaudeStatus,
|
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
}: UseChatComposerStateArgs) {
|
}: UseChatComposerStateArgs) {
|
||||||
@@ -620,27 +608,18 @@ 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). A brand-new
|
||||||
setClaudeStatus({
|
// conversation has no session id yet, so it is tracked under the
|
||||||
text: 'Processing',
|
// pending placeholder until `session_created` announces the real id.
|
||||||
tokens: 0,
|
onSessionProcessing?.(effectiveSessionId || PENDING_SESSION_ID, {
|
||||||
can_interrupt: true,
|
statusText: null,
|
||||||
|
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 =
|
||||||
@@ -776,19 +755,14 @@ export function useChatComposerState({
|
|||||||
geminiModel,
|
geminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
isLoading,
|
isLoading,
|
||||||
onSessionActive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
pendingViewSessionRef,
|
|
||||||
permissionMode,
|
permissionMode,
|
||||||
provider,
|
provider,
|
||||||
resetCommandMenuState,
|
resetCommandMenuState,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
setCanAbortSession,
|
|
||||||
addMessage,
|
addMessage,
|
||||||
setClaudeStatus,
|
|
||||||
setIsLoading,
|
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
],
|
],
|
||||||
@@ -1000,15 +974,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);
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { useEffect, useRef } from 'react';
|
|||||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||||
|
|
||||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||||
|
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
|
||||||
|
import { playChatCompletionSound } from '../../../utils/notificationSound';
|
||||||
|
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
|
||||||
|
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
||||||
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
|
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
|
||||||
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||||
|
|
||||||
type PendingViewSession = {
|
|
||||||
startedAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LatestChatMessage = {
|
type LatestChatMessage = {
|
||||||
type?: string;
|
type?: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
@@ -53,18 +53,14 @@ interface UseChatRealtimeHandlersArgs {
|
|||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
setCurrentSessionId: (sessionId: string | null) => void;
|
setCurrentSessionId: (sessionId: string | null) => void;
|
||||||
setIsLoading: (loading: boolean) => void;
|
|
||||||
setCanAbortSession: (canAbort: boolean) => void;
|
|
||||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
|
||||||
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
||||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
|
||||||
streamTimerRef: MutableRefObject<number | null>;
|
streamTimerRef: MutableRefObject<number | null>;
|
||||||
accumulatedStreamRef: MutableRefObject<string>;
|
accumulatedStreamRef: MutableRefObject<string>;
|
||||||
onSessionInactive?: (sessionId?: string | null) => void;
|
/** When each session's `check-session-status` was last sent; guards stale idle replies. */
|
||||||
onSessionActive?: (sessionId?: string | null) => void;
|
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
|
||||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
onSessionProcessing?: MarkSessionProcessing;
|
||||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
onSessionIdle?: MarkSessionIdle;
|
||||||
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
|
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
|
||||||
onWebSocketReconnect?: () => void;
|
onWebSocketReconnect?: () => void;
|
||||||
sessionStore: SessionStore;
|
sessionStore: SessionStore;
|
||||||
@@ -80,25 +76,19 @@ export function useChatRealtimeHandlers({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
setIsLoading,
|
|
||||||
setCanAbortSession,
|
|
||||||
setClaudeStatus,
|
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
onSessionInactive,
|
statusCheckSentAtRef,
|
||||||
onSessionActive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onWebSocketReconnect,
|
onWebSocketReconnect,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
}: UseChatRealtimeHandlersArgs) {
|
}: UseChatRealtimeHandlersArgs) {
|
||||||
const paletteOps = usePaletteOps();
|
const paletteOps = usePaletteOps();
|
||||||
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
|
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
|
||||||
const terminalSessionIdsRef = useRef<Set<string>>(new Set());
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!latestMessage) return;
|
if (!latestMessage) return;
|
||||||
@@ -137,46 +127,24 @@ export function useChatRealtimeHandlers({
|
|||||||
|
|
||||||
const status = msg.status;
|
const status = msg.status;
|
||||||
if (status) {
|
if (status) {
|
||||||
const statusInfo = {
|
onSessionProcessing?.(statusSessionId, {
|
||||||
text: status.text || 'Working...',
|
statusText: status.text || null,
|
||||||
tokens: status.tokens || 0,
|
canInterrupt: status.can_interrupt !== false,
|
||||||
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 && terminalSessionIdsRef.current.has(statusSessionId)) {
|
|
||||||
onSessionInactive?.(statusSessionId);
|
|
||||||
onSessionNotProcessing?.(statusSessionId);
|
|
||||||
if (isCurrentSession) {
|
|
||||||
setIsLoading(false);
|
|
||||||
setCanAbortSession(false);
|
|
||||||
setClaudeStatus(null);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reply to check-session-status (or unsolicited processing update)
|
||||||
if (msg.isProcessing) {
|
if (msg.isProcessing) {
|
||||||
onSessionActive?.(statusSessionId);
|
|
||||||
onSessionProcessing?.(statusSessionId);
|
onSessionProcessing?.(statusSessionId);
|
||||||
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSessionInactive?.(statusSessionId);
|
// Idle reply: ignore it if a newer request started after the check
|
||||||
onSessionNotProcessing?.(statusSessionId);
|
// was sent — the reply describes the older request.
|
||||||
if (isCurrentSession) {
|
onSessionIdle?.(statusSessionId, {
|
||||||
setIsLoading(false);
|
ifStartedBefore: statusCheckSentAtRef.current.get(statusSessionId),
|
||||||
setCanAbortSession(false);
|
});
|
||||||
setClaudeStatus(null);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,10 +160,6 @@ export function useChatRealtimeHandlers({
|
|||||||
|
|
||||||
const sid = msg.sessionId || activeViewSessionId;
|
const sid = msg.sessionId || activeViewSessionId;
|
||||||
|
|
||||||
if (sid && msg.kind === 'session_created') {
|
|
||||||
terminalSessionIdsRef.current.delete(sid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Streaming: buffer for performance ---
|
// --- Streaming: buffer for performance ---
|
||||||
if (msg.kind === 'stream_delta') {
|
if (msg.kind === 'stream_delta') {
|
||||||
const text = msg.content || '';
|
const text = msg.content || '';
|
||||||
@@ -252,32 +216,20 @@ export function useChatRealtimeHandlers({
|
|||||||
// We no longer synthesize client-side placeholder IDs. Until the provider
|
// We no longer synthesize client-side placeholder IDs. Until the provider
|
||||||
// announces `session_created`, the active id is expected to be null.
|
// announces `session_created`, the active id is expected to be null.
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
console.log('Session created with ID:', newSessionId);
|
|
||||||
console.log('Existing session ID:', currentSessionId);
|
|
||||||
setCurrentSessionId(newSessionId);
|
setCurrentSessionId(newSessionId);
|
||||||
setPendingPermissionRequests((prev) =>
|
setPendingPermissionRequests((prev) =>
|
||||||
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
|
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
pendingViewSessionRef.current = null;
|
// The in-flight request now has a concrete session id: migrate the
|
||||||
onSessionActive?.(newSessionId);
|
// processing entry from the pending placeholder.
|
||||||
|
onSessionIdle?.(PENDING_SESSION_ID);
|
||||||
onSessionProcessing?.(newSessionId);
|
onSessionProcessing?.(newSessionId);
|
||||||
setIsLoading(true);
|
|
||||||
setCanAbortSession(true);
|
|
||||||
setClaudeStatus({
|
|
||||||
text: 'Processing',
|
|
||||||
tokens: 0,
|
|
||||||
can_interrupt: true,
|
|
||||||
});
|
|
||||||
onNavigateToSession?.(newSessionId);
|
onNavigateToSession?.(newSessionId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'complete': {
|
case 'complete': {
|
||||||
if (sid) {
|
|
||||||
terminalSessionIdsRef.current.add(sid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush any remaining streaming state
|
// Flush any remaining streaming state
|
||||||
if (streamTimerRef.current) {
|
if (streamTimerRef.current) {
|
||||||
clearTimeout(streamTimerRef.current);
|
clearTimeout(streamTimerRef.current);
|
||||||
@@ -289,22 +241,28 @@ export function useChatRealtimeHandlers({
|
|||||||
}
|
}
|
||||||
accumulatedStreamRef.current = '';
|
accumulatedStreamRef.current = '';
|
||||||
|
|
||||||
setIsLoading(false);
|
// `complete` is the unified terminal event — every provider run ends
|
||||||
setCanAbortSession(false);
|
// with exactly one, regardless of success, failure, or abort. The
|
||||||
setClaudeStatus(null);
|
// indicator derives from the processing map, so deleting the entry
|
||||||
|
// hides it immediately and atomically.
|
||||||
|
onSessionIdle?.(sid);
|
||||||
|
onSessionIdle?.(PENDING_SESSION_ID);
|
||||||
setPendingPermissionRequests([]);
|
setPendingPermissionRequests([]);
|
||||||
onSessionInactive?.(sid);
|
|
||||||
onSessionNotProcessing?.(sid);
|
|
||||||
pendingViewSessionRef.current = null;
|
|
||||||
|
|
||||||
// Handle aborted case
|
// Handle aborted case
|
||||||
if (msg.aborted) {
|
if (msg.aborted) {
|
||||||
// Abort was requested — the complete event confirms it
|
// Abort was requested — the complete event confirms it
|
||||||
// No special UI action needed beyond clearing loading state above
|
// No special UI action needed beyond clearing the processing entry above
|
||||||
// The backend already sent any abort-related messages
|
// The backend already sent any abort-related messages
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Celebrate only successful runs (failed runs end with success: false).
|
||||||
|
if (msg.success !== false) {
|
||||||
|
showCompletionTitleIndicator();
|
||||||
|
void playChatCompletionSound();
|
||||||
|
}
|
||||||
|
|
||||||
const actualSessionId =
|
const actualSessionId =
|
||||||
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
||||||
? msg.actualSessionId
|
? msg.actualSessionId
|
||||||
@@ -317,6 +275,7 @@ export function useChatRealtimeHandlers({
|
|||||||
|
|
||||||
if (actualSessionId && sid && actualSessionId !== sid) {
|
if (actualSessionId && sid && actualSessionId !== sid) {
|
||||||
sessionStore.replaceSessionId(sid, actualSessionId);
|
sessionStore.replaceSessionId(sid, actualSessionId);
|
||||||
|
onSessionIdle?.(actualSessionId);
|
||||||
|
|
||||||
if (isVisibleSession) {
|
if (isVisibleSession) {
|
||||||
setCurrentSessionId(actualSessionId);
|
setCurrentSessionId(actualSessionId);
|
||||||
@@ -332,19 +291,9 @@ export function useChatRealtimeHandlers({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'error': {
|
// 'error' is an informational message row, not a terminal event —
|
||||||
if (sid) {
|
// providers emit it for mid-run stderr output too. Run teardown is
|
||||||
terminalSessionIdsRef.current.add(sid);
|
// always signalled by the unified 'complete' that follows.
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false);
|
|
||||||
setCanAbortSession(false);
|
|
||||||
setClaudeStatus(null);
|
|
||||||
onSessionInactive?.(sid);
|
|
||||||
onSessionNotProcessing?.(sid);
|
|
||||||
pendingViewSessionRef.current = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'permission_request': {
|
case 'permission_request': {
|
||||||
if (!msg.requestId) break;
|
if (!msg.requestId) break;
|
||||||
@@ -359,9 +308,7 @@ export function useChatRealtimeHandlers({
|
|||||||
receivedAt: new Date(),
|
receivedAt: new Date(),
|
||||||
}];
|
}];
|
||||||
});
|
});
|
||||||
setIsLoading(true);
|
onSessionProcessing?.(sid || PENDING_SESSION_ID);
|
||||||
setCanAbortSession(true);
|
|
||||||
setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,13 +323,10 @@ export function useChatRealtimeHandlers({
|
|||||||
if (msg.text === 'token_budget' && msg.tokenBudget) {
|
if (msg.text === 'token_budget' && msg.tokenBudget) {
|
||||||
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
|
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
|
||||||
} else if (msg.text) {
|
} else if (msg.text) {
|
||||||
setClaudeStatus({
|
onSessionProcessing?.(sid || PENDING_SESSION_ID, {
|
||||||
text: msg.text,
|
statusText: msg.text,
|
||||||
tokens: msg.tokens || 0,
|
canInterrupt: msg.canInterrupt !== false,
|
||||||
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
|
|
||||||
});
|
});
|
||||||
setIsLoading(true);
|
|
||||||
setCanAbortSession(msg.canInterrupt !== false);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -398,18 +342,13 @@ export function useChatRealtimeHandlers({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
setIsLoading,
|
|
||||||
setCanAbortSession,
|
|
||||||
setClaudeStatus,
|
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
onSessionInactive,
|
statusCheckSentAtRef,
|
||||||
onSessionActive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onWebSocketReconnect,
|
onWebSocketReconnect,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
|||||||
import type { MutableRefObject } from 'react';
|
import type { MutableRefObject } from 'react';
|
||||||
|
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
|
||||||
|
import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection';
|
||||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||||
import type { ChatMessage, Provider } from '../types/types';
|
import type { ChatMessage, Provider } from '../types/types';
|
||||||
@@ -12,10 +14,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 +22,11 @@ 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 `check-session-status` was last sent; guards stale idle replies. */
|
||||||
|
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
|
||||||
sessionStore: SessionStore;
|
sessionStore: SessionStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,21 +99,19 @@ export function useChatSessionState({
|
|||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
|
onSessionIdle,
|
||||||
resetStreamingState,
|
resetStreamingState,
|
||||||
pendingViewSessionRef,
|
statusCheckSentAtRef,
|
||||||
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);
|
||||||
@@ -131,8 +129,6 @@ export function useChatSessionState({
|
|||||||
const pendingInitialScrollRef = useRef(true);
|
const pendingInitialScrollRef = useRef(true);
|
||||||
const messagesOffsetRef = useRef(0);
|
const messagesOffsetRef = useRef(0);
|
||||||
const scrollPositionRef = useRef({ height: 0, top: 0 });
|
const scrollPositionRef = useRef({ height: 0, top: 0 });
|
||||||
const previousProcessingSessionsRef = useRef<Set<string> | null>(null);
|
|
||||||
const previousProcessingSessionViewIdRef = useRef<string | null>(null);
|
|
||||||
const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const loadAllOverlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const loadAllOverlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastLoadedSessionKeyRef = useRef<string | null>(null);
|
const lastLoadedSessionKeyRef = useRef<string | null>(null);
|
||||||
@@ -172,10 +168,7 @@ export function useChatSessionState({
|
|||||||
* - No coupling to unrelated external update signals.
|
* - No coupling to unrelated external update signals.
|
||||||
*/
|
*/
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
pendingViewSessionRef.current = null;
|
onSessionIdle?.(PENDING_SESSION_ID);
|
||||||
setClaudeStatus(null);
|
|
||||||
setCanAbortSession(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
setPendingUserMessage(null);
|
setPendingUserMessage(null);
|
||||||
sessionStorage.removeItem('cursorSessionId');
|
sessionStorage.removeItem('cursorSessionId');
|
||||||
@@ -206,13 +199,29 @@ 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 (or of the pending not-yet-created session on a fresh
|
||||||
|
// draft) — never stale local UI state from the last time it was open.
|
||||||
|
const sessionActivity = processingSessions?.get(activeSessionId ?? PENDING_SESSION_ID) ?? null;
|
||||||
|
const isProcessing = sessionActivity !== null;
|
||||||
|
const canAbortSession = isProcessing && sessionActivity.canInterrupt;
|
||||||
|
|
||||||
|
// Ref mirror so effects can read the latest map without re-running on
|
||||||
|
// every activity transition.
|
||||||
|
const processingSessionsRef = useRef(processingSessions);
|
||||||
|
processingSessionsRef.current = processingSessions;
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Derive chatMessages from the store */
|
/* Derive chatMessages from the store */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
const activeSessionId = selectedSession?.id || currentSessionId || null;
|
|
||||||
const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);
|
const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);
|
||||||
const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null);
|
const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null);
|
||||||
|
|
||||||
@@ -385,28 +394,59 @@ 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 new provider run can be in flight before the router has a canonical
|
||||||
// selectedSession. Keep the processing banner alive until complete/error.
|
// selectedSession. Keep the draft view intact until complete/error.
|
||||||
if (pendingViewSessionRef.current) {
|
if (processingSessionsRef.current?.has(PENDING_SESSION_ID)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
pendingViewSessionRef.current = null;
|
|
||||||
setClaudeStatus(null);
|
|
||||||
setCanAbortSession(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
sessionStorage.removeItem('cursorSessionId');
|
sessionStorage.removeItem('cursorSessionId');
|
||||||
messagesOffsetRef.current = 0;
|
messagesOffsetRef.current = 0;
|
||||||
@@ -428,9 +468,6 @@ export function useChatSessionState({
|
|||||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
||||||
if (sessionChanged) {
|
if (sessionChanged) {
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
pendingViewSessionRef.current = null;
|
|
||||||
setClaudeStatus(null);
|
|
||||||
setCanAbortSession(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset pagination/scroll state
|
// Reset pagination/scroll state
|
||||||
@@ -449,7 +486,6 @@ export function useChatSessionState({
|
|||||||
|
|
||||||
if (sessionChanged) {
|
if (sessionChanged) {
|
||||||
setTokenBudget(null);
|
setTokenBudget(null);
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentSessionId(selectedSession.id);
|
setCurrentSessionId(selectedSession.id);
|
||||||
@@ -457,8 +493,11 @@ export function useChatSessionState({
|
|||||||
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check session status
|
// Reconcile processing state with the server. Recording the send time
|
||||||
|
// lets the reply handler discard idle replies that a newer request has
|
||||||
|
// since outdated.
|
||||||
if (ws) {
|
if (ws) {
|
||||||
|
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
|
||||||
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });
|
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,11 +522,11 @@ export function useChatSessionState({
|
|||||||
setIsLoadingSessionMessages(false);
|
setIsLoadingSessionMessages(false);
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
pendingViewSessionRef,
|
|
||||||
resetStreamingState,
|
resetStreamingState,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession?.id,
|
selectedSession?.id,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
statusCheckSentAtRef,
|
||||||
ws,
|
ws,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
]);
|
]);
|
||||||
@@ -501,7 +540,7 @@ export function useChatSessionState({
|
|||||||
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||||
|
|
||||||
// Skip store refresh during active streaming
|
// Skip store refresh during active streaming
|
||||||
if (!isLoading) {
|
if (!isProcessing) {
|
||||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||||
provider: (selectedSession.__provider || provider) as LLMProvider,
|
provider: (selectedSession.__provider || provider) as LLMProvider,
|
||||||
projectId: selectedProject.projectId,
|
projectId: selectedProject.projectId,
|
||||||
@@ -526,7 +565,7 @@ export function useChatSessionState({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
isLoading,
|
isProcessing,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Search navigation target
|
// Search navigation target
|
||||||
@@ -693,24 +732,6 @@ export function useChatSessionState({
|
|||||||
return () => container.removeEventListener('scroll', handleScroll);
|
return () => container.removeEventListener('scroll', handleScroll);
|
||||||
}, [handleScroll]);
|
}, [handleScroll]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const activeViewSessionId = selectedSession?.id || currentSessionId;
|
|
||||||
const previousProcessingSessions = previousProcessingSessionsRef.current;
|
|
||||||
const previousProcessingSessionViewId = previousProcessingSessionViewIdRef.current;
|
|
||||||
previousProcessingSessionsRef.current = processingSessions ?? null;
|
|
||||||
previousProcessingSessionViewIdRef.current = activeViewSessionId ?? null;
|
|
||||||
|
|
||||||
if (!activeViewSessionId || !processingSessions) return;
|
|
||||||
|
|
||||||
const activeViewSessionChanged = previousProcessingSessionViewId !== activeViewSessionId;
|
|
||||||
const wasProcessing = previousProcessingSessions?.has(activeViewSessionId) ?? false;
|
|
||||||
const shouldBeProcessing = processingSessions.has(activeViewSessionId);
|
|
||||||
if (shouldBeProcessing && (!wasProcessing || activeViewSessionChanged) && !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(() => {
|
||||||
@@ -792,16 +813,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,
|
||||||
@@ -814,8 +834,6 @@ export function useChatSessionState({
|
|||||||
isLoadingAllMessages,
|
isLoadingAllMessages,
|
||||||
loadAllJustFinished,
|
loadAllJustFinished,
|
||||||
showLoadAllOverlay,
|
showLoadAllOverlay,
|
||||||
claudeStatus,
|
|
||||||
setClaudeStatus,
|
|
||||||
createDiff,
|
createDiff,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
|
|||||||
@@ -393,7 +393,8 @@ export function useSlashCommands({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slashPattern = /^\/(\S*)$/;
|
// Match / at start of input OR after whitespace, capturing the /word up to cursor.
|
||||||
|
const slashPattern = /(?:^|\s)(\/\S*)$/;
|
||||||
const match = textBeforeCursor.match(slashPattern);
|
const match = textBeforeCursor.match(slashPattern);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -401,8 +402,9 @@ export function useSlashCommands({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slashPos = 0;
|
// Compute actual position of / in the full input string.
|
||||||
const query = match[1];
|
const slashPos = match.index! + (match[0].length - match[1].length);
|
||||||
|
const query = match[1].slice(1); // strip leading /
|
||||||
|
|
||||||
setSlashPosition(slashPos);
|
setSlashPosition(slashPos);
|
||||||
setShowCommandMenu(true);
|
setShowCommandMenu(true);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -110,11 +115,9 @@ export interface ChatInterfaceProps {
|
|||||||
latestMessage: any;
|
latestMessage: any;
|
||||||
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
||||||
onInputFocusChange?: (focused: boolean) => void;
|
onInputFocusChange?: (focused: boolean) => void;
|
||||||
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;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
autoExpandTools?: boolean;
|
autoExpandTools?: boolean;
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ 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,
|
||||||
@@ -29,10 +25,8 @@ function ChatInterface({
|
|||||||
latestMessage,
|
latestMessage,
|
||||||
onFileOpen,
|
onFileOpen,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
onSessionActive,
|
|
||||||
onSessionInactive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
@@ -51,7 +45,9 @@ function ChatInterface({
|
|||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
const streamTimerRef = useRef<number | null>(null);
|
const streamTimerRef = useRef<number | null>(null);
|
||||||
const accumulatedStreamRef = useRef('');
|
const accumulatedStreamRef = useRef('');
|
||||||
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
|
// When each session's `check-session-status` was last sent; idle replies
|
||||||
|
// older than a later local request are discarded as stale.
|
||||||
|
const statusCheckSentAtRef = useRef(new Map<string, number>());
|
||||||
|
|
||||||
const resetStreamingState = useCallback(() => {
|
const resetStreamingState = useCallback(() => {
|
||||||
if (streamTimerRef.current) {
|
if (streamTimerRef.current) {
|
||||||
@@ -92,16 +88,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 +109,6 @@ function ChatInterface({
|
|||||||
isLoadingAllMessages,
|
isLoadingAllMessages,
|
||||||
loadAllJustFinished,
|
loadAllJustFinished,
|
||||||
showLoadAllOverlay,
|
showLoadAllOverlay,
|
||||||
claudeStatus,
|
|
||||||
setClaudeStatus,
|
|
||||||
createDiff,
|
createDiff,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
@@ -130,8 +123,9 @@ function ChatInterface({
|
|||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
|
onSessionIdle,
|
||||||
resetStreamingState,
|
resetStreamingState,
|
||||||
pendingViewSessionRef,
|
statusCheckSentAtRef,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,40 +185,40 @@ function ChatInterface({
|
|||||||
codexModel,
|
codexModel,
|
||||||
geminiModel,
|
geminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
isLoading,
|
isLoading: isProcessing,
|
||||||
canAbortSession,
|
canAbortSession,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
onSessionActive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
onFileOpen,
|
onFileOpen,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
pendingViewSessionRef,
|
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
addMessage,
|
addMessage,
|
||||||
setIsLoading,
|
|
||||||
setCanAbortSession,
|
|
||||||
setClaudeStatus,
|
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
});
|
});
|
||||||
|
|
||||||
// On WebSocket reconnect, re-fetch the current session's messages from the 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-check the session's
|
||||||
|
// processing status — the authoritative reply restores or clears the
|
||||||
|
// activity indicator depending on whether the run is still active.
|
||||||
const handleWebSocketReconnect = useCallback(async () => {
|
const handleWebSocketReconnect = useCallback(async () => {
|
||||||
if (!selectedProject || !selectedSession) return;
|
if (!selectedProject || !selectedSession) return;
|
||||||
const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
|
const providerVal =
|
||||||
|
selectedSession.__provider
|
||||||
|
|| (localStorage.getItem('selected-provider') as LLMProvider)
|
||||||
|
|| 'claude';
|
||||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||||
provider: (selectedSession.__provider || providerVal) as LLMProvider,
|
provider: providerVal as LLMProvider,
|
||||||
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
|
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
|
||||||
projectId: selectedProject.projectId,
|
projectId: selectedProject.projectId,
|
||||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||||
});
|
});
|
||||||
setIsLoading(false);
|
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
|
||||||
setCanAbortSession(false);
|
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider: providerVal });
|
||||||
}, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]);
|
}, [selectedProject, selectedSession, sendMessage, sessionStore]);
|
||||||
|
|
||||||
useChatRealtimeHandlers({
|
useChatRealtimeHandlers({
|
||||||
latestMessage,
|
latestMessage,
|
||||||
@@ -232,25 +226,20 @@ function ChatInterface({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
setIsLoading,
|
|
||||||
setCanAbortSession,
|
|
||||||
setClaudeStatus,
|
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
onSessionInactive,
|
statusCheckSentAtRef,
|
||||||
onSessionActive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onWebSocketReconnect: handleWebSocketReconnect,
|
onWebSocketReconnect: handleWebSocketReconnect,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading || !canAbortSession) {
|
if (!canAbortSession) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +256,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 () => {
|
||||||
@@ -362,10 +351,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 && (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';
|
|||||||
|
|
||||||
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
|
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
|
||||||
|
|
||||||
|
export const MAX_FILE_UPLOAD_SIZE_MB = 200;
|
||||||
|
|
||||||
|
export const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
|
||||||
|
|
||||||
|
export const MAX_FILE_UPLOAD_SIZE_LABEL = `${MAX_FILE_UPLOAD_SIZE_MB}MB`;
|
||||||
|
|
||||||
|
export const MAX_FILE_UPLOAD_COUNT = 20;
|
||||||
|
|
||||||
export const IMAGE_FILE_EXTENSIONS = new Set([
|
export const IMAGE_FILE_EXTENSIONS = new Set([
|
||||||
'png',
|
'png',
|
||||||
'jpg',
|
'jpg',
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useCallback, useState, useRef } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { DragEvent } from 'react';
|
||||||
|
|
||||||
|
import { IS_PLATFORM } from '../../../constants/config';
|
||||||
import type { Project } from '../../../types/app';
|
import type { Project } from '../../../types/app';
|
||||||
import { api } from '../../../utils/api';
|
import {
|
||||||
|
MAX_FILE_UPLOAD_COUNT,
|
||||||
|
MAX_FILE_UPLOAD_SIZE_BYTES,
|
||||||
|
MAX_FILE_UPLOAD_SIZE_LABEL,
|
||||||
|
} from '../constants/constants';
|
||||||
|
|
||||||
type UseFileTreeUploadOptions = {
|
type UseFileTreeUploadOptions = {
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
@@ -8,6 +15,141 @@ type UseFileTreeUploadOptions = {
|
|||||||
showToast: (message: string, type: 'success' | 'error') => void;
|
showToast: (message: string, type: 'success' | 'error') => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FileTreeUploadProgressState = {
|
||||||
|
status: 'uploading' | 'complete' | 'error';
|
||||||
|
progress: number;
|
||||||
|
fileCount: number;
|
||||||
|
uploadedCount?: number;
|
||||||
|
fileName?: string;
|
||||||
|
targetPath?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadResponse = {
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
files?: unknown[];
|
||||||
|
uploadedCount?: number;
|
||||||
|
requestedFileCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPLETE_PROGRESS_CLEAR_DELAY_MS = 1400;
|
||||||
|
const ERROR_PROGRESS_CLEAR_DELAY_MS = 3200;
|
||||||
|
|
||||||
|
const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files');
|
||||||
|
|
||||||
|
const getRelativePath = (file: File) => {
|
||||||
|
const fileWithRelativePath = file as File & { webkitRelativePath?: string };
|
||||||
|
return fileWithRelativePath.webkitRelativePath || file.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileDisplayName = (file: File) => {
|
||||||
|
const relativePath = getRelativePath(file);
|
||||||
|
return relativePath.split(/[\\/]/).pop() || file.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateFilesForUpload = (files: File[]): string | null => {
|
||||||
|
if (files.length > MAX_FILE_UPLOAD_COUNT) {
|
||||||
|
return `You can upload up to ${MAX_FILE_UPLOAD_COUNT} files at once.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oversizedFile = files.find((file) => file.size > MAX_FILE_UPLOAD_SIZE_BYTES);
|
||||||
|
if (oversizedFile) {
|
||||||
|
return `${getFileDisplayName(oversizedFile)} is larger than ${MAX_FILE_UPLOAD_SIZE_LABEL}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseUploadResponse = (xhr: XMLHttpRequest): UploadResponse => {
|
||||||
|
if (!xhr.responseText) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(xhr.responseText) as UploadResponse;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUploadSuccessMessage = (uploadedCount: number, requestedFileCount: number) => {
|
||||||
|
if (uploadedCount !== requestedFileCount) {
|
||||||
|
return `Uploaded ${uploadedCount} of ${requestedFileCount} ${pluralizeFiles(requestedFileCount)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Uploaded ${uploadedCount} ${pluralizeFiles(uploadedCount)} successfully`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUploadFormData = (files: File[], targetPath: string) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
const relativePaths: string[] = [];
|
||||||
|
|
||||||
|
formData.append('targetPath', targetPath);
|
||||||
|
formData.append('requestedFileCount', String(files.length));
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const relativePath = getRelativePath(file);
|
||||||
|
const cleanFile = new File([file], relativePath.split(/[\\/]/).pop() || file.name, {
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
});
|
||||||
|
|
||||||
|
formData.append('files', cleanFile);
|
||||||
|
relativePaths.push(relativePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
formData.append('relativePaths', JSON.stringify(relativePaths));
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFormDataWithProgress = (
|
||||||
|
projectId: string,
|
||||||
|
formData: FormData,
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
) =>
|
||||||
|
new Promise<UploadResponse>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.open('POST', `/api/projects/${encodeURIComponent(projectId)}/files/upload`);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
if (!IS_PLATFORM && token) {
|
||||||
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (!event.lengthComputable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep 100% for the server response so the UI can distinguish transfer
|
||||||
|
// completion from the final write/refresh step.
|
||||||
|
onProgress(Math.min(99, Math.round((event.loaded / event.total) * 100)));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
const refreshedToken = xhr.getResponseHeader('X-Refreshed-Token');
|
||||||
|
if (refreshedToken) {
|
||||||
|
localStorage.setItem('auth-token', refreshedToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseUploadResponse(xhr);
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error(payload.error || payload.message || `Upload failed with status ${xhr.status}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new Error('Upload failed. Check your connection and try again.'));
|
||||||
|
xhr.onabort = () => reject(new Error('Upload canceled.'));
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
|
||||||
// Helper function to read all files from a directory entry recursively
|
// Helper function to read all files from a directory entry recursively
|
||||||
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
|
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
|
||||||
const files: File[] = [];
|
const files: File[] = [];
|
||||||
@@ -57,6 +199,48 @@ const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry,
|
|||||||
return files;
|
return files;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const collectDroppedFiles = async (dataTransfer: DataTransfer) => {
|
||||||
|
const files: File[] = [];
|
||||||
|
|
||||||
|
// Use DataTransferItemList for folder support
|
||||||
|
const { items } = dataTransfer;
|
||||||
|
if (items) {
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.kind !== 'file') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
||||||
|
if (!entry) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file = await new Promise<File>((resolve, reject) => {
|
||||||
|
(entry as FileSystemFileEntry).file(resolve, reject);
|
||||||
|
});
|
||||||
|
files.push(file);
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
// Pass the directory name as basePath so files include the folder path
|
||||||
|
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
|
||||||
|
files.push(...dirFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for browsers that don't support webkitGetAsEntry
|
||||||
|
for (const file of Array.from(dataTransfer.files)) {
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
export const useFileTreeUpload = ({
|
export const useFileTreeUpload = ({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -65,20 +249,150 @@ export const useFileTreeUpload = ({
|
|||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||||
const [operationLoading, setOperationLoading] = useState(false);
|
const [operationLoading, setOperationLoading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<FileTreeUploadProgressState | null>(null);
|
||||||
const treeRef = useRef<HTMLDivElement>(null);
|
const treeRef = useRef<HTMLDivElement>(null);
|
||||||
|
const clearProgressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
const clearProgressTimer = useCallback(() => {
|
||||||
|
if (clearProgressTimerRef.current) {
|
||||||
|
clearTimeout(clearProgressTimerRef.current);
|
||||||
|
clearProgressTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleProgressClear = useCallback(
|
||||||
|
(delay: number) => {
|
||||||
|
clearProgressTimer();
|
||||||
|
clearProgressTimerRef.current = setTimeout(() => {
|
||||||
|
setUploadProgress(null);
|
||||||
|
clearProgressTimerRef.current = null;
|
||||||
|
}, delay);
|
||||||
|
},
|
||||||
|
[clearProgressTimer],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => clearProgressTimer, [clearProgressTimer]);
|
||||||
|
|
||||||
|
const setUploadError = useCallback(
|
||||||
|
(message: string, fileCount: number, targetPath = '', fileName?: string, progress = 0) => {
|
||||||
|
setUploadProgress({
|
||||||
|
status: 'error',
|
||||||
|
progress,
|
||||||
|
fileCount,
|
||||||
|
fileName,
|
||||||
|
targetPath,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
scheduleProgressClear(ERROR_PROGRESS_CLEAR_DELAY_MS);
|
||||||
|
},
|
||||||
|
[scheduleProgressClear],
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadFiles = useCallback(
|
||||||
|
async (files: File[], targetPath = '') => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
setDropTarget(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = files.length === 1 ? getFileDisplayName(files[0]) : undefined;
|
||||||
|
|
||||||
|
if (!selectedProject) {
|
||||||
|
const message = 'Select a project before uploading files.';
|
||||||
|
showToast(message, 'error');
|
||||||
|
setUploadError(message, files.length, targetPath, fileName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = validateFilesForUpload(files);
|
||||||
|
if (validationError) {
|
||||||
|
showToast(validationError, 'error');
|
||||||
|
setUploadError(validationError, files.length, targetPath, fileName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearProgressTimer();
|
||||||
|
setOperationLoading(true);
|
||||||
|
setUploadProgress({
|
||||||
|
status: 'uploading',
|
||||||
|
progress: 0,
|
||||||
|
fileCount: files.length,
|
||||||
|
fileName,
|
||||||
|
targetPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
let latestProgress = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await uploadFormDataWithProgress(
|
||||||
|
selectedProject.projectId,
|
||||||
|
buildUploadFormData(files, targetPath),
|
||||||
|
(progress) => {
|
||||||
|
latestProgress = progress;
|
||||||
|
setUploadProgress((current) =>
|
||||||
|
current && current.status === 'uploading'
|
||||||
|
? { ...current, progress }
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadedCount =
|
||||||
|
typeof response.uploadedCount === 'number' ? response.uploadedCount : response.files?.length ?? files.length;
|
||||||
|
const requestedFileCount =
|
||||||
|
typeof response.requestedFileCount === 'number' ? response.requestedFileCount : files.length;
|
||||||
|
|
||||||
|
setUploadProgress({
|
||||||
|
status: 'complete',
|
||||||
|
progress: 100,
|
||||||
|
fileCount: requestedFileCount,
|
||||||
|
uploadedCount,
|
||||||
|
fileName,
|
||||||
|
targetPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
showToast(formatUploadSuccessMessage(uploadedCount, requestedFileCount), 'success');
|
||||||
|
scheduleProgressClear(COMPLETE_PROGRESS_CLEAR_DELAY_MS);
|
||||||
|
onRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Upload failed';
|
||||||
|
console.error('Upload error:', err);
|
||||||
|
showToast(message, 'error');
|
||||||
|
setUploadError(message, files.length, targetPath, fileName, latestProgress);
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(false);
|
||||||
|
setDropTarget(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
clearProgressTimer,
|
||||||
|
onRefresh,
|
||||||
|
scheduleProgressClear,
|
||||||
|
selectedProject,
|
||||||
|
setUploadError,
|
||||||
|
showToast,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback(
|
||||||
|
async (fileList: FileList | File[]) => {
|
||||||
|
await uploadFiles(Array.from(fileList), '');
|
||||||
|
},
|
||||||
|
[uploadFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDragOver(true);
|
setIsDragOver(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
const handleDragOver = useCallback((e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
const handleDragLeave = useCallback((e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Only set isDragOver to false if we're leaving the entire tree
|
// Only set isDragOver to false if we're leaving the entire tree
|
||||||
@@ -88,103 +402,35 @@ export const useFileTreeUpload = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
const handleDrop = useCallback(
|
||||||
e.preventDefault();
|
async (e: DragEvent) => {
|
||||||
e.stopPropagation();
|
e.preventDefault();
|
||||||
setIsDragOver(false);
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
const targetPath = dropTarget || '';
|
const targetPath = dropTarget || '';
|
||||||
setOperationLoading(true);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files: File[] = [];
|
const files = await collectDroppedFiles(e.dataTransfer);
|
||||||
|
await uploadFiles(files, targetPath);
|
||||||
// Use DataTransferItemList for folder support
|
} catch (err) {
|
||||||
const items = e.dataTransfer.items;
|
const message = err instanceof Error ? err.message : 'Could not read dropped files';
|
||||||
if (items) {
|
console.error('Upload error:', err);
|
||||||
for (const item of Array.from(items)) {
|
showToast(message, 'error');
|
||||||
if (item.kind === 'file') {
|
setUploadError(message, 0, targetPath);
|
||||||
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
|
||||||
|
|
||||||
if (entry) {
|
|
||||||
if (entry.isFile) {
|
|
||||||
const file = await new Promise<File>((resolve, reject) => {
|
|
||||||
(entry as FileSystemFileEntry).file(resolve, reject);
|
|
||||||
});
|
|
||||||
files.push(file);
|
|
||||||
} else if (entry.isDirectory) {
|
|
||||||
// Pass the directory name as basePath so files include the folder path
|
|
||||||
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
|
|
||||||
files.push(...dirFiles);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback for browsers that don't support webkitGetAsEntry
|
|
||||||
const fileList = e.dataTransfer.files;
|
|
||||||
for (const file of Array.from(fileList)) {
|
|
||||||
files.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
|
||||||
setOperationLoading(false);
|
|
||||||
setDropTarget(null);
|
setDropTarget(null);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[dropTarget, setUploadError, showToast, uploadFiles],
|
||||||
|
);
|
||||||
|
|
||||||
const formData = new FormData();
|
const handleItemDragOver = useCallback((e: DragEvent, itemPath: string) => {
|
||||||
formData.append('targetPath', targetPath);
|
|
||||||
|
|
||||||
// Store relative paths separately since FormData strips path info from File.name
|
|
||||||
const relativePaths: string[] = [];
|
|
||||||
files.forEach((file) => {
|
|
||||||
// Create a new file with just the filename (without path) for FormData
|
|
||||||
// but store the relative path separately
|
|
||||||
const cleanFile = new File([file], file.name.split('/').pop()!, {
|
|
||||||
type: file.type,
|
|
||||||
lastModified: file.lastModified
|
|
||||||
});
|
|
||||||
formData.append('files', cleanFile);
|
|
||||||
relativePaths.push(file.name); // Keep the full relative path
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send relative paths as a JSON array
|
|
||||||
formData.append('relativePaths', JSON.stringify(relativePaths));
|
|
||||||
|
|
||||||
const response = await api.post(
|
|
||||||
// File upload endpoint is keyed by DB projectId post-migration.
|
|
||||||
`/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`,
|
|
||||||
formData
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
throw new Error(data.error || 'Upload failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast(
|
|
||||||
`Uploaded ${files.length} file(s)`,
|
|
||||||
'success'
|
|
||||||
);
|
|
||||||
onRefresh();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Upload error:', err);
|
|
||||||
showToast(err instanceof Error ? err.message : 'Upload failed', 'error');
|
|
||||||
} finally {
|
|
||||||
setOperationLoading(false);
|
|
||||||
setDropTarget(null);
|
|
||||||
}
|
|
||||||
}, [dropTarget, selectedProject, onRefresh, showToast]);
|
|
||||||
|
|
||||||
const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDropTarget(itemPath);
|
setDropTarget(itemPath);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => {
|
const handleItemDrop = useCallback((e: DragEvent, itemPath: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDropTarget(itemPath);
|
setDropTarget(itemPath);
|
||||||
@@ -194,7 +440,9 @@ export const useFileTreeUpload = ({
|
|||||||
isDragOver,
|
isDragOver,
|
||||||
dropTarget,
|
dropTarget,
|
||||||
operationLoading,
|
operationLoading,
|
||||||
|
uploadProgress,
|
||||||
treeRef,
|
treeRef,
|
||||||
|
handleFileSelect,
|
||||||
handleDragEnter,
|
handleDragEnter,
|
||||||
handleDragOver,
|
handleDragOver,
|
||||||
handleDragLeave,
|
handleDragLeave,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
|
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from '../../../lib/utils';
|
||||||
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
|
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
|
||||||
import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
|
import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
|
||||||
@@ -13,10 +14,12 @@ import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
|
|||||||
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
|
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
|
||||||
import { Project } from '../../../types/app';
|
import { Project } from '../../../types/app';
|
||||||
import { ScrollArea, Input } from '../../../shared/view/ui';
|
import { ScrollArea, Input } from '../../../shared/view/ui';
|
||||||
|
|
||||||
import FileTreeBody from './FileTreeBody';
|
import FileTreeBody from './FileTreeBody';
|
||||||
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
|
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
|
||||||
import FileTreeHeader from './FileTreeHeader';
|
import FileTreeHeader from './FileTreeHeader';
|
||||||
import FileTreeLoadingState from './FileTreeLoadingState';
|
import FileTreeLoadingState from './FileTreeLoadingState';
|
||||||
|
import FileTreeUploadProgress from './FileTreeUploadProgress';
|
||||||
import ImageViewer from './ImageViewer';
|
import ImageViewer from './ImageViewer';
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +69,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
onRefresh: refreshFiles,
|
onRefresh: refreshFiles,
|
||||||
showToast,
|
showToast,
|
||||||
});
|
});
|
||||||
|
const operationLoading = operations.operationLoading || upload.operationLoading;
|
||||||
|
|
||||||
// Focus input when creating new item
|
// Focus input when creating new item
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,14 +150,19 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
onViewModeChange={changeViewMode}
|
onViewModeChange={changeViewMode}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchQueryChange={setSearchQuery}
|
onSearchQueryChange={setSearchQuery}
|
||||||
|
onUploadFiles={upload.handleFileSelect}
|
||||||
onNewFile={() => operations.handleStartCreate('', 'file')}
|
onNewFile={() => operations.handleStartCreate('', 'file')}
|
||||||
onNewFolder={() => operations.handleStartCreate('', 'directory')}
|
onNewFolder={() => operations.handleStartCreate('', 'directory')}
|
||||||
onRefresh={refreshFiles}
|
onRefresh={refreshFiles}
|
||||||
onCollapseAll={collapseAll}
|
onCollapseAll={collapseAll}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
operationLoading={operations.operationLoading}
|
operationLoading={operationLoading}
|
||||||
|
isUploading={upload.uploadProgress?.status === 'uploading'}
|
||||||
|
uploadProgress={upload.uploadProgress?.progress ?? null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FileTreeUploadProgress upload={upload.uploadProgress} />
|
||||||
|
|
||||||
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
|
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
|
||||||
|
|
||||||
<ScrollArea className="flex-1 px-2 py-1">
|
<ScrollArea className="flex-1 px-2 py-1">
|
||||||
@@ -184,7 +193,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
}, 100);
|
}, 100);
|
||||||
}}
|
}}
|
||||||
className="h-6 flex-1 text-sm"
|
className="h-6 flex-1 text-sm"
|
||||||
disabled={operations.operationLoading}
|
disabled={operationLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -213,7 +222,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
handleConfirmRename={operations.handleConfirmRename}
|
handleConfirmRename={operations.handleConfirmRename}
|
||||||
handleCancelRename={operations.handleCancelRename}
|
handleCancelRename={operations.handleCancelRename}
|
||||||
renameInputRef={renameInputRef}
|
renameInputRef={renameInputRef}
|
||||||
operationLoading={operations.operationLoading}
|
operationLoading={operationLoading}
|
||||||
/>
|
/>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
@@ -251,17 +260,17 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={operations.handleCancelDelete}
|
onClick={operations.handleCancelDelete}
|
||||||
disabled={operations.operationLoading}
|
disabled={operationLoading}
|
||||||
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
{t('common.cancel', 'Cancel')}
|
{t('common.cancel', 'Cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={operations.handleConfirmDelete}
|
onClick={operations.handleConfirmDelete}
|
||||||
disabled={operations.operationLoading}
|
disabled={operationLoading}
|
||||||
className="flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 disabled:opacity-50"
|
className="flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{operations.operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
{operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{t('fileTree.delete.confirm', 'Delete')}
|
{t('fileTree.delete.confirm', 'Delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react';
|
import { useRef } from 'react';
|
||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
import { ChevronDown, Eye, FileText, FolderPlus, List, Loader2, RefreshCw, Search, TableProperties, Upload, X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button, Input } from '../../../shared/view/ui';
|
import { Button, Input } from '../../../shared/view/ui';
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from '../../../lib/utils';
|
||||||
|
import { MAX_FILE_UPLOAD_SIZE_LABEL } from '../constants/constants';
|
||||||
import type { FileTreeViewMode } from '../types/types';
|
import type { FileTreeViewMode } from '../types/types';
|
||||||
|
|
||||||
type FileTreeHeaderProps = {
|
type FileTreeHeaderProps = {
|
||||||
@@ -12,11 +16,14 @@ type FileTreeHeaderProps = {
|
|||||||
// Toolbar actions
|
// Toolbar actions
|
||||||
onNewFile?: () => void;
|
onNewFile?: () => void;
|
||||||
onNewFolder?: () => void;
|
onNewFolder?: () => void;
|
||||||
|
onUploadFiles?: (files: FileList) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onCollapseAll?: () => void;
|
onCollapseAll?: () => void;
|
||||||
// Loading state
|
// Loading state
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
operationLoading?: boolean;
|
operationLoading?: boolean;
|
||||||
|
isUploading?: boolean;
|
||||||
|
uploadProgress?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FileTreeHeader({
|
export default function FileTreeHeader({
|
||||||
@@ -26,12 +33,24 @@ export default function FileTreeHeader({
|
|||||||
onSearchQueryChange,
|
onSearchQueryChange,
|
||||||
onNewFile,
|
onNewFile,
|
||||||
onNewFolder,
|
onNewFolder,
|
||||||
|
onUploadFiles,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onCollapseAll,
|
onCollapseAll,
|
||||||
loading,
|
loading,
|
||||||
operationLoading,
|
operationLoading,
|
||||||
|
isUploading,
|
||||||
|
uploadProgress,
|
||||||
}: FileTreeHeaderProps) {
|
}: FileTreeHeaderProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleUploadInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { files } = event.target;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
onUploadFiles?.(files);
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 border-b border-border px-3 pb-2 pt-3">
|
<div className="space-y-2 border-b border-border px-3 pb-2 pt-3">
|
||||||
@@ -40,6 +59,50 @@ export default function FileTreeHeader({
|
|||||||
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
|
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
|
{onUploadFiles && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={uploadInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleUploadInputChange}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="relative h-7 w-7 p-0"
|
||||||
|
onClick={() => uploadInputRef.current?.click()}
|
||||||
|
title={
|
||||||
|
isUploading
|
||||||
|
? t('fileTree.uploadingFiles', 'Uploading files')
|
||||||
|
: t('fileTree.uploadFiles', 'Upload files (max {{size}} each)', {
|
||||||
|
size: MAX_FILE_UPLOAD_SIZE_LABEL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={t('fileTree.uploadFiles', 'Upload files (max {{size}} each)', {
|
||||||
|
size: MAX_FILE_UPLOAD_SIZE_LABEL,
|
||||||
|
})}
|
||||||
|
disabled={operationLoading}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{isUploading && typeof uploadProgress === 'number' && (
|
||||||
|
<span className="absolute bottom-0.5 left-1/2 h-0.5 w-4 -translate-x-1/2 overflow-hidden rounded-full bg-primary/20">
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full bg-primary transition-[width] duration-150"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{onNewFile && (
|
{onNewFile && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
90
src/components/file-tree/view/FileTreeUploadProgress.tsx
Normal file
90
src/components/file-tree/view/FileTreeUploadProgress.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { AlertCircle, CheckCircle2, Upload } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { cn } from '../../../lib/utils';
|
||||||
|
import type { FileTreeUploadProgressState } from '../hooks/useFileTreeUpload';
|
||||||
|
|
||||||
|
type FileTreeUploadProgressProps = {
|
||||||
|
upload: FileTreeUploadProgressState | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clampProgress = (progress: number) => Math.min(100, Math.max(0, progress));
|
||||||
|
|
||||||
|
const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files');
|
||||||
|
|
||||||
|
export default function FileTreeUploadProgress({ upload }: FileTreeUploadProgressProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!upload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = clampProgress(upload.progress);
|
||||||
|
const isUploading = upload.status === 'uploading';
|
||||||
|
const isComplete = upload.status === 'complete';
|
||||||
|
const isError = upload.status === 'error';
|
||||||
|
const fileSummary =
|
||||||
|
upload.fileCount === 1 && upload.fileName
|
||||||
|
? upload.fileName
|
||||||
|
: `${upload.fileCount} ${pluralizeFiles(upload.fileCount)}`;
|
||||||
|
|
||||||
|
const title = isUploading
|
||||||
|
? t('fileTree.uploadingFiles', 'Uploading files')
|
||||||
|
: isComplete
|
||||||
|
? t('fileTree.uploadComplete', 'Upload complete')
|
||||||
|
: t('fileTree.uploadFailed', 'Upload failed');
|
||||||
|
|
||||||
|
const detail = isError
|
||||||
|
? upload.error
|
||||||
|
: isComplete && typeof upload.uploadedCount === 'number'
|
||||||
|
? t('fileTree.uploadedCount', 'Uploaded {{uploaded}} of {{total}} {{label}}', {
|
||||||
|
uploaded: upload.uploadedCount,
|
||||||
|
total: upload.fileCount,
|
||||||
|
label: pluralizeFiles(upload.fileCount),
|
||||||
|
})
|
||||||
|
: fileSummary;
|
||||||
|
|
||||||
|
const Icon = isError ? AlertCircle : isComplete ? CheckCircle2 : Upload;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-b px-3 py-2 transition-colors',
|
||||||
|
isError
|
||||||
|
? 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||||
|
: isComplete
|
||||||
|
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||||
|
: 'border-primary/20 bg-primary/10 text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-h-[36px] items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md',
|
||||||
|
isError ? 'bg-red-500/15' : isComplete ? 'bg-emerald-500/15' : 'bg-primary/15',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-3.5 w-3.5', isUploading && 'animate-pulse')} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="truncate text-xs font-medium">{title}</span>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
{isUploading ? `${progress}%` : isComplete ? t('common.done', 'Done') : t('common.failed', 'Failed')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate text-[11px] text-muted-foreground">{detail}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-background/80">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-[width] duration-200',
|
||||||
|
isError ? 'bg-red-500' : isComplete ? 'bg-emerald-500' : 'bg-primary',
|
||||||
|
)}
|
||||||
|
style={{ width: `${isError ? Math.max(progress, 8) : progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +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 {
|
||||||
|
MarkSessionIdle,
|
||||||
|
MarkSessionProcessing,
|
||||||
|
SessionActivityMap,
|
||||||
|
} from '../../../hooks/useSessionProtection';
|
||||||
import type { SessionNavigationOptions } from '../../chat/types/types';
|
import type { SessionNavigationOptions } from '../../chat/types/types';
|
||||||
|
|
||||||
export type SessionLifecycleHandler = (sessionId?: string | null) => void;
|
|
||||||
|
|
||||||
export type TaskMasterTask = {
|
export type TaskMasterTask = {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -46,11 +49,9 @@ export type MainContentProps = {
|
|||||||
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;
|
onShowSettings: () => void;
|
||||||
externalMessageUpdate: number;
|
externalMessageUpdate: number;
|
||||||
|
|||||||
@@ -42,10 +42,8 @@ function MainContent({
|
|||||||
onMenuClick,
|
onMenuClick,
|
||||||
isLoading,
|
isLoading,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
onSessionActive,
|
|
||||||
onSessionInactive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
@@ -131,10 +129,8 @@ function MainContent({
|
|||||||
latestMessage={latestMessage}
|
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}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-start
|
|||||||
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
|
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
|
||||||
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';
|
||||||
|
|
||||||
type PluginRecommendation = {
|
type PluginRecommendation = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -72,6 +73,14 @@ const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
|
|||||||
icon: Clock,
|
icon: Clock,
|
||||||
source: 'unofficial',
|
source: 'unofficial',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'prism',
|
||||||
|
translationKey: 'prismCloudCLI',
|
||||||
|
repoUrl: PRISM_CLOUDCLI_PLUGIN_URL,
|
||||||
|
installedNames: ['prism'],
|
||||||
|
icon: Activity,
|
||||||
|
source: 'unofficial'
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
function repoSlug(repoUrl: string) {
|
function repoSlug(repoUrl: string) {
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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,4 +1,5 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Bell, BellOff, BellRing, Loader2 } from 'lucide-react';
|
import { Bell, BellOff, BellRing, Loader2, Play, Volume2 } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Button } from '../../../../shared/view/ui';
|
||||||
|
import { playChatCompletionSound } from '../../../../utils/notificationSound';
|
||||||
import type { NotificationPreferencesState } from '../../types/types';
|
import type { NotificationPreferencesState } from '../../types/types';
|
||||||
|
|
||||||
type NotificationsSettingsTabProps = {
|
type NotificationsSettingsTabProps = {
|
||||||
@@ -82,6 +85,54 @@ export default function NotificationsSettingsTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Volume2 className="h-4 w-4 text-blue-600" />
|
||||||
|
<h4 className="font-medium text-foreground">
|
||||||
|
{t('notifications.sound.title', { defaultValue: 'Sound' })}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('notifications.sound.description', {
|
||||||
|
defaultValue: 'Play a short tone when a chat run finishes.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex shrink-0 items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationPreferences.channels.sound}
|
||||||
|
onChange={(event) =>
|
||||||
|
onNotificationPreferencesChange({
|
||||||
|
...notificationPreferences,
|
||||||
|
channels: {
|
||||||
|
...notificationPreferences.channels,
|
||||||
|
sound: event.target.checked,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{t('notifications.sound.enabled', { defaultValue: 'Enabled' })}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
void playChatCompletionSound({ force: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
{t('notifications.sound.test', { defaultValue: 'Test sound' })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
||||||
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { authenticatedFetch } from "../../../utils/api";
|
import { authenticatedFetch } from "../../../utils/api";
|
||||||
import { ReleaseInfo } from "../../../types/sharedTypes";
|
import { ReleaseInfo } from "../../../types/sharedTypes";
|
||||||
@@ -154,8 +156,10 @@ export function VersionUpgradeModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-64 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-600 dark:bg-gray-700/50">
|
<div className="max-h-64 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-600 dark:bg-gray-700/50">
|
||||||
<div className="prose prose-sm max-w-none whitespace-pre-wrap text-sm text-gray-700 dark:prose-invert dark:text-gray-300">
|
<div className="prose prose-sm max-w-none text-sm text-gray-700 dark:prose-invert dark:text-gray-300">
|
||||||
{cleanChangelog(releaseInfo.body)}
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={changelogComponents}>
|
||||||
|
{cleanChangelog(releaseInfo.body)}
|
||||||
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,6 +240,14 @@ export function VersionUpgradeModal({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changelogComponents = {
|
||||||
|
a: ({ href, children }: { href?: string; children?: ReactNode }) => (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline dark:text-blue-400">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
// Clean up changelog by removing GitHub-specific metadata
|
// Clean up changelog by removing GitHub-specific metadata
|
||||||
const cleanChangelog = (body: string) => {
|
const cleanChangelog = (body: string) => {
|
||||||
if (!body) return '';
|
if (!body) return '';
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import type {
|
|||||||
ProjectsUpdatedMessage,
|
ProjectsUpdatedMessage,
|
||||||
} from '../types/app';
|
} from '../types/app';
|
||||||
|
|
||||||
|
import type { SessionActivityMap } from './useSessionProtection';
|
||||||
|
|
||||||
type UseProjectsStateArgs = {
|
type UseProjectsStateArgs = {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
navigate: NavigateFunction;
|
navigate: NavigateFunction;
|
||||||
latestMessage: AppSocketMessage | null;
|
latestMessage: AppSocketMessage | null;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
activeSessions: Set<string>;
|
activeSessions: SessionActivityMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FetchProjectsOptions = {
|
type FetchProjectsOptions = {
|
||||||
|
|||||||
@@ -1,55 +1,103 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map key for a request that is in flight before the provider has announced
|
||||||
|
* its real session id (a brand-new conversation). `session_created` migrates
|
||||||
|
* the entry to the concrete session id.
|
||||||
|
*/
|
||||||
|
export const PENDING_SESSION_ID = '__pending_session__';
|
||||||
|
|
||||||
|
export interface SessionActivity {
|
||||||
|
/** Provider-supplied status line; null renders the default activity label. */
|
||||||
|
statusText: string | null;
|
||||||
|
canInterrupt: boolean;
|
||||||
|
/**
|
||||||
|
* When this request was first marked as processing (client clock). Drives
|
||||||
|
* the elapsed-time display and the stale `session-status` reply guard.
|
||||||
|
*/
|
||||||
|
startedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionActivityMap = ReadonlyMap<string, SessionActivity>;
|
||||||
|
|
||||||
|
export type MarkSessionProcessing = (
|
||||||
|
sessionId?: string | null,
|
||||||
|
activity?: { statusText?: string | null; canInterrupt?: boolean },
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export type MarkSessionIdle = (
|
||||||
|
sessionId?: string | null,
|
||||||
|
opts?: { ifStartedBefore?: number },
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for which sessions are actively processing a
|
||||||
|
* request. Everything the chat UI shows (activity indicator, abort
|
||||||
|
* availability, status text) is derived from this map; terminal events
|
||||||
|
* (`complete`, `error`, abort, an authoritative idle status reply) delete the
|
||||||
|
* entry atomically. The map also drives session protection: project refreshes
|
||||||
|
* are suppressed for sessions that have an entry here.
|
||||||
|
*/
|
||||||
export function useSessionProtection() {
|
export function useSessionProtection() {
|
||||||
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
|
const [processingSessions, setProcessingSessions] = useState<Map<string, SessionActivity>>(
|
||||||
const [processingSessions, setProcessingSessions] = useState<Set<string>>(new Set());
|
new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
const markSessionAsActive = useCallback((sessionId?: string | null) => {
|
const markSessionProcessing = useCallback<MarkSessionProcessing>((sessionId, activity) => {
|
||||||
if (!sessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveSessions((prev) => new Set([...prev, sessionId]));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const markSessionAsInactive = useCallback((sessionId?: string | null) => {
|
|
||||||
if (!sessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveSessions((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(sessionId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const markSessionAsProcessing = useCallback((sessionId?: string | null) => {
|
|
||||||
if (!sessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProcessingSessions((prev) => new Set([...prev, sessionId]));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const markSessionAsNotProcessing = useCallback((sessionId?: string | null) => {
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProcessingSessions((prev) => {
|
setProcessingSessions((prev) => {
|
||||||
const next = new Set(prev);
|
const existing = prev.get(sessionId);
|
||||||
next.delete(sessionId);
|
const next: SessionActivity = {
|
||||||
return next;
|
statusText:
|
||||||
|
activity?.statusText !== undefined ? activity.statusText : existing?.statusText ?? null,
|
||||||
|
canInterrupt: activity?.canInterrupt ?? existing?.canInterrupt ?? true,
|
||||||
|
startedAt: existing?.startedAt ?? Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
existing
|
||||||
|
&& existing.statusText === next.statusText
|
||||||
|
&& existing.canInterrupt === next.canInterrupt
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = new Map(prev);
|
||||||
|
updated.set(sessionId, next);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markSessionIdle = useCallback<MarkSessionIdle>((sessionId, opts) => {
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessingSessions((prev) => {
|
||||||
|
const existing = prev.get(sessionId);
|
||||||
|
if (!existing) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against stale `check-session-status` replies: if a new request
|
||||||
|
// started after the check was sent, the idle reply describes the older
|
||||||
|
// request and must not clear the newer one.
|
||||||
|
if (opts?.ifStartedBefore !== undefined && existing.startedAt >= opts.ifStartedBefore) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = new Map(prev);
|
||||||
|
updated.delete(sessionId);
|
||||||
|
return updated;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeSessions,
|
|
||||||
processingSessions,
|
processingSessions,
|
||||||
markSessionAsActive,
|
markSessionProcessing,
|
||||||
markSessionAsInactive,
|
markSessionIdle,
|
||||||
markSessionAsProcessing,
|
|
||||||
markSessionAsNotProcessing,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,9 +94,35 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & Token",
|
"apiTokens": "API & Token",
|
||||||
"tasks": "Aufgaben",
|
"tasks": "Aufgaben",
|
||||||
|
"notifications": "Benachrichtigungen",
|
||||||
"plugins": "Plugins",
|
"plugins": "Plugins",
|
||||||
"about": "Info"
|
"about": "Info"
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Benachrichtigungen",
|
||||||
|
"description": "Lege fest, welche Benachrichtigungen du erhältst.",
|
||||||
|
"webPush": {
|
||||||
|
"title": "Web-Push-Benachrichtigungen",
|
||||||
|
"enable": "Push-Benachrichtigungen aktivieren",
|
||||||
|
"disable": "Push-Benachrichtigungen deaktivieren",
|
||||||
|
"enabled": "Push-Benachrichtigungen sind aktiviert",
|
||||||
|
"loading": "Wird aktualisiert...",
|
||||||
|
"unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
|
||||||
|
"denied": "Push-Benachrichtigungen sind blockiert. Bitte erlaube sie in den Browsereinstellungen."
|
||||||
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Ton",
|
||||||
|
"description": "Spielt einen kurzen Ton ab, wenn ein Chat-Lauf abgeschlossen ist.",
|
||||||
|
"enabled": "Aktiviert",
|
||||||
|
"test": "Ton testen"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "Ereignistypen",
|
||||||
|
"actionRequired": "Aktion erforderlich",
|
||||||
|
"stop": "Lauf gestoppt",
|
||||||
|
"error": "Lauf fehlgeschlagen"
|
||||||
|
}
|
||||||
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
"label": "Darkmode",
|
"label": "Darkmode",
|
||||||
|
|||||||
@@ -224,6 +224,7 @@
|
|||||||
"label": "{{time}} elapsed",
|
"label": "{{time}} elapsed",
|
||||||
"startingNow": "Starting now"
|
"startingNow": "Starting now"
|
||||||
},
|
},
|
||||||
|
"stop": "Stop",
|
||||||
"controls": {
|
"controls": {
|
||||||
"stopGeneration": "Stop Generation",
|
"stopGeneration": "Stop Generation",
|
||||||
"pressEscToStop": "Press Esc anytime to stop"
|
"pressEscToStop": "Press Esc anytime to stop"
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "Push notifications are not supported in this browser.",
|
"unsupported": "Push notifications are not supported in this browser.",
|
||||||
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Sound",
|
||||||
|
"description": "Play a short tone when a chat run finishes.",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"test": "Test sound"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Event Types",
|
"title": "Event Types",
|
||||||
"actionRequired": "Action required",
|
"actionRequired": "Action required",
|
||||||
@@ -502,6 +508,12 @@
|
|||||||
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
|
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
|
||||||
"install": "Install"
|
"install": "Install"
|
||||||
},
|
},
|
||||||
|
"prismCloudCLI": {
|
||||||
|
"name": "PRISM CloudCLI",
|
||||||
|
"badge": "unofficial",
|
||||||
|
"description": "Session intelligence for Claude Code, inside CloudCLI. See why your sessions are burning tokens without leaving the browser.",
|
||||||
|
"install": "Install"
|
||||||
|
},
|
||||||
"morePlugins": "More",
|
"morePlugins": "More",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "Le notifiche push non sono supportate in questo browser.",
|
"unsupported": "Le notifiche push non sono supportate in questo browser.",
|
||||||
"denied": "Le notifiche push sono bloccate. Abilitale nelle impostazioni del browser."
|
"denied": "Le notifiche push sono bloccate. Abilitale nelle impostazioni del browser."
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Suono",
|
||||||
|
"description": "Riproduci un breve tono quando termina un'esecuzione della chat.",
|
||||||
|
"enabled": "Attivato",
|
||||||
|
"test": "Prova suono"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Tipi di evento",
|
"title": "Tipi di evento",
|
||||||
"actionRequired": "Azione richiesta",
|
"actionRequired": "Azione richiesta",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
||||||
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "サウンド",
|
||||||
|
"description": "チャット実行が完了したときに短い音を再生します。",
|
||||||
|
"enabled": "有効",
|
||||||
|
"test": "サウンドをテスト"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "イベント種別",
|
"title": "イベント種別",
|
||||||
"actionRequired": "対応が必要",
|
"actionRequired": "対応が必要",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
||||||
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "소리",
|
||||||
|
"description": "채팅 실행이 완료되면 짧은 알림음을 재생합니다.",
|
||||||
|
"enabled": "사용",
|
||||||
|
"test": "소리 테스트"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "이벤트 유형",
|
"title": "이벤트 유형",
|
||||||
"actionRequired": "작업 필요",
|
"actionRequired": "작업 필요",
|
||||||
|
|||||||
@@ -94,9 +94,35 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API и токены",
|
"apiTokens": "API и токены",
|
||||||
"tasks": "Задачи",
|
"tasks": "Задачи",
|
||||||
|
"notifications": "Уведомления",
|
||||||
"plugins": "Плагины",
|
"plugins": "Плагины",
|
||||||
"about": "О программе"
|
"about": "О программе"
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Уведомления",
|
||||||
|
"description": "Управляйте тем, какие события уведомлений вы получаете.",
|
||||||
|
"webPush": {
|
||||||
|
"title": "Web Push уведомления",
|
||||||
|
"enable": "Включить Push уведомления",
|
||||||
|
"disable": "Отключить Push уведомления",
|
||||||
|
"enabled": "Push уведомления включены",
|
||||||
|
"loading": "Обновление...",
|
||||||
|
"unsupported": "Push уведомления не поддерживаются в этом браузере.",
|
||||||
|
"denied": "Push уведомления заблокированы. Разрешите их в настройках браузера."
|
||||||
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Звук",
|
||||||
|
"description": "Воспроизводить короткий сигнал при завершении запуска чата.",
|
||||||
|
"enabled": "Включено",
|
||||||
|
"test": "Проверить звук"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "Типы событий",
|
||||||
|
"actionRequired": "Требуется действие",
|
||||||
|
"stop": "Запуск остановлен",
|
||||||
|
"error": "Запуск завершился с ошибкой"
|
||||||
|
}
|
||||||
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
"label": "Темная тема",
|
"label": "Темная тема",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "Bu tarayıcıda push bildirimleri desteklenmiyor.",
|
"unsupported": "Bu tarayıcıda push bildirimleri desteklenmiyor.",
|
||||||
"denied": "Push bildirimleri engellendi. Lütfen tarayıcı ayarlarından izin ver."
|
"denied": "Push bildirimleri engellendi. Lütfen tarayıcı ayarlarından izin ver."
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Ses",
|
||||||
|
"description": "Sohbet çalışması tamamlandığında kısa bir ton çal.",
|
||||||
|
"enabled": "Etkin",
|
||||||
|
"test": "Sesi test et"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Etkinlik Türleri",
|
"title": "Etkinlik Türleri",
|
||||||
"actionRequired": "Aksiyon gerekli",
|
"actionRequired": "Aksiyon gerekli",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "此浏览器不支持推送通知。",
|
"unsupported": "此浏览器不支持推送通知。",
|
||||||
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
|
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "声音",
|
||||||
|
"description": "聊天运行完成时播放短提示音。",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"test": "测试声音"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "事件类型",
|
"title": "事件类型",
|
||||||
"actionRequired": "需要处理",
|
"actionRequired": "需要处理",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "此瀏覽器不支援推播通知。",
|
"unsupported": "此瀏覽器不支援推播通知。",
|
||||||
"denied": "推播通知已被封鎖,請在瀏覽器設定中允許。"
|
"denied": "推播通知已被封鎖,請在瀏覽器設定中允許。"
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "聲音",
|
||||||
|
"description": "聊天執行完成時播放短提示音。",
|
||||||
|
"enabled": "已啟用",
|
||||||
|
"test": "測試聲音"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "事件類型",
|
"title": "事件類型",
|
||||||
"actionRequired": "需要處理",
|
"actionRequired": "需要處理",
|
||||||
|
|||||||
83
src/utils/notificationSound.ts
Normal file
83
src/utils/notificationSound.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
const NOTIFICATION_SOUND_ENABLED_STORAGE_KEY = 'notificationSoundEnabled';
|
||||||
|
const AudioContextConstructor =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let audioContext: AudioContext | null = null;
|
||||||
|
|
||||||
|
export const isNotificationSoundEnabled = (): boolean => {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return localStorage.getItem(NOTIFICATION_SOUND_ENABLED_STORAGE_KEY) !== 'false';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setNotificationSoundEnabled = (enabled: boolean): void => {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(NOTIFICATION_SOUND_ENABLED_STORAGE_KEY, String(enabled));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAudioContext = (): AudioContext | null => {
|
||||||
|
if (!AudioContextConstructor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audioContext) {
|
||||||
|
audioContext = new AudioContextConstructor();
|
||||||
|
}
|
||||||
|
|
||||||
|
return audioContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
const playTone = (
|
||||||
|
context: AudioContext,
|
||||||
|
frequency: number,
|
||||||
|
startsAt: number,
|
||||||
|
duration: number,
|
||||||
|
peakVolume: number,
|
||||||
|
): void => {
|
||||||
|
const oscillator = context.createOscillator();
|
||||||
|
const gain = context.createGain();
|
||||||
|
|
||||||
|
oscillator.type = 'sine';
|
||||||
|
oscillator.frequency.setValueAtTime(frequency, startsAt);
|
||||||
|
|
||||||
|
// Shape the volume so the synthesized tone starts and stops cleanly.
|
||||||
|
gain.gain.setValueAtTime(0.0001, startsAt);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(peakVolume, startsAt + 0.015);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, startsAt + duration);
|
||||||
|
|
||||||
|
oscillator.connect(gain);
|
||||||
|
gain.connect(context.destination);
|
||||||
|
oscillator.start(startsAt);
|
||||||
|
oscillator.stop(startsAt + duration + 0.02);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const playChatCompletionSound = async ({ force = false } = {}): Promise<void> => {
|
||||||
|
if (!force && !isNotificationSoundEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = getAudioContext();
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (context.state === 'suspended') {
|
||||||
|
await context.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = context.currentTime;
|
||||||
|
playTone(context, 740, now, 0.12, 0.075);
|
||||||
|
playTone(context, 988, now + 0.11, 0.16, 0.06);
|
||||||
|
} catch (error) {
|
||||||
|
// Browsers may block audio until the page receives a user gesture.
|
||||||
|
console.warn('Unable to play notification sound:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
112
src/utils/pageTitleNotification.ts
Normal file
112
src/utils/pageTitleNotification.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
const COMPLETION_TITLE_INDICATOR = '[Done]';
|
||||||
|
const TITLE_INDICATOR_CLEAR_DELAY_MS = 2000;
|
||||||
|
|
||||||
|
let clearTimer: number | null = null;
|
||||||
|
let returnListenersAttached = false;
|
||||||
|
|
||||||
|
const getIndicatorPrefix = () => `${COMPLETION_TITLE_INDICATOR} `;
|
||||||
|
|
||||||
|
const stripIndicator = (title: string): string => {
|
||||||
|
const prefix = getIndicatorPrefix();
|
||||||
|
return title.startsWith(prefix) ? title.slice(prefix.length) : title;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageIsActive = (): boolean => (
|
||||||
|
document.visibilityState === 'visible' && document.hasFocus()
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeReturnListeners = (): void => {
|
||||||
|
if (!returnListenersAttached || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.removeEventListener('visibilitychange', handleUserReturn);
|
||||||
|
window.removeEventListener('focus', handleUserReturn, true);
|
||||||
|
window.removeEventListener('click', handleUserReturn, true);
|
||||||
|
returnListenersAttached = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTitleIndicator = (): void => {
|
||||||
|
if (clearTimer !== null) {
|
||||||
|
window.clearTimeout(clearTimer);
|
||||||
|
clearTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeReturnListeners();
|
||||||
|
removePageInactiveListener();
|
||||||
|
|
||||||
|
if (document.title.startsWith(getIndicatorPrefix())) {
|
||||||
|
document.title = stripIndicator(document.title);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePageInactiveListener = (): void => {
|
||||||
|
document.removeEventListener('visibilitychange', handlePageInactive);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleClear = (): void => {
|
||||||
|
if (clearTimer !== null) {
|
||||||
|
window.clearTimeout(clearTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimer = window.setTimeout(() => {
|
||||||
|
clearTitleIndicator();
|
||||||
|
}, TITLE_INDICATOR_CLEAR_DELAY_MS);
|
||||||
|
|
||||||
|
removePageInactiveListener();
|
||||||
|
document.addEventListener('visibilitychange', handlePageInactive, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleUserReturn(): void {
|
||||||
|
if (!pageIsActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background completions keep the marker indefinitely. A tab click normally
|
||||||
|
// surfaces as visibility/focus, while an in-page click is a useful fallback.
|
||||||
|
scheduleClear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageInactive(): void {
|
||||||
|
if (document.visibilityState !== 'hidden') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearTimer !== null) {
|
||||||
|
window.clearTimeout(clearTimer);
|
||||||
|
clearTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!returnListenersAttached) {
|
||||||
|
document.addEventListener('visibilitychange', handleUserReturn);
|
||||||
|
window.addEventListener('focus', handleUserReturn, true);
|
||||||
|
window.addEventListener('click', handleUserReturn, true);
|
||||||
|
returnListenersAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showCompletionTitleIndicator = (): void => {
|
||||||
|
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseTitle = stripIndicator(document.title || 'CloudCLI UI');
|
||||||
|
document.title = `${getIndicatorPrefix()}${baseTitle}`;
|
||||||
|
|
||||||
|
if (pageIsActive()) {
|
||||||
|
scheduleClear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearTimer !== null) {
|
||||||
|
window.clearTimeout(clearTimer);
|
||||||
|
clearTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!returnListenersAttached) {
|
||||||
|
document.addEventListener('visibilitychange', handleUserReturn);
|
||||||
|
window.addEventListener('focus', handleUserReturn, true);
|
||||||
|
window.addEventListener('click', handleUserReturn, true);
|
||||||
|
returnListenersAttached = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user