Compare commits

..

4 Commits

Author SHA1 Message Date
Haileyesus
21b0f14e7a chore: add github issues board plugin 2026-06-11 14:00:41 +03:00
Simos Mikelatos
f12af8a61b Merge pull request #864 from siteboon/chore/add-plugins
chore: add plugins
2026-06-11 09:51:07 +02:00
Haileyesus
f549bd99e7 docs: update available plugin readmes 2026-06-10 16:57:40 +03:00
Haileyesus
bc34085af9 chore: add more plugins list 2026-06-10 16:49:38 +03:00
32 changed files with 600 additions and 482 deletions

View File

@@ -164,6 +164,14 @@ CloudCLI verfügt über ein Plugin-System, mit dem benutzerdefinierte Tabs mit e
| Plugin | Beschreibung | | Plugin | Beschreibung |
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Vollwertiges xterm.js-Terminal mit Multi-Tab-Unterstützung |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Überwacht lange laufende Claude-Code-Sitzungen auf Hänger und stellt Prozesssteuerungen bereit |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Erstellt arbeitsbereichsbezogene geplante Prompts und führt sie über eine lokale CLI wie Codex, Claude Code oder Gemini CLI aus |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Sitzungsintelligenz für Claude Code in CloudCLI, inklusive Sichtbarkeit des Token-Verbrauchs |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktive Claude-Code-Sitzungen anzeigen, verwalten und beenden |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | API-Kosten anhand von Modellpreisen und Token-Nutzung berechnen, mit Unterstützung für Preisvorlagen |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task-Queue-Dashboard zum Anzeigen, Filtern und Starten von Agent-Aufgaben |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-Board für GitHub Issues mit bidirektionaler TaskMaster-Synchronisierung und automatischer Installation des /github-task CLI-Skills |
### Eigenes Plugin erstellen ### Eigenes Plugin erstellen

View File

@@ -158,6 +158,14 @@ CloudCLI にはプラグインシステムがあり、独自のフロントエ
| プラグイン | 説明 | | プラグイン | 説明 |
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 複数タブに対応した本格的な xterm.js ターミナル |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 長時間実行中の Claude Code セッションのハングを監視し、プロセス操作を提供 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | ワークスペース単位のスケジュール済みプロンプトを作成し、Codex、Claude Code、Gemini CLI などのローカル CLI で実行 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 内で Claude Code のセッション分析を行い、トークン消費の可視化も提供 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | アクティブな Claude Code セッションを表示、管理、終了 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | モデル価格とトークン使用量から API コストを計算し、モデル価格プリセットにも対応 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | エージェントタスクを表示、フィルタリング、起動するためのタスクキューダッシュボード |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues 用の Kanban ボード。TaskMaster との双方向同期と /github-task CLI スキルの自動インストールに対応 |
### 自作する ### 自作する

View File

@@ -158,6 +158,14 @@ CloudCLI는 커스텀 탭과 선택적 Node.js 백엔드가 포함된 플러그
| 플러그인 | 설명 | | 플러그인 | 설명 |
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 다중 탭을 지원하는 전체 xterm.js 터미널 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 장시간 실행 중인 Claude Code 세션의 중단 상태를 감시하고 프로세스 제어를 제공 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 워크스페이스 범위 예약 프롬프트를 만들고 Codex, Claude Code, Gemini CLI 같은 로컬 CLI로 실행 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 안에서 Claude Code 세션 인텔리전스와 토큰 소모 가시성을 제공 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 활성 Claude Code 세션을 보고, 관리하고, 종료 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 모델 가격과 토큰 사용량으로 API 비용을 계산하고 모델 가격 프리셋을 지원 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 에이전트 작업을 보고, 필터링하고, 실행하는 작업 큐 대시보드 |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues용 Kanban 보드. TaskMaster 양방향 동기화와 /github-task CLI 스킬 자동 설치 지원 |
### 직접 만들기 ### 직접 만들기

View File

@@ -163,8 +163,15 @@ CloudCLI has a plugin system that lets you add custom tabs with their own fronte
| Plugin | Description | | Plugin | Description |
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support| | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI| | **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Watches long-running Claude Code sessions for hangs and exposes process controls |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Session intelligence for Claude Code inside CloudCLI, including token burn visibility |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | View, manage, and kill active Claude Code sessions |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Calculate API costs from model prices and token usage, with preset model pricing support |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task queue dashboard to view, filter, and launch agent tasks |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban board for GitHub Issues with bidirectional TaskMaster sync and /github-task CLI skill auto-install |
### Build Your Own ### Build Your Own
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server. **[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server.

View File

@@ -164,6 +164,14 @@ CloudCLI UI — это open source UI-слой, на котором постро
| Плагин | Описание | | Плагин | Описание |
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Полноценный терминал xterm.js с поддержкой нескольких вкладок |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Отслеживает зависания долгих сессий Claude Code и предоставляет управление процессами |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Создаёт запланированные промпты для рабочей области и запускает их через локальную CLI, например Codex, Claude Code или Gemini CLI |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Аналитика сессий Claude Code внутри CloudCLI, включая видимость расхода токенов |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Просмотр, управление и завершение активных сессий Claude Code |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Расчёт стоимости API по ценам моделей и использованию токенов, с поддержкой пресетов цен |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Дашборд очереди задач для просмотра, фильтрации и запуска агентских задач |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-доска для GitHub Issues с двусторонней синхронизацией TaskMaster и автоустановкой CLI-навыка /github-task |
### Создать свой ### Создать свой

View File

@@ -164,6 +164,13 @@ CloudCLI, kendi frontend UI'sı ve isteğe bağlı Node.js arka ucu olan özel s
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Mevcut projen için dosya sayıları, kod satırları, dosya türü dağılımı, en büyük dosyalar ve son değiştirilen dosyaları gösterir | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Mevcut projen için dosya sayıları, kod satırları, dosya türü dağılımı, en büyük dosyalar ve son değiştirilen dosyaları gösterir |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Çoklu sekme destekli tam xterm.js terminali | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Çoklu sekme destekli tam xterm.js terminali |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Uzun süren Claude Code oturumlarını takılmalara karşı izler ve süreç kontrolleri sunar |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Çalışma alanı kapsamlı zamanlanmış prompt'lar oluşturur ve bunları Codex, Claude Code veya Gemini CLI gibi yerel CLI'larla çalıştırır |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI içinde Claude Code oturum zekası ve token tüketimi görünürlüğü sağlar |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktif Claude Code oturumlarını görüntülemeni, yönetmeni ve sonlandırmanı sağlar |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Model fiyatları ve token kullanımından API maliyetlerini hesaplar; model fiyatı hazır ayarlarını destekler |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Ajan görevlerini görüntülemek, filtrelemek ve başlatmak için görev kuyruğu paneli |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues için Kanban panosu; çift yönlü TaskMaster senkronizasyonu ve /github-task CLI becerisi otomatik kurulumu içerir |
### Kendi Eklentini Yaz ### Kendi Eklentini Yaz

View File

@@ -158,6 +158,14 @@ CloudCLI 配备插件系统,允许你添加带自定义前端 UI 和可选 Nod
| 插件 | 描述 | | 插件 | 描述 |
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支持多标签页的完整 xterm.js 终端 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 监控长时间运行的 Claude Code 会话是否卡住,并提供进程控制 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 创建工作区范围的定时提示词,并通过 Codex、Claude Code 或 Gemini CLI 等本地 CLI 执行 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 会话智能分析,包括 token 消耗可视化 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 查看、管理并终止活动的 Claude Code 会话 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根据模型价格和 token 用量计算 API 成本,并支持模型价格预设 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用于查看、筛选和启动代理任务的任务队列仪表板 |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用于 GitHub Issues 的看板,支持 TaskMaster 双向同步和 /github-task CLI 技能自动安装 |
### 自行构建 ### 自行构建

View File

@@ -158,6 +158,14 @@ CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Nod
| 外掛 | 描述 | | 外掛 | 描述 |
|---|---| |---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 | | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支援多分頁的完整 xterm.js 終端機 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 監控長時間執行的 Claude Code 工作階段是否卡住,並提供程序控制 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 建立工作區範圍的排程提示詞,並透過 Codex、Claude Code 或 Gemini CLI 等本機 CLI 執行 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 工作階段智慧分析,包括 token 消耗可視化 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 檢視、管理並終止作用中的 Claude Code 工作階段 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根據模型價格與 token 用量計算 API 成本,並支援模型價格預設 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用於檢視、篩選和啟動代理任務的任務佇列儀表板 |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用於 GitHub Issues 的看板,支援 TaskMaster 雙向同步和 /github-task CLI 技能自動安裝 |
### 自行建構 ### 自行建構

View File

@@ -28,14 +28,10 @@ import {
} from './services/notification-orchestrator.js'; } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js'; import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; import { createNormalizedMessage } from './shared/utils.js';
const activeSessions = new Map(); const activeSessions = new Map();
const pendingToolApprovals = new Map(); const pendingToolApprovals = new Map();
// Sessions cancelled via abort-session. The abort handler already sent the
// terminal `complete` (aborted: true) to the client, so the run loop must not
// emit a second one when its generator winds down.
const abortedSessionIds = new Set();
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000; const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
@@ -735,18 +731,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files // Clean up temporary image files
await cleanupTempFiles(tempImagePaths, tempDir); await cleanupTempFiles(tempImagePaths, tempDir);
// Send the terminal completion event — skipped for aborted runs, whose // Send completion event
// terminal `complete` (aborted: true) was already sent by abort-session. ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
if (!wasAborted) {
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
}
notifyRunStopped({ notifyRunStopped({
userId: ws?.userId || null, userId: ws?.userId || null,
provider: 'claude', provider: 'claude',
sessionId: capturedSessionId || sessionId || null, sessionId: capturedSessionId || sessionId || null,
sessionName: sessionSummary, sessionName: sessionSummary,
stopReason: wasAborted ? 'aborted' : 'completed' stopReason: 'completed'
}); });
// Complete // Complete
@@ -761,22 +753,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files on error // Clean up temporary image files on error
await cleanupTempFiles(tempImagePaths, tempDir); await cleanupTempFiles(tempImagePaths, tempDir);
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
if (wasAborted) {
// The abort already produced the terminal complete; a generator throw
// caused by interrupt() is expected noise, not a user-facing error.
return;
}
// Check if Claude CLI is installed for a clearer error message // Check if Claude CLI is installed for a clearer error message
const installed = await providerAuthService.isProviderInstalled('claude'); const installed = await providerAuthService.isProviderInstalled('claude');
const errorContent = !installed const errorContent = !installed
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code' ? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
: error.message; : error.message;
// Send error to WebSocket, then the terminal complete // Send error to WebSocket
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
notifyRunFailed({ notifyRunFailed({
userId: ws?.userId || null, userId: ws?.userId || null,
provider: 'claude', provider: 'claude',
@@ -803,10 +787,6 @@ async function abortClaudeSDKSession(sessionId) {
try { try {
console.log(`Aborting SDK session: ${sessionId}`); console.log(`Aborting SDK session: ${sessionId}`);
// Mark before interrupting so the run loop knows not to emit its own
// terminal complete (the abort handler sends the aborted one).
abortedSessionIds.add(sessionId);
// Call interrupt() on the query instance // Call interrupt() on the query instance
await session.instance.interrupt(); await session.instance.interrupt();
@@ -822,8 +802,6 @@ async function abortClaudeSDKSession(sessionId) {
return true; return true;
} catch (error) { } catch (error) {
console.error(`Error aborting session ${sessionId}:`, error); console.error(`Error aborting session ${sessionId}:`, error);
// The run keeps going; let it emit its own terminal complete.
abortedSessionIds.delete(sessionId);
return false; return false;
} }
} }

View File

@@ -4,7 +4,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
import { sessionsService } from './modules/providers/services/sessions.service.js'; import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; import { createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for better command execution // Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -34,10 +34,6 @@ async function spawnCursor(command, options = {}, ws) {
let sessionCreatedSent = false; // Track if we've already sent session-created event let sessionCreatedSent = false; // Track if we've already sent session-created event
let hasRetriedWithTrust = false; let hasRetriedWithTrust = false;
let settled = false; let settled = false;
// The unified lifecycle contract requires exactly one terminal `complete`
// per run. Cursor surfaces completion twice (the `result` JSON line and
// the process close), so the first emission wins.
let completeSent = false;
// Use tools settings passed from frontend, or defaults // Use tools settings passed from frontend, or defaults
const settings = toolsSettings || { const settings = toolsSettings || {
@@ -201,15 +197,15 @@ async function spawnCursor(command, options = {}, ws) {
break; break;
case 'result': { case 'result': {
// Session complete — terminal lifecycle event for this run // Session complete — send stream end + lifecycle complete with result payload
if (!completeSent) { const resultText = typeof response.result === 'string' ? response.result : '';
completeSent = true; ws.send(createNormalizedMessage({
ws.send(createCompleteMessage({ kind: 'complete',
provider: 'cursor', exitCode: response.subtype === 'success' ? 0 : 1,
sessionId: capturedSessionId || sessionId || null, resultText,
exitCode: response.subtype === 'success' ? 0 : 1, isError: response.subtype !== 'success',
})); sessionId: capturedSessionId || sessionId, provider: 'cursor',
} }));
break; break;
} }
@@ -275,12 +271,7 @@ async function spawnCursor(command, options = {}, ws) {
return; return;
} }
// Terminal complete — unless the `result` line already sent it, or the ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' }));
// run was aborted (abort-session sent the aborted complete).
if (!completeSent && !cursorProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: finalSessionId, exitCode: code }));
}
if (code === 0) { if (code === 0) {
notifyTerminalState({ code }); notifyTerminalState({ code });
@@ -306,10 +297,6 @@ async function spawnCursor(command, options = {}, ws) {
: error.message; : error.message;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' })); ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
if (!completeSent && !cursorProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
}
notifyTerminalState({ error }); notifyTerminalState({ error });
settleOnce(() => reject(error)); settleOnce(() => reject(error));
@@ -327,9 +314,6 @@ function abortCursorSession(sessionId) {
const process = activeCursorProcesses.get(sessionId); const process = activeCursorProcesses.get(sessionId);
if (process) { if (process) {
console.log(`Aborting Cursor session: ${sessionId}`); console.log(`Aborting Cursor session: ${sessionId}`);
// The abort handler sends the terminal complete (aborted: true); flag the
// process so its close handler does not emit a second one.
process.aborted = true;
process.kill('SIGTERM'); process.kill('SIGTERM');
activeCursorProcesses.delete(sessionId); activeCursorProcesses.delete(sessionId);
return true; return true;

View File

@@ -10,7 +10,7 @@ import GeminiResponseHandler from './gemini-response-handler.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; import { createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js) // Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -129,9 +129,6 @@ async function spawnGemini(command, options = {}, ws) {
let capturedSessionId = sessionId; // Track session ID throughout the process let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event let sessionCreatedSent = false; // Track if we've already sent session-created event
let assistantBlocks = []; // Accumulate the full response blocks including tools let assistantBlocks = []; // Accumulate the full response blocks including tools
// Unified lifecycle contract: exactly one terminal `complete` per run
// (close and error handlers can both fire for spawn failures).
let completeSent = false;
// Use tools settings passed from frontend, or defaults // Use tools settings passed from frontend, or defaults
const settings = toolsSettings || { const settings = toolsSettings || {
@@ -489,12 +486,7 @@ async function spawnGemini(command, options = {}, ws) {
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks); sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
} }
// Terminal complete — skipped for aborted runs (abort-session ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' }));
// already sent the aborted complete on this run's behalf).
if (!completeSent && !geminiProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: finalSessionId, exitCode: code }));
}
// Clean up temporary image files if any // Clean up temporary image files if any
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) { if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
@@ -574,10 +566,6 @@ async function spawnGemini(command, options = {}, ws) {
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' })); ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
if (!completeSent && !geminiProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: errorSessionId, exitCode: 1 }));
}
notifyTerminalState({ error }); notifyTerminalState({ error });
reject(error); reject(error);
@@ -602,9 +590,6 @@ function abortGeminiSession(sessionId) {
if (geminiProc) { if (geminiProc) {
try { try {
// The abort handler sends the terminal complete (aborted: true);
// flag the process so its close handler does not emit a second one.
geminiProc.aborted = true;
geminiProc.kill('SIGTERM'); geminiProc.kill('SIGTERM');
setTimeout(() => { setTimeout(() => {
if (activeGeminiProcesses.has(processKey)) { if (activeGeminiProcesses.has(processKey)) {

View File

@@ -133,10 +133,9 @@ flowchart TD
### Chat Notes ### Chat Notes
1. **Unified terminal lifecycle**: every provider run ends with exactly one `complete` message built by `createCompleteMessage()` (`server/shared/utils.ts`), regardless of provider: `{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }`. Failed runs emit an informational `error` message first, then the terminal `complete` with `success: false`. Mid-run `error` messages (e.g. stderr output) are non-terminal; the frontend only treats `complete` as end-of-run. 1. `abort-session` returns a normalized `complete` message with `aborted: true`.
2. `abort-session` sends the terminal `complete` (`aborted: true`) on behalf of the cancelled run; providers detect the abort and skip their own `complete` so the client sees exactly one. 2. `check-session-status` returns `{ type: "session-status", isProcessing }`.
3. `check-session-status` returns `{ type: "session-status", isProcessing }`. 3. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
4. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
## `/shell` Terminal Flow ## `/shell` Terminal Flow

View File

@@ -7,7 +7,7 @@ import type {
AuthenticatedWebSocketRequest, AuthenticatedWebSocketRequest,
LLMProvider, LLMProvider,
} from '@/shared/types.js'; } from '@/shared/types.js';
import { createCompleteMessage, parseIncomingJsonObject } from '@/shared/utils.js'; import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js';
type ChatIncomingMessage = AnyRecord & { type ChatIncomingMessage = AnyRecord & {
type?: string; type?: string;
@@ -173,14 +173,14 @@ export function handleChatConnection(
success = await dependencies.abortClaudeSDKSession(sessionId); success = await dependencies.abortClaudeSDKSession(sessionId);
} }
// Terminal complete on behalf of the cancelled run — providers skip
// their own complete for aborted runs so the client sees exactly one.
writer.send( writer.send(
createCompleteMessage({ createNormalizedMessage({
provider, kind: 'complete',
sessionId,
exitCode: success ? 0 : 1, exitCode: success ? 0 : 1,
aborted: true, aborted: true,
success,
sessionId,
provider,
}) })
); );
return; return;
@@ -202,11 +202,13 @@ export function handleChatConnection(
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : ''; const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
const success = dependencies.abortCursorSession(sessionId); const success = dependencies.abortCursorSession(sessionId);
writer.send( writer.send(
createCompleteMessage({ createNormalizedMessage({
provider: 'cursor', kind: 'complete',
sessionId,
exitCode: success ? 0 : 1, exitCode: success ? 0 : 1,
aborted: true, aborted: true,
success,
sessionId,
provider: 'cursor',
}) })
); );
return; return;

View File

@@ -18,7 +18,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
import { sessionsService } from './modules/providers/services/sessions.service.js'; import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; import { createNormalizedMessage } from './shared/utils.js';
// Track active sessions // Track active sessions
const activeCodexSessions = new Map(); const activeCodexSessions = new Map();
@@ -352,26 +352,21 @@ export async function queryCodex(command, options = {}, ws) {
} }
} }
// Send the terminal completion event — skipped for aborted runs, whose // Send completion event
// terminal `complete` (aborted: true) was already sent by abort-session. if (!terminalFailure) {
const runSession = capturedSessionId ? activeCodexSessions.get(capturedSessionId) : null; sendMessage(ws, createNormalizedMessage({
const runAborted = runSession?.status === 'aborted' || abortController.signal.aborted; kind: 'complete',
if (!runAborted) { actualSessionId: capturedSessionId || thread.id || sessionId || null,
sendMessage(ws, createCompleteMessage({ sessionId: capturedSessionId || sessionId || null,
provider: 'codex'
}));
notifyRunStopped({
userId: ws?.userId || null,
provider: 'codex', provider: 'codex',
sessionId: capturedSessionId || sessionId || null, sessionId: capturedSessionId || sessionId || null,
actualSessionId: capturedSessionId || thread.id || sessionId || null, sessionName: sessionSummary,
exitCode: terminalFailure ? 1 : 0, stopReason: 'completed'
})); });
if (!terminalFailure) {
notifyRunStopped({
userId: ws?.userId || null,
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
sessionName: sessionSummary,
stopReason: 'completed'
});
}
} }
} catch (error) { } catch (error) {
@@ -391,11 +386,6 @@ export async function queryCodex(command, options = {}, ws) {
: error.message; : error.message;
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'codex' })); sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
sendMessage(ws, createCompleteMessage({
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
exitCode: 1,
}));
if (!terminalFailure) { if (!terminalFailure) {
notifyRunFailed({ notifyRunFailed({
userId: ws?.userId || null, userId: ws?.userId || null,

View File

@@ -8,7 +8,7 @@ import { sessionsService } from './modules/providers/services/sessions.service.j
import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js'; import { createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -92,9 +92,6 @@ async function spawnOpenCode(command, options = {}, ws) {
let stdoutLineBuffer = ''; let stdoutLineBuffer = '';
let terminalNotificationSent = false; let terminalNotificationSent = false;
let opencodeProcess = null; let opencodeProcess = null;
// Unified lifecycle contract: exactly one terminal `complete` per run
// (close and error handlers can both fire for spawn failures).
let completeSent = false;
const notifyTerminalState = ({ code = null, error = null } = {}) => { const notifyTerminalState = ({ code = null, error = null } = {}) => {
if (terminalNotificationSent) { if (terminalNotificationSent) {
@@ -259,12 +256,13 @@ async function spawnOpenCode(command, options = {}, ws) {
})); }));
} }
// Terminal complete — skipped for aborted runs (abort-session ws.send(createNormalizedMessage({
// already sent the aborted complete on this run's behalf). kind: 'complete',
if (!completeSent && !opencodeProcess.aborted) { exitCode: code,
completeSent = true; isNewSession: !sessionId && !!command,
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: code })); sessionId: finalSessionId,
} provider: 'opencode',
}));
if (code === 0) { if (code === 0) {
notifyTerminalState({ code }); notifyTerminalState({ code });
@@ -304,10 +302,6 @@ async function spawnOpenCode(command, options = {}, ws) {
sessionId: finalSessionId, sessionId: finalSessionId,
provider: 'opencode', provider: 'opencode',
})); }));
if (!completeSent && !opencodeProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: 1 }));
}
notifyTerminalState({ error }); notifyTerminalState({ error });
reject(error); reject(error);
}); });
@@ -321,9 +315,6 @@ function abortOpenCodeSession(sessionId) {
return false; return false;
} }
// The abort handler sends the terminal complete (aborted: true); flag the
// process so its close handler does not emit a second one.
process.aborted = true;
process.kill('SIGTERM'); process.kill('SIGTERM');
activeOpenCodeProcesses.delete(sessionId); activeOpenCodeProcesses.delete(sessionId);
return true; return true;

View File

@@ -346,43 +346,6 @@ export function createNormalizedMessage(fields: NormalizedMessageInput): Normali
}; };
} }
/**
* Build the unified terminal `complete` lifecycle message.
*
* Contract: every provider run ends with exactly one `complete` (the
* abort-session handler emits it on behalf of cancelled runs, so aborted runs
* must NOT emit their own). The frontend treats `complete` as the only
* terminal signal and never needs provider-specific handling:
*
* - `sessionId` — the id the client knows this run by ('' if never discovered)
* - `actualSessionId` — canonical id after the run; equals `sessionId` unless
* the provider rewrote it mid-run
* - `exitCode` — 0 on success; a missing/null code (e.g. killed process)
* is reported as failure
* - `success` — exitCode === 0 and not aborted
* - `aborted` — run was cancelled by the user
*/
export function createCompleteMessage(opts: {
provider: NormalizedMessage['provider'];
sessionId?: string | null;
actualSessionId?: string | null;
exitCode?: number | null;
aborted?: boolean;
}): NormalizedMessage {
const exitCode = typeof opts.exitCode === 'number' ? opts.exitCode : 1;
const aborted = Boolean(opts.aborted);
return createNormalizedMessage({
kind: 'complete',
provider: opts.provider,
sessionId: opts.sessionId || null,
actualSessionId: opts.actualSessionId || opts.sessionId || null,
exitCode,
success: exitCode === 0 && !aborted,
aborted,
});
}
// --------------------------- // ---------------------------
//----------------- MCP CONFIG PARSING UTILITIES ------------ //----------------- MCP CONFIG PARSING UTILITIES ------------
/** /**

View File

@@ -28,9 +28,12 @@ function AppContentInner() {
const wasConnectedRef = useRef(false); const wasConnectedRef = useRef(false);
const { const {
activeSessions,
processingSessions, processingSessions,
markSessionProcessing, markSessionAsActive,
markSessionIdle, markSessionAsInactive,
markSessionAsProcessing,
markSessionAsNotProcessing,
} = useSessionProtection(); } = useSessionProtection();
const { const {
@@ -54,7 +57,7 @@ function AppContentInner() {
navigate, navigate,
latestMessage, latestMessage,
isMobile, isMobile,
activeSessions: processingSessions, activeSessions,
}); });
usePaletteOpsRegister({ usePaletteOpsRegister({
@@ -182,8 +185,10 @@ function AppContentInner() {
onMenuClick={() => setSidebarOpen(true)} onMenuClick={() => setSidebarOpen(true)}
isLoading={isLoadingProjects} isLoading={isLoadingProjects}
onInputFocusChange={setIsInputFocused} onInputFocusChange={setIsInputFocused}
onSessionProcessing={markSessionProcessing} onSessionActive={markSessionAsActive}
onSessionIdle={markSessionIdle} onSessionInactive={markSessionAsInactive}
onSessionProcessing={markSessionAsProcessing}
onSessionNotProcessing={markSessionAsNotProcessing}
processingSessions={processingSessions} processingSessions={processingSessions}
onNavigateToSession={(targetSessionId: string, options) => onNavigateToSession={(targetSessionId: string, options) =>
navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) }) navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) })

View File

@@ -12,8 +12,6 @@ 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 {
@@ -27,6 +25,10 @@ import { escapeRegExp } from '../utils/chatFormatting';
import { useFileMentions } from './useFileMentions'; import { useFileMentions } from './useFileMentions';
import { type SlashCommand, useSlashCommands } from './useSlashCommands'; import { type SlashCommand, useSlashCommands } from './useSlashCommands';
type PendingViewSession = {
startedAt: number;
};
interface UseChatComposerStateArgs { interface UseChatComposerStateArgs {
selectedProject: Project | null; selectedProject: Project | null;
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
@@ -44,12 +46,17 @@ interface UseChatComposerStateArgs {
tokenBudget: Record<string, unknown> | null; tokenBudget: Record<string, unknown> | null;
sendMessage: (message: unknown) => void; sendMessage: (message: unknown) => void;
sendByCtrlEnter?: boolean; sendByCtrlEnter?: boolean;
onSessionProcessing?: MarkSessionProcessing; onSessionActive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
onInputFocusChange?: (focused: boolean) => void; onInputFocusChange?: (focused: boolean) => void;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void; onShowSettings?: () => void;
pendingViewSessionRef: { current: PendingViewSession | null };
scrollToBottom: () => void; scrollToBottom: () => void;
addMessage: (msg: ChatMessage) => void; addMessage: (msg: ChatMessage) => void;
setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setIsUserScrolledUp: (isScrolledUp: boolean) => void; setIsUserScrolledUp: (isScrolledUp: boolean) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>; setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
} }
@@ -170,12 +177,17 @@ export function useChatComposerState({
tokenBudget, tokenBudget,
sendMessage, sendMessage,
sendByCtrlEnter, sendByCtrlEnter,
onSessionActive,
onSessionProcessing, onSessionProcessing,
onInputFocusChange, onInputFocusChange,
onFileOpen, onFileOpen,
onShowSettings, onShowSettings,
pendingViewSessionRef,
scrollToBottom, scrollToBottom,
addMessage, addMessage,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setIsUserScrolledUp, setIsUserScrolledUp,
setPendingPermissionRequests, setPendingPermissionRequests,
}: UseChatComposerStateArgs) { }: UseChatComposerStateArgs) {
@@ -608,18 +620,27 @@ export function useChatComposerState({
}; };
addMessage(userMessage); addMessage(userMessage);
// Mark this request as processing in the per-session activity map (the setIsLoading(true); // Processing banner starts
// single source of truth the indicator derives from). A brand-new setCanAbortSession(true);
// conversation has no session id yet, so it is tracked under the setClaudeStatus({
// pending placeholder until `session_created` announces the real id. text: 'Processing',
onSessionProcessing?.(effectiveSessionId || PENDING_SESSION_ID, { tokens: 0,
statusText: null, can_interrupt: true,
canInterrupt: true,
}); });
setIsUserScrolledUp(false); setIsUserScrolledUp(false);
setTimeout(() => scrollToBottom(), 100); setTimeout(() => scrollToBottom(), 100);
if (!effectiveSessionId && !selectedSession?.id) {
// This tracks only that a request is in flight before the provider has
// emitted its real session id; routing still waits for session_created.
pendingViewSessionRef.current = { startedAt: Date.now() };
}
if (effectiveSessionId) {
onSessionActive?.(effectiveSessionId);
onSessionProcessing?.(effectiveSessionId);
}
const getToolsSettings = () => { const getToolsSettings = () => {
try { try {
const settingsKey = const settingsKey =
@@ -755,14 +776,19 @@ 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,
], ],
@@ -974,11 +1000,15 @@ export function useChatComposerState({
}); });
}); });
setPendingPermissionRequests((previous) => setPendingPermissionRequests((previous) => {
previous.filter((request) => !validIds.includes(request.requestId)), const next = previous.filter((request) => !validIds.includes(request.requestId));
); if (next.length === 0) {
setClaudeStatus(null);
}
return next;
});
}, },
[sendMessage, setPendingPermissionRequests], [sendMessage, setClaudeStatus, setPendingPermissionRequests],
); );
const [isInputFocused, setIsInputFocused] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false);

View File

@@ -4,12 +4,14 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification'; import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
import { playChatCompletionSound } from '../../../utils/notificationSound'; import { playChatCompletionSound } from '../../../utils/notificationSound';
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types'; import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app'; import type { ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
type PendingViewSession = {
startedAt: number;
};
type LatestChatMessage = { type LatestChatMessage = {
type?: string; type?: string;
kind?: string; kind?: string;
@@ -53,14 +55,18 @@ interface UseChatRealtimeHandlersArgs {
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
currentSessionId: string | null; currentSessionId: string | null;
setCurrentSessionId: (sessionId: string | null) => void; setCurrentSessionId: (sessionId: string | null) => void;
setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setTokenBudget: (budget: Record<string, unknown> | null) => void; setTokenBudget: (budget: Record<string, unknown> | null) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>; setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
streamTimerRef: MutableRefObject<number | null>; streamTimerRef: MutableRefObject<number | null>;
accumulatedStreamRef: MutableRefObject<string>; accumulatedStreamRef: MutableRefObject<string>;
/** When each session's `check-session-status` was last sent; guards stale idle replies. */ onSessionInactive?: (sessionId?: string | null) => void;
statusCheckSentAtRef: MutableRefObject<Map<string, number>>; onSessionActive?: (sessionId?: string | null) => void;
onSessionProcessing?: MarkSessionProcessing; onSessionProcessing?: (sessionId?: string | null) => void;
onSessionIdle?: MarkSessionIdle; onSessionNotProcessing?: (sessionId?: string | null) => void;
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void; onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
onWebSocketReconnect?: () => void; onWebSocketReconnect?: () => void;
sessionStore: SessionStore; sessionStore: SessionStore;
@@ -76,13 +82,18 @@ export function useChatRealtimeHandlers({
selectedSession, selectedSession,
currentSessionId, currentSessionId,
setCurrentSessionId, setCurrentSessionId,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget, setTokenBudget,
setPendingPermissionRequests, setPendingPermissionRequests,
pendingViewSessionRef,
streamTimerRef, streamTimerRef,
accumulatedStreamRef, accumulatedStreamRef,
statusCheckSentAtRef, onSessionInactive,
onSessionActive,
onSessionProcessing, onSessionProcessing,
onSessionIdle, onSessionNotProcessing,
onNavigateToSession, onNavigateToSession,
onWebSocketReconnect, onWebSocketReconnect,
sessionStore, sessionStore,
@@ -127,24 +138,35 @@ export function useChatRealtimeHandlers({
const status = msg.status; const status = msg.status;
if (status) { if (status) {
onSessionProcessing?.(statusSessionId, { const statusInfo = {
statusText: status.text || null, text: status.text || 'Working...',
canInterrupt: status.can_interrupt !== false, tokens: status.tokens || 0,
}); can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
};
setClaudeStatus(statusInfo);
setIsLoading(true);
setCanAbortSession(statusInfo.can_interrupt);
return; return;
} }
// Reply to check-session-status (or unsolicited processing update) // Legacy isProcessing format from check-session-status
const isCurrentSession =
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
if (msg.isProcessing) { if (msg.isProcessing) {
onSessionActive?.(statusSessionId);
onSessionProcessing?.(statusSessionId); onSessionProcessing?.(statusSessionId);
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
return; return;
} }
// Idle reply: ignore it if a newer request started after the check onSessionInactive?.(statusSessionId);
// was sent — the reply describes the older request. onSessionNotProcessing?.(statusSessionId);
onSessionIdle?.(statusSessionId, { if (isCurrentSession) {
ifStartedBefore: statusCheckSentAtRef.current.get(statusSessionId), setIsLoading(false);
}); setCanAbortSession(false);
setClaudeStatus(null);
}
return; return;
} }
@@ -216,15 +238,23 @@ export function useChatRealtimeHandlers({
// We no longer synthesize client-side placeholder IDs. Until the provider // We no longer synthesize client-side placeholder IDs. Until the provider
// announces `session_created`, the active id is expected to be null. // announces `session_created`, the active id is expected to be null.
if (!currentSessionId) { if (!currentSessionId) {
console.log('Session created with ID:', newSessionId);
console.log('Existing session ID:', currentSessionId);
setCurrentSessionId(newSessionId); setCurrentSessionId(newSessionId);
setPendingPermissionRequests((prev) => setPendingPermissionRequests((prev) =>
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })), prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
); );
} }
// The in-flight request now has a concrete session id: migrate the pendingViewSessionRef.current = null;
// processing entry from the pending placeholder. onSessionActive?.(newSessionId);
onSessionIdle?.(PENDING_SESSION_ID);
onSessionProcessing?.(newSessionId); onSessionProcessing?.(newSessionId);
setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({
text: 'Processing',
tokens: 0,
can_interrupt: true,
});
onNavigateToSession?.(newSessionId); onNavigateToSession?.(newSessionId);
break; break;
} }
@@ -241,27 +271,24 @@ export function useChatRealtimeHandlers({
} }
accumulatedStreamRef.current = ''; accumulatedStreamRef.current = '';
// `complete` is the unified terminal event — every provider run ends setIsLoading(false);
// with exactly one, regardless of success, failure, or abort. The setCanAbortSession(false);
// indicator derives from the processing map, so deleting the entry setClaudeStatus(null);
// hides it immediately and atomically.
onSessionIdle?.(sid);
onSessionIdle?.(PENDING_SESSION_ID);
setPendingPermissionRequests([]); setPendingPermissionRequests([]);
onSessionInactive?.(sid);
onSessionNotProcessing?.(sid);
pendingViewSessionRef.current = null;
// Handle aborted case // Handle aborted case
if (msg.aborted) { if (msg.aborted) {
// Abort was requested — the complete event confirms it // Abort was requested — the complete event confirms it
// No special UI action needed beyond clearing the processing entry above // No special UI action needed beyond clearing loading state above
// The backend already sent any abort-related messages // The backend already sent any abort-related messages
break; break;
} }
// Celebrate only successful runs (failed runs end with success: false). showCompletionTitleIndicator();
if (msg.success !== false) { void playChatCompletionSound();
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
@@ -275,7 +302,6 @@ export function useChatRealtimeHandlers({
if (actualSessionId && sid && actualSessionId !== sid) { if (actualSessionId && sid && actualSessionId !== sid) {
sessionStore.replaceSessionId(sid, actualSessionId); sessionStore.replaceSessionId(sid, actualSessionId);
onSessionIdle?.(actualSessionId);
if (isVisibleSession) { if (isVisibleSession) {
setCurrentSessionId(actualSessionId); setCurrentSessionId(actualSessionId);
@@ -291,9 +317,15 @@ export function useChatRealtimeHandlers({
break; break;
} }
// 'error' is an informational message row, not a terminal event — case 'error': {
// providers emit it for mid-run stderr output too. Run teardown is setIsLoading(false);
// always signalled by the unified 'complete' that follows. setCanAbortSession(false);
setClaudeStatus(null);
onSessionInactive?.(sid);
onSessionNotProcessing?.(sid);
pendingViewSessionRef.current = null;
break;
}
case 'permission_request': { case 'permission_request': {
if (!msg.requestId) break; if (!msg.requestId) break;
@@ -308,7 +340,9 @@ export function useChatRealtimeHandlers({
receivedAt: new Date(), receivedAt: new Date(),
}]; }];
}); });
onSessionProcessing?.(sid || PENDING_SESSION_ID); setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
break; break;
} }
@@ -323,10 +357,13 @@ export function useChatRealtimeHandlers({
if (msg.text === 'token_budget' && msg.tokenBudget) { if (msg.text === 'token_budget' && msg.tokenBudget) {
setTokenBudget(msg.tokenBudget as Record<string, unknown>); setTokenBudget(msg.tokenBudget as Record<string, unknown>);
} else if (msg.text) { } else if (msg.text) {
onSessionProcessing?.(sid || PENDING_SESSION_ID, { setClaudeStatus({
statusText: msg.text, text: msg.text,
canInterrupt: msg.canInterrupt !== false, tokens: msg.tokens || 0,
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
}); });
setIsLoading(true);
setCanAbortSession(msg.canInterrupt !== false);
} }
break; break;
} }
@@ -342,13 +379,18 @@ export function useChatRealtimeHandlers({
selectedSession, selectedSession,
currentSessionId, currentSessionId,
setCurrentSessionId, setCurrentSessionId,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget, setTokenBudget,
setPendingPermissionRequests, setPendingPermissionRequests,
pendingViewSessionRef,
streamTimerRef, streamTimerRef,
accumulatedStreamRef, accumulatedStreamRef,
statusCheckSentAtRef, onSessionInactive,
onSessionActive,
onSessionProcessing, onSessionProcessing,
onSessionIdle, onSessionNotProcessing,
onNavigateToSession, onNavigateToSession,
onWebSocketReconnect, onWebSocketReconnect,
sessionStore, sessionStore,

View File

@@ -2,8 +2,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
import type { ChatMessage, Provider } from '../types/types'; import type { ChatMessage, Provider } from '../types/types';
@@ -14,6 +12,10 @@ import { normalizedToChatMessages } from './useChatMessages';
const MESSAGES_PER_PAGE = 20; const MESSAGES_PER_PAGE = 20;
const INITIAL_VISIBLE_MESSAGES = 100; const INITIAL_VISIBLE_MESSAGES = 100;
type PendingViewSession = {
startedAt: number;
};
interface UseChatSessionStateArgs { interface UseChatSessionStateArgs {
selectedProject: Project | null; selectedProject: Project | null;
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
@@ -22,11 +24,9 @@ interface UseChatSessionStateArgs {
autoScrollToBottom?: boolean; autoScrollToBottom?: boolean;
externalMessageUpdate?: number; externalMessageUpdate?: number;
newSessionTrigger?: number; newSessionTrigger?: number;
processingSessions?: SessionActivityMap; processingSessions?: Set<string>;
onSessionIdle?: MarkSessionIdle;
resetStreamingState: () => void; resetStreamingState: () => void;
/** When each session's `check-session-status` was last sent; guards stale idle replies. */ pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
sessionStore: SessionStore; sessionStore: SessionStore;
} }
@@ -99,19 +99,21 @@ export function useChatSessionState({
externalMessageUpdate, externalMessageUpdate,
newSessionTrigger, newSessionTrigger,
processingSessions, processingSessions,
onSessionIdle,
resetStreamingState, resetStreamingState,
statusCheckSentAtRef, pendingViewSessionRef,
sessionStore, sessionStore,
}: UseChatSessionStateArgs) { }: UseChatSessionStateArgs) {
const [isLoading, setIsLoading] = useState(false);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null); const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false); const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
const [hasMoreMessages, setHasMoreMessages] = useState(false); const [hasMoreMessages, setHasMoreMessages] = useState(false);
const [totalMessages, setTotalMessages] = useState(0); const [totalMessages, setTotalMessages] = useState(0);
const [canAbortSession, setCanAbortSession] = useState(false);
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null); const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES); const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
const [allMessagesLoaded, setAllMessagesLoaded] = useState(false); const [allMessagesLoaded, setAllMessagesLoaded] = useState(false);
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false); const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false); const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
@@ -168,7 +170,10 @@ export function useChatSessionState({
* - No coupling to unrelated external update signals. * - No coupling to unrelated external update signals.
*/ */
resetStreamingState(); resetStreamingState();
onSessionIdle?.(PENDING_SESSION_ID); pendingViewSessionRef.current = null;
setClaudeStatus(null);
setCanAbortSession(false);
setIsLoading(false);
setCurrentSessionId(null); setCurrentSessionId(null);
setPendingUserMessage(null); setPendingUserMessage(null);
sessionStorage.removeItem('cursorSessionId'); sessionStorage.removeItem('cursorSessionId');
@@ -199,29 +204,13 @@ export function useChatSessionState({
clearTimeout(loadAllFinishedTimerRef.current); clearTimeout(loadAllFinishedTimerRef.current);
loadAllFinishedTimerRef.current = null; loadAllFinishedTimerRef.current = null;
} }
}, [newSessionTrigger, onSessionIdle, resetStreamingState]); }, [newSessionTrigger, pendingViewSessionRef, resetStreamingState]);
/* ---------------------------------------------------------------- */
/* Derive processing state for the viewed session */
/* ---------------------------------------------------------------- */
const activeSessionId = selectedSession?.id || currentSessionId || null;
// The activity indicator always reflects the latest status of the session
// being viewed (or of the pending not-yet-created session on a fresh
// draft) — never stale local UI state from the last time it was open.
const sessionActivity = processingSessions?.get(activeSessionId ?? PENDING_SESSION_ID) ?? null;
const isProcessing = sessionActivity !== null;
const canAbortSession = isProcessing && sessionActivity.canInterrupt;
// Ref mirror so effects can read the latest map without re-running on
// every activity transition.
const processingSessionsRef = useRef(processingSessions);
processingSessionsRef.current = processingSessions;
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Derive chatMessages from the store */ /* Derive chatMessages from the store */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
const activeSessionId = selectedSession?.id || currentSessionId || null;
const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null); const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);
const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null); const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null);
@@ -441,12 +430,16 @@ export function useChatSessionState({
useEffect(() => { useEffect(() => {
if (!selectedSession || !selectedProject) { if (!selectedSession || !selectedProject) {
// A new provider run can be in flight before the router has a canonical // A new provider run can be in flight before the router has a canonical
// selectedSession. Keep the draft view intact until complete/error. // selectedSession. Keep the processing banner alive until complete/error.
if (processingSessionsRef.current?.has(PENDING_SESSION_ID)) { if (pendingViewSessionRef.current) {
return; return;
} }
resetStreamingState(); resetStreamingState();
pendingViewSessionRef.current = null;
setClaudeStatus(null);
setCanAbortSession(false);
setIsLoading(false);
setCurrentSessionId(null); setCurrentSessionId(null);
sessionStorage.removeItem('cursorSessionId'); sessionStorage.removeItem('cursorSessionId');
messagesOffsetRef.current = 0; messagesOffsetRef.current = 0;
@@ -468,6 +461,9 @@ export function useChatSessionState({
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
if (sessionChanged) { if (sessionChanged) {
resetStreamingState(); resetStreamingState();
pendingViewSessionRef.current = null;
setClaudeStatus(null);
setCanAbortSession(false);
} }
// Reset pagination/scroll state // Reset pagination/scroll state
@@ -486,6 +482,7 @@ export function useChatSessionState({
if (sessionChanged) { if (sessionChanged) {
setTokenBudget(null); setTokenBudget(null);
setIsLoading(false);
} }
setCurrentSessionId(selectedSession.id); setCurrentSessionId(selectedSession.id);
@@ -493,11 +490,8 @@ export function useChatSessionState({
sessionStorage.setItem('cursorSessionId', selectedSession.id); sessionStorage.setItem('cursorSessionId', selectedSession.id);
} }
// Reconcile processing state with the server. Recording the send time // Check session status
// lets the reply handler discard idle replies that a newer request has
// since outdated.
if (ws) { if (ws) {
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider }); sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });
} }
@@ -522,11 +516,11 @@ export function useChatSessionState({
setIsLoadingSessionMessages(false); setIsLoadingSessionMessages(false);
}); });
}, [ }, [
pendingViewSessionRef,
resetStreamingState, resetStreamingState,
selectedProject, selectedProject,
selectedSession?.id, selectedSession?.id,
sendMessage, sendMessage,
statusCheckSentAtRef,
ws, ws,
sessionStore, sessionStore,
]); ]);
@@ -540,7 +534,7 @@ export function useChatSessionState({
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude'; const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
// Skip store refresh during active streaming // Skip store refresh during active streaming
if (!isProcessing) { if (!isLoading) {
await sessionStore.refreshFromServer(selectedSession.id, { await sessionStore.refreshFromServer(selectedSession.id, {
provider: (selectedSession.__provider || provider) as LLMProvider, provider: (selectedSession.__provider || provider) as LLMProvider,
projectId: selectedProject.projectId, projectId: selectedProject.projectId,
@@ -565,7 +559,7 @@ export function useChatSessionState({
selectedProject, selectedProject,
selectedSession, selectedSession,
sessionStore, sessionStore,
isProcessing, isLoading,
]); ]);
// Search navigation target // Search navigation target
@@ -732,6 +726,16 @@ export function useChatSessionState({
return () => container.removeEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll);
}, [handleScroll]); }, [handleScroll]);
useEffect(() => {
const activeViewSessionId = selectedSession?.id || currentSessionId;
if (!activeViewSessionId || !processingSessions) return;
const shouldBeProcessing = processingSessions.has(activeViewSessionId);
if (shouldBeProcessing && !isLoading) {
setIsLoading(true);
setCanAbortSession(true);
}
}, [currentSessionId, isLoading, processingSessions, selectedSession?.id]);
// "Load all" overlay // "Load all" overlay
const prevLoadingRef = useRef(false); const prevLoadingRef = useRef(false);
useEffect(() => { useEffect(() => {
@@ -813,15 +817,16 @@ export function useChatSessionState({
addMessage, addMessage,
clearMessages, clearMessages,
rewindMessages, rewindMessages,
sessionActivity, isLoading,
isProcessing, setIsLoading,
canAbortSession,
currentSessionId, currentSessionId,
setCurrentSessionId, setCurrentSessionId,
isLoadingSessionMessages, isLoadingSessionMessages,
isLoadingMoreMessages, isLoadingMoreMessages,
hasMoreMessages, hasMoreMessages,
totalMessages, totalMessages,
canAbortSession,
setCanAbortSession,
isUserScrolledUp, isUserScrolledUp,
setIsUserScrolledUp, setIsUserScrolledUp,
tokenBudget, tokenBudget,
@@ -834,6 +839,8 @@ export function useChatSessionState({
isLoadingAllMessages, isLoadingAllMessages,
loadAllJustFinished, loadAllJustFinished,
showLoadAllOverlay, showLoadAllOverlay,
claudeStatus,
setClaudeStatus,
createDiff, createDiff,
scrollContainerRef, scrollContainerRef,
scrollToBottom, scrollToBottom,

View File

@@ -1,9 +1,4 @@
import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type {
MarkSessionIdle,
MarkSessionProcessing,
SessionActivityMap,
} from '../../../hooks/useSessionProtection';
export type Provider = LLMProvider; export type Provider = LLMProvider;
@@ -115,9 +110,11 @@ export interface ChatInterfaceProps {
latestMessage: any; latestMessage: any;
onFileOpen?: (filePath: string, diffInfo?: any) => void; onFileOpen?: (filePath: string, diffInfo?: any) => void;
onInputFocusChange?: (focused: boolean) => void; onInputFocusChange?: (focused: boolean) => void;
onSessionProcessing?: MarkSessionProcessing; onSessionActive?: (sessionId?: string | null) => void;
onSessionIdle?: MarkSessionIdle; onSessionInactive?: (sessionId?: string | null) => void;
processingSessions?: SessionActivityMap; onSessionProcessing?: (sessionId?: string | null) => void;
onSessionNotProcessing?: (sessionId?: string | null) => void;
processingSessions?: Set<string>;
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void; onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
onShowSettings?: () => void; onShowSettings?: () => void;
autoExpandTools?: boolean; autoExpandTools?: boolean;

View File

@@ -17,6 +17,10 @@ import ChatComposer from './subcomponents/ChatComposer';
import CommandResultModal from './subcomponents/CommandResultModal'; import CommandResultModal from './subcomponents/CommandResultModal';
type PendingViewSession = {
startedAt: number;
};
function ChatInterface({ function ChatInterface({
selectedProject, selectedProject,
selectedSession, selectedSession,
@@ -25,8 +29,10 @@ function ChatInterface({
latestMessage, latestMessage,
onFileOpen, onFileOpen,
onInputFocusChange, onInputFocusChange,
onSessionActive,
onSessionInactive,
onSessionProcessing, onSessionProcessing,
onSessionIdle, onSessionNotProcessing,
processingSessions, processingSessions,
onNavigateToSession, onNavigateToSession,
onShowSettings, onShowSettings,
@@ -45,9 +51,7 @@ function ChatInterface({
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const streamTimerRef = useRef<number | null>(null); const streamTimerRef = useRef<number | null>(null);
const accumulatedStreamRef = useRef(''); const accumulatedStreamRef = useRef('');
// When each session's `check-session-status` was last sent; idle replies const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
// older than a later local request are discarded as stale.
const statusCheckSentAtRef = useRef(new Map<string, number>());
const resetStreamingState = useCallback(() => { const resetStreamingState = useCallback(() => {
if (streamTimerRef.current) { if (streamTimerRef.current) {
@@ -88,15 +92,16 @@ function ChatInterface({
const { const {
chatMessages, chatMessages,
addMessage, addMessage,
sessionActivity, isLoading,
isProcessing, setIsLoading,
canAbortSession,
currentSessionId, currentSessionId,
setCurrentSessionId, setCurrentSessionId,
isLoadingSessionMessages, isLoadingSessionMessages,
isLoadingMoreMessages, isLoadingMoreMessages,
hasMoreMessages, hasMoreMessages,
totalMessages, totalMessages,
canAbortSession,
setCanAbortSession,
isUserScrolledUp, isUserScrolledUp,
setIsUserScrolledUp, setIsUserScrolledUp,
tokenBudget, tokenBudget,
@@ -109,6 +114,8 @@ function ChatInterface({
isLoadingAllMessages, isLoadingAllMessages,
loadAllJustFinished, loadAllJustFinished,
showLoadAllOverlay, showLoadAllOverlay,
claudeStatus,
setClaudeStatus,
createDiff, createDiff,
scrollContainerRef, scrollContainerRef,
scrollToBottom, scrollToBottom,
@@ -123,9 +130,8 @@ function ChatInterface({
externalMessageUpdate, externalMessageUpdate,
newSessionTrigger, newSessionTrigger,
processingSessions, processingSessions,
onSessionIdle,
resetStreamingState, resetStreamingState,
statusCheckSentAtRef, pendingViewSessionRef,
sessionStore, sessionStore,
}); });
@@ -185,40 +191,40 @@ function ChatInterface({
codexModel, codexModel,
geminiModel, geminiModel,
opencodeModel, opencodeModel,
isLoading: isProcessing, isLoading,
canAbortSession, canAbortSession,
tokenBudget, tokenBudget,
sendMessage, sendMessage,
sendByCtrlEnter, sendByCtrlEnter,
onSessionActive,
onSessionProcessing, onSessionProcessing,
onInputFocusChange, onInputFocusChange,
onFileOpen, onFileOpen,
onShowSettings, onShowSettings,
pendingViewSessionRef,
scrollToBottom, scrollToBottom,
addMessage, addMessage,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setIsUserScrolledUp, setIsUserScrolledUp,
setPendingPermissionRequests, setPendingPermissionRequests,
}); });
// On WebSocket reconnect, re-fetch the current session's messages from the // On WebSocket reconnect, re-fetch the current session's messages from the server
// server so missed streaming events are shown, then re-check the session's // so missed streaming events are shown. Also reset isLoading.
// processing status — the authoritative reply restores or clears the
// activity indicator depending on whether the run is still active.
const handleWebSocketReconnect = useCallback(async () => { const handleWebSocketReconnect = useCallback(async () => {
if (!selectedProject || !selectedSession) return; if (!selectedProject || !selectedSession) return;
const providerVal = const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
selectedSession.__provider
|| (localStorage.getItem('selected-provider') as LLMProvider)
|| 'claude';
await sessionStore.refreshFromServer(selectedSession.id, { await sessionStore.refreshFromServer(selectedSession.id, {
provider: providerVal as LLMProvider, provider: (selectedSession.__provider || providerVal) as LLMProvider,
// Use DB projectId; legacy folder-derived projectName is no longer accepted here. // Use DB projectId; legacy folder-derived projectName is no longer accepted here.
projectId: selectedProject.projectId, projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '', projectPath: selectedProject.fullPath || selectedProject.path || '',
}); });
statusCheckSentAtRef.current.set(selectedSession.id, Date.now()); setIsLoading(false);
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider: providerVal }); setCanAbortSession(false);
}, [selectedProject, selectedSession, sendMessage, sessionStore]); }, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]);
useChatRealtimeHandlers({ useChatRealtimeHandlers({
latestMessage, latestMessage,
@@ -226,20 +232,25 @@ function ChatInterface({
selectedSession, selectedSession,
currentSessionId, currentSessionId,
setCurrentSessionId, setCurrentSessionId,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget, setTokenBudget,
setPendingPermissionRequests, setPendingPermissionRequests,
pendingViewSessionRef,
streamTimerRef, streamTimerRef,
accumulatedStreamRef, accumulatedStreamRef,
statusCheckSentAtRef, onSessionInactive,
onSessionActive,
onSessionProcessing, onSessionProcessing,
onSessionIdle, onSessionNotProcessing,
onNavigateToSession, onNavigateToSession,
onWebSocketReconnect: handleWebSocketReconnect, onWebSocketReconnect: handleWebSocketReconnect,
sessionStore, sessionStore,
}); });
useEffect(() => { useEffect(() => {
if (!canAbortSession) { if (!isLoading || !canAbortSession) {
return; return;
} }
@@ -256,7 +267,7 @@ function ChatInterface({
return () => { return () => {
document.removeEventListener('keydown', handleGlobalEscape, { capture: true }); document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
}; };
}, [canAbortSession, handleAbortSession]); }, [canAbortSession, handleAbortSession, isLoading]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -351,9 +362,10 @@ function ChatInterface({
pendingPermissionRequests={pendingPermissionRequests} pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision} handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission} handleGrantToolPermission={handleGrantToolPermission}
activity={sessionActivity} claudeStatus={claudeStatus}
isLoading={isProcessing} isLoading={isLoading}
onAbortSession={handleAbortSession} onAbortSession={handleAbortSession}
provider={provider}
permissionMode={permissionMode} permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode} onModeSwitch={cyclePermissionMode}
tokenBudget={tokenBudget} tokenBudget={tokenBudget}

View File

@@ -1,80 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Shimmer } from '../../../../shared/view/ui';
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
type ActivityIndicatorProps = {
activity: SessionActivity | null;
onAbort?: () => void;
};
const ACTION_KEYS = [
'claudeStatus.actions.thinking',
'claudeStatus.actions.processing',
'claudeStatus.actions.analyzing',
'claudeStatus.actions.working',
'claudeStatus.actions.computing',
'claudeStatus.actions.reasoning',
];
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
/**
* Minimal response-in-progress indicator, in the spirit of the inline status
* lines in Claude Code / Codex / OpenCode: a shimmering activity label, the
* elapsed time, and an interrupt affordance. Rendered only while the viewed
* session has an entry in the processing map; it disappears the instant that
* entry is removed.
*/
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
const { t } = useTranslation('chat');
const startedAt = activity?.startedAt ?? null;
const [elapsedSeconds, setElapsedSeconds] = useState(0);
useEffect(() => {
if (startedAt === null) return;
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [startedAt]);
if (!activity) return null;
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
.replace(/\.+$/, '');
const minutes = Math.floor(elapsedSeconds / 60);
const seconds = elapsedSeconds % 60;
const elapsedLabel = minutes < 1
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
return (
<div className="animate-in fade-in mb-2 w-full duration-300">
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1">
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
<Shimmer className="text-xs font-medium">{`${label}`}</Shimmer>
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
{activity.canInterrupt && onAbort && (
<button
type="button"
onClick={onAbort}
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
>
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
<rect x="5" y="5" width="14" height="14" rx="2" />
</svg>
<span>{t('claudeStatus.stop', { defaultValue: 'Stop' })}</span>
<kbd className="hidden rounded border border-border/60 px-1 text-[10px] text-muted-foreground/70 sm:inline-block">
esc
</kbd>
</button>
)}
</div>
</div>
);
}

View File

@@ -11,8 +11,7 @@ import type {
} from 'react'; } from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react'; import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
import type { SessionActivity } from '../../../../hooks/useSessionProtection'; import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
import { import {
PromptInput, PromptInput,
PromptInputHeader, PromptInputHeader,
@@ -25,7 +24,7 @@ import {
} from '../../../../shared/view/ui'; } from '../../../../shared/view/ui';
import CommandMenu from './CommandMenu'; import CommandMenu from './CommandMenu';
import ActivityIndicator from './ActivityIndicator'; import ClaudeStatus from './ClaudeStatus';
import ImageAttachment from './ImageAttachment'; import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner'; import PermissionRequestsBanner from './PermissionRequestsBanner';
import TokenUsageSummary from './TokenUsageSummary'; import TokenUsageSummary from './TokenUsageSummary';
@@ -52,9 +51,10 @@ interface ChatComposerProps {
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown }, decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => void; ) => void;
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean }; handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
activity: SessionActivity | null; claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
isLoading: boolean; isLoading: boolean;
onAbortSession: () => void; onAbortSession: () => void;
provider: Provider | string;
permissionMode: PermissionMode | string; permissionMode: PermissionMode | string;
onModeSwitch: () => void; onModeSwitch: () => void;
tokenBudget: Record<string, unknown> | null; tokenBudget: Record<string, unknown> | null;
@@ -105,9 +105,10 @@ export default function ChatComposer({
pendingPermissionRequests, pendingPermissionRequests,
handlePermissionDecision, handlePermissionDecision,
handleGrantToolPermission, handleGrantToolPermission,
activity, claudeStatus,
isLoading, isLoading,
onAbortSession, onAbortSession,
provider,
permissionMode, permissionMode,
onModeSwitch, onModeSwitch,
tokenBudget, tokenBudget,
@@ -172,7 +173,12 @@ export default function ChatComposer({
return ( return (
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6"> <div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
{!hasPendingPermissions && ( {!hasPendingPermissions && (
<ActivityIndicator activity={activity} onAbort={onAbortSession} /> <ClaudeStatus
status={claudeStatus}
isLoading={isLoading}
onAbort={onAbortSession}
provider={provider}
/>
)} )}
{pendingPermissionRequests.length > 0 && ( {pendingPermissionRequests.length > 0 && (

View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../../lib/utils';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
type ClaudeStatusProps = {
status: {
text?: string;
tokens?: number;
can_interrupt?: boolean;
} | null;
onAbort?: () => void;
isLoading: boolean;
provider?: string;
};
const ACTION_KEYS = [
'claudeStatus.actions.thinking',
'claudeStatus.actions.processing',
'claudeStatus.actions.analyzing',
'claudeStatus.actions.working',
'claudeStatus.actions.computing',
'claudeStatus.actions.reasoning',
];
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const PROVIDER_LABEL_KEYS: Record<string, string> = {
claude: 'messageTypes.claude',
codex: 'messageTypes.codex',
cursor: 'messageTypes.cursor',
gemini: 'messageTypes.gemini',
opencode: 'messageTypes.opencode',
};
function formatElapsedTime(totalSeconds: number) {
const mins = Math.floor(totalSeconds / 60);
const secs = totalSeconds % 60;
return mins < 1 ? `${secs}s` : `${mins}m ${secs}s`;
}
export default function ClaudeStatus({
status,
onAbort,
isLoading,
provider = 'claude',
}: ClaudeStatusProps) {
const { t } = useTranslation('chat');
const [elapsedTime, setElapsedTime] = useState(0);
const [dots, setDots] = useState('');
useEffect(() => {
if (!isLoading) {
setElapsedTime(0);
return;
}
const startTime = Date.now();
const timer = setInterval(() => {
setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
}, 1000);
const dotTimer = setInterval(() => {
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
}, 500);
return () => {
clearInterval(timer);
clearInterval(dotTimer);
};
}, [isLoading]);
if (!isLoading && !status) return null;
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
const statusText = (status?.text || actionWords[Math.floor(elapsedTime / 3) % actionWords.length]).replace(/[.]+$/, '');
const providerLabel = t(PROVIDER_LABEL_KEYS[provider] || 'claudeStatus.providers.assistant', { defaultValue: 'Assistant' });
return (
<div className="animate-in fade-in slide-in-from-bottom-2 mb-3 w-full duration-500">
<div className="mx-auto flex max-w-4xl items-center justify-between gap-3 overflow-hidden rounded-full border border-border/50 bg-slate-100 px-3 py-1.5 shadow-sm backdrop-blur-md dark:bg-slate-900">
{/* Left Side: Identity & Status */}
<div className="flex min-w-0 items-center gap-2.5">
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/20 ring-1 ring-primary/10">
<SessionProviderLogo provider={provider} className="h-3.5 w-3.5" />
{isLoading && (
<span className="absolute inset-0 animate-pulse rounded-full ring-2 ring-emerald-500/20" />
)}
</div>
<div className="flex min-w-0 flex-col sm:flex-row sm:items-center sm:gap-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70">
{providerLabel}
</span>
<div className="flex items-center gap-1.5">
<span className={cn("h-1.5 w-1.5 rounded-full", isLoading ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
<p className="truncate text-xs font-medium text-foreground">
{statusText}<span className="inline-block w-4 text-primary">{isLoading ? dots : ''}</span>
</p>
</div>
</div>
</div>
{/* Right Side: Metrics & Actions */}
<div className="flex items-center gap-2">
{isLoading && status?.can_interrupt !== false && onAbort && (
<>
<div className="hidden items-center rounded-md bg-muted/50 px-2 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground sm:flex">
{formatElapsedTime(elapsedTime)}
</div>
<button
type="button"
onClick={onAbort}
className="group flex items-center gap-1.5 rounded-full bg-destructive/10 px-2.5 py-1 text-[10px] font-bold text-destructive transition-all hover:bg-destructive hover:text-destructive-foreground"
>
<svg className="h-3 w-3 fill-current" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z" />
</svg>
<span className="hidden sm:inline">STOP</span>
<kbd className="hidden rounded bg-black/10 px-1 text-[9px] group-hover:bg-white/20 sm:block">
ESC
</kbd>
</button>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,13 +1,10 @@
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import type { AppTab, Project, ProjectSession } from '../../../types/app'; import type { AppTab, Project, ProjectSession } from '../../../types/app';
import type {
MarkSessionIdle,
MarkSessionProcessing,
SessionActivityMap,
} from '../../../hooks/useSessionProtection';
import type { SessionNavigationOptions } from '../../chat/types/types'; import type { SessionNavigationOptions } from '../../chat/types/types';
export type SessionLifecycleHandler = (sessionId?: string | null) => void;
export type TaskMasterTask = { export type TaskMasterTask = {
id: string | number; id: string | number;
title?: string; title?: string;
@@ -49,9 +46,11 @@ export type MainContentProps = {
onMenuClick: () => void; onMenuClick: () => void;
isLoading: boolean; isLoading: boolean;
onInputFocusChange: (focused: boolean) => void; onInputFocusChange: (focused: boolean) => void;
onSessionProcessing: MarkSessionProcessing; onSessionActive: SessionLifecycleHandler;
onSessionIdle: MarkSessionIdle; onSessionInactive: SessionLifecycleHandler;
processingSessions: SessionActivityMap; onSessionProcessing: SessionLifecycleHandler;
onSessionNotProcessing: SessionLifecycleHandler;
processingSessions: Set<string>;
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void; onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
onShowSettings: () => void; onShowSettings: () => void;
externalMessageUpdate: number; externalMessageUpdate: number;

View File

@@ -42,8 +42,10 @@ function MainContent({
onMenuClick, onMenuClick,
isLoading, isLoading,
onInputFocusChange, onInputFocusChange,
onSessionActive,
onSessionInactive,
onSessionProcessing, onSessionProcessing,
onSessionIdle, onSessionNotProcessing,
processingSessions, processingSessions,
onNavigateToSession, onNavigateToSession,
onShowSettings, onShowSettings,
@@ -129,8 +131,10 @@ function MainContent({
latestMessage={latestMessage} latestMessage={latestMessage}
onFileOpen={handleFileOpen} onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange} onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive}
onSessionInactive={onSessionInactive}
onSessionProcessing={onSessionProcessing} onSessionProcessing={onSessionProcessing}
onSessionIdle={onSessionIdle} onSessionNotProcessing={onSessionNotProcessing}
processingSessions={processingSessions} processingSessions={processingSessions}
onNavigateToSession={onNavigateToSession} onNavigateToSession={onNavigateToSession}
onShowSettings={onShowSettings} onShowSettings={onShowSettings}

View File

@@ -4,11 +4,14 @@ import {
Activity, Activity,
BarChart3, BarChart3,
BookOpen, BookOpen,
Calculator,
Clock, Clock,
Download, Download,
ExternalLink, ExternalLink,
Github,
GitBranch, GitBranch,
Loader2, Loader2,
ListTodo,
RefreshCw, RefreshCw,
ServerCrash, ServerCrash,
ShieldAlert, ShieldAlert,
@@ -27,6 +30,10 @@ const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-term
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron'; const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch'; const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
const PRISM_CLOUDCLI_PLUGIN_URL = 'https://github.com/jakeefr/cloudcli-plugin-prism'; const PRISM_CLOUDCLI_PLUGIN_URL = 'https://github.com/jakeefr/cloudcli-plugin-prism';
const SESSION_MANAGER_PLUGIN_URL = 'https://github.com/strykereye2/cloudcli-plugin-session-manager';
const TOKEN_COST_CALCULATOR_PLUGIN_URL = 'https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator';
const TASK_QUEUE_PLUGIN_URL = 'https://github.com/TadMSTR/cloudcli-plugin-task-queue';
const GITHUB_ISSUES_BOARD_PLUGIN_URL = 'https://github.com/szmidtpiotr/claude-github-issue';
type PluginRecommendation = { type PluginRecommendation = {
id: string; id: string;
@@ -79,8 +86,40 @@ const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
repoUrl: PRISM_CLOUDCLI_PLUGIN_URL, repoUrl: PRISM_CLOUDCLI_PLUGIN_URL,
installedNames: ['prism'], installedNames: ['prism'],
icon: Activity, icon: Activity,
source: 'unofficial' source: 'unofficial',
} },
{
id: 'session-manager',
translationKey: 'sessionManagerPlugin',
repoUrl: SESSION_MANAGER_PLUGIN_URL,
installedNames: ['session-manager'],
icon: Activity,
source: 'unofficial',
},
{
id: 'token-cost-calculator',
translationKey: 'tokenCostCalculatorPlugin',
repoUrl: TOKEN_COST_CALCULATOR_PLUGIN_URL,
installedNames: ['token-cost-calculator'],
icon: Calculator,
source: 'unofficial',
},
{
id: 'task-queue',
translationKey: 'taskQueuePlugin',
repoUrl: TASK_QUEUE_PLUGIN_URL,
installedNames: ['task-queue'],
icon: ListTodo,
source: 'unofficial',
},
{
id: 'claude-github-issue',
translationKey: 'githubIssuesBoardPlugin',
repoUrl: GITHUB_ISSUES_BOARD_PLUGIN_URL,
installedNames: ['claude-github-issue'],
icon: Github,
source: 'unofficial',
},
]; ];
function repoSlug(repoUrl: string) { function repoSlug(repoUrl: string) {

View File

@@ -12,14 +12,12 @@ import type {
ProjectsUpdatedMessage, ProjectsUpdatedMessage,
} from '../types/app'; } from '../types/app';
import type { SessionActivityMap } from './useSessionProtection';
type UseProjectsStateArgs = { type UseProjectsStateArgs = {
sessionId?: string; sessionId?: string;
navigate: NavigateFunction; navigate: NavigateFunction;
latestMessage: AppSocketMessage | null; latestMessage: AppSocketMessage | null;
isMobile: boolean; isMobile: boolean;
activeSessions: SessionActivityMap; activeSessions: Set<string>;
}; };
type FetchProjectsOptions = { type FetchProjectsOptions = {

View File

@@ -1,103 +1,55 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
/**
* Map key for a request that is in flight before the provider has announced
* its real session id (a brand-new conversation). `session_created` migrates
* the entry to the concrete session id.
*/
export const PENDING_SESSION_ID = '__pending_session__';
export interface SessionActivity {
/** Provider-supplied status line; null renders the default activity label. */
statusText: string | null;
canInterrupt: boolean;
/**
* When this request was first marked as processing (client clock). Drives
* the elapsed-time display and the stale `session-status` reply guard.
*/
startedAt: number;
}
export type SessionActivityMap = ReadonlyMap<string, SessionActivity>;
export type MarkSessionProcessing = (
sessionId?: string | null,
activity?: { statusText?: string | null; canInterrupt?: boolean },
) => void;
export type MarkSessionIdle = (
sessionId?: string | null,
opts?: { ifStartedBefore?: number },
) => void;
/**
* Single source of truth for which sessions are actively processing a
* request. Everything the chat UI shows (activity indicator, abort
* availability, status text) is derived from this map; terminal events
* (`complete`, `error`, abort, an authoritative idle status reply) delete the
* entry atomically. The map also drives session protection: project refreshes
* are suppressed for sessions that have an entry here.
*/
export function useSessionProtection() { export function useSessionProtection() {
const [processingSessions, setProcessingSessions] = useState<Map<string, SessionActivity>>( const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
new Map(), const [processingSessions, setProcessingSessions] = useState<Set<string>>(new Set());
);
const markSessionProcessing = useCallback<MarkSessionProcessing>((sessionId, activity) => { const markSessionAsActive = useCallback((sessionId?: string | null) => {
if (!sessionId) { if (!sessionId) {
return; return;
} }
setProcessingSessions((prev) => { setActiveSessions((prev) => new Set([...prev, sessionId]));
const existing = prev.get(sessionId); }, []);
const next: SessionActivity = {
statusText:
activity?.statusText !== undefined ? activity.statusText : existing?.statusText ?? null,
canInterrupt: activity?.canInterrupt ?? existing?.canInterrupt ?? true,
startedAt: existing?.startedAt ?? Date.now(),
};
if ( const markSessionAsInactive = useCallback((sessionId?: string | null) => {
existing if (!sessionId) {
&& existing.statusText === next.statusText return;
&& existing.canInterrupt === next.canInterrupt }
) {
return prev;
}
const updated = new Map(prev); setActiveSessions((prev) => {
updated.set(sessionId, next); const next = new Set(prev);
return updated; next.delete(sessionId);
return next;
}); });
}, []); }, []);
const markSessionIdle = useCallback<MarkSessionIdle>((sessionId, opts) => { const markSessionAsProcessing = useCallback((sessionId?: string | null) => {
if (!sessionId) {
return;
}
setProcessingSessions((prev) => new Set([...prev, sessionId]));
}, []);
const markSessionAsNotProcessing = useCallback((sessionId?: string | null) => {
if (!sessionId) { if (!sessionId) {
return; return;
} }
setProcessingSessions((prev) => { setProcessingSessions((prev) => {
const existing = prev.get(sessionId); const next = new Set(prev);
if (!existing) { next.delete(sessionId);
return prev; return next;
}
// Guard against stale `check-session-status` replies: if a new request
// started after the check was sent, the idle reply describes the older
// request and must not clear the newer one.
if (opts?.ifStartedBefore !== undefined && existing.startedAt >= opts.ifStartedBefore) {
return prev;
}
const updated = new Map(prev);
updated.delete(sessionId);
return updated;
}); });
}, []); }, []);
return { return {
activeSessions,
processingSessions, processingSessions,
markSessionProcessing, markSessionAsActive,
markSessionIdle, markSessionAsInactive,
markSessionAsProcessing,
markSessionAsNotProcessing,
}; };
} }

View File

@@ -224,7 +224,6 @@
"label": "{{time}} elapsed", "label": "{{time}} elapsed",
"startingNow": "Starting now" "startingNow": "Starting now"
}, },
"stop": "Stop",
"controls": { "controls": {
"stopGeneration": "Stop Generation", "stopGeneration": "Stop Generation",
"pressEscToStop": "Press Esc anytime to stop" "pressEscToStop": "Press Esc anytime to stop"

View File

@@ -514,6 +514,30 @@
"description": "Session intelligence for Claude Code, inside CloudCLI. See why your sessions are burning tokens without leaving the browser.", "description": "Session intelligence for Claude Code, inside CloudCLI. See why your sessions are burning tokens without leaving the browser.",
"install": "Install" "install": "Install"
}, },
"sessionManagerPlugin": {
"name": "Sessions",
"badge": "unofficial",
"description": "View, manage, and kill active Claude Code sessions.",
"install": "Install"
},
"tokenCostCalculatorPlugin": {
"name": "Token Cost Calculator",
"badge": "unofficial",
"description": "Calculate API costs from model prices and token usage, with preset model pricing support.",
"install": "Install"
},
"taskQueuePlugin": {
"name": "Task Queue",
"badge": "unofficial",
"description": "Task queue dashboard to view, filter, and launch agent tasks.",
"install": "Install"
},
"githubIssuesBoardPlugin": {
"name": "GitHub Issues Board",
"badge": "unofficial",
"description": "Kanban board for GitHub Issues with bidirectional TaskMaster sync and /github-task CLI skill auto-install",
"install": "Install"
},
"morePlugins": "More", "morePlugins": "More",
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",