mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-13 01:22:06 +08:00
Compare commits
7 Commits
main
...
feat/upgra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a0c8b20e5 | ||
|
|
00e526b6e9 | ||
|
|
591b18e9e3 | ||
|
|
881e72d4a0 | ||
|
|
f5eac2ec12 | ||
|
|
3d948217ef | ||
|
|
afc717e69e |
@@ -164,14 +164,6 @@ CloudCLI verfügt über ein Plugin-System, mit dem benutzerdefinierte Tabs mit e
|
|||||||
| Plugin | Beschreibung |
|
| Plugin | Beschreibung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts |
|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts |
|
||||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Vollwertiges xterm.js-Terminal mit Multi-Tab-Unterstützung |
|
|
||||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Überwacht lange laufende Claude-Code-Sitzungen auf Hänger und stellt Prozesssteuerungen bereit |
|
|
||||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Erstellt arbeitsbereichsbezogene geplante Prompts und führt sie über eine lokale CLI wie Codex, Claude Code oder Gemini CLI aus |
|
|
||||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Sitzungsintelligenz für Claude Code in CloudCLI, inklusive Sichtbarkeit des Token-Verbrauchs |
|
|
||||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktive Claude-Code-Sitzungen anzeigen, verwalten und beenden |
|
|
||||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | API-Kosten anhand von Modellpreisen und Token-Nutzung berechnen, mit Unterstützung für Preisvorlagen |
|
|
||||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task-Queue-Dashboard zum Anzeigen, Filtern und Starten von Agent-Aufgaben |
|
|
||||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-Board für GitHub Issues mit bidirektionaler TaskMaster-Synchronisierung und automatischer Installation des /github-task CLI-Skills |
|
|
||||||
|
|
||||||
### Eigenes Plugin erstellen
|
### Eigenes Plugin erstellen
|
||||||
|
|
||||||
|
|||||||
@@ -158,14 +158,6 @@ 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 スキルの自動インストールに対応 |
|
|
||||||
|
|
||||||
### 自作する
|
### 自作する
|
||||||
|
|
||||||
|
|||||||
@@ -158,14 +158,6 @@ 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 스킬 자동 설치 지원 |
|
|
||||||
|
|
||||||
### 직접 만들기
|
### 직접 만들기
|
||||||
|
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -163,15 +163,8 @@ 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|
|
||||||
| **[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|
|
||||||
| **[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.
|
||||||
|
|||||||
@@ -164,14 +164,6 @@ 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 |
|
|
||||||
|
|
||||||
### Создать свой
|
### Создать свой
|
||||||
|
|
||||||
|
|||||||
@@ -164,13 +164,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -158,14 +158,6 @@ 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 技能自动安装 |
|
|
||||||
|
|
||||||
### 自行构建
|
### 自行构建
|
||||||
|
|
||||||
|
|||||||
@@ -158,14 +158,6 @@ 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 技能自動安裝 |
|
|
||||||
|
|
||||||
### 自行建構
|
### 自行建構
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,14 @@ import {
|
|||||||
} from './services/notification-orchestrator.js';
|
} from './services/notification-orchestrator.js';
|
||||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
import { createNormalizedMessage } from './shared/utils.js';
|
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
const activeSessions = new Map();
|
const activeSessions = new Map();
|
||||||
const pendingToolApprovals = new Map();
|
const pendingToolApprovals = new Map();
|
||||||
|
// Sessions cancelled via abort-session. The abort handler already sent the
|
||||||
|
// terminal `complete` (aborted: true) to the client, so the run loop must not
|
||||||
|
// emit a second one when its generator winds down.
|
||||||
|
const abortedSessionIds = new Set();
|
||||||
|
|
||||||
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
|
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
|
||||||
|
|
||||||
@@ -731,14 +735,18 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
// Clean up temporary image files
|
// Clean up temporary image files
|
||||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||||
|
|
||||||
// Send completion event
|
// Send the terminal completion event — skipped for aborted runs, whose
|
||||||
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
|
// terminal `complete` (aborted: true) was already sent by abort-session.
|
||||||
|
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
|
||||||
|
if (!wasAborted) {
|
||||||
|
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
|
||||||
|
}
|
||||||
notifyRunStopped({
|
notifyRunStopped({
|
||||||
userId: ws?.userId || null,
|
userId: ws?.userId || null,
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
sessionId: capturedSessionId || sessionId || null,
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
sessionName: sessionSummary,
|
sessionName: sessionSummary,
|
||||||
stopReason: 'completed'
|
stopReason: wasAborted ? 'aborted' : 'completed'
|
||||||
});
|
});
|
||||||
// Complete
|
// Complete
|
||||||
|
|
||||||
@@ -753,14 +761,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
// Clean up temporary image files on error
|
// Clean up temporary image files on error
|
||||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||||
|
|
||||||
|
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
|
||||||
|
if (wasAborted) {
|
||||||
|
// The abort already produced the terminal complete; a generator throw
|
||||||
|
// caused by interrupt() is expected noise, not a user-facing error.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if Claude CLI is installed for a clearer error message
|
// Check if Claude CLI is installed for a clearer error message
|
||||||
const installed = await providerAuthService.isProviderInstalled('claude');
|
const installed = await providerAuthService.isProviderInstalled('claude');
|
||||||
const errorContent = !installed
|
const errorContent = !installed
|
||||||
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
|
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
|
||||||
: error.message;
|
: error.message;
|
||||||
|
|
||||||
// Send error to WebSocket
|
// Send error to WebSocket, then the terminal complete
|
||||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||||
|
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
|
||||||
notifyRunFailed({
|
notifyRunFailed({
|
||||||
userId: ws?.userId || null,
|
userId: ws?.userId || null,
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
@@ -787,6 +803,10 @@ async function abortClaudeSDKSession(sessionId) {
|
|||||||
try {
|
try {
|
||||||
console.log(`Aborting SDK session: ${sessionId}`);
|
console.log(`Aborting SDK session: ${sessionId}`);
|
||||||
|
|
||||||
|
// Mark before interrupting so the run loop knows not to emit its own
|
||||||
|
// terminal complete (the abort handler sends the aborted one).
|
||||||
|
abortedSessionIds.add(sessionId);
|
||||||
|
|
||||||
// Call interrupt() on the query instance
|
// Call interrupt() on the query instance
|
||||||
await session.instance.interrupt();
|
await session.instance.interrupt();
|
||||||
|
|
||||||
@@ -802,6 +822,8 @@ async function abortClaudeSDKSession(sessionId) {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error aborting session ${sessionId}:`, error);
|
console.error(`Error aborting session ${sessionId}:`, error);
|
||||||
|
// The run keeps going; let it emit its own terminal complete.
|
||||||
|
abortedSessionIds.delete(sessionId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
|
|||||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
import { createNormalizedMessage } from './shared/utils.js';
|
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
// Use cross-spawn on Windows for better command execution
|
// Use cross-spawn on Windows for better command execution
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
@@ -34,6 +34,10 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||||
let hasRetriedWithTrust = false;
|
let hasRetriedWithTrust = false;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
// The unified lifecycle contract requires exactly one terminal `complete`
|
||||||
|
// per run. Cursor surfaces completion twice (the `result` JSON line and
|
||||||
|
// the process close), so the first emission wins.
|
||||||
|
let completeSent = false;
|
||||||
|
|
||||||
// Use tools settings passed from frontend, or defaults
|
// Use tools settings passed from frontend, or defaults
|
||||||
const settings = toolsSettings || {
|
const settings = toolsSettings || {
|
||||||
@@ -197,15 +201,15 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'result': {
|
case 'result': {
|
||||||
// Session complete — send stream end + lifecycle complete with result payload
|
// Session complete — terminal lifecycle event for this run
|
||||||
const resultText = typeof response.result === 'string' ? response.result : '';
|
if (!completeSent) {
|
||||||
ws.send(createNormalizedMessage({
|
completeSent = true;
|
||||||
kind: 'complete',
|
ws.send(createCompleteMessage({
|
||||||
exitCode: response.subtype === 'success' ? 0 : 1,
|
provider: 'cursor',
|
||||||
resultText,
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
isError: response.subtype !== 'success',
|
exitCode: response.subtype === 'success' ? 0 : 1,
|
||||||
sessionId: capturedSessionId || sessionId, provider: 'cursor',
|
}));
|
||||||
}));
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +275,12 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' }));
|
// Terminal complete — unless the `result` line already sent it, or the
|
||||||
|
// run was aborted (abort-session sent the aborted complete).
|
||||||
|
if (!completeSent && !cursorProcess.aborted) {
|
||||||
|
completeSent = true;
|
||||||
|
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: finalSessionId, exitCode: code }));
|
||||||
|
}
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
notifyTerminalState({ code });
|
notifyTerminalState({ code });
|
||||||
@@ -297,6 +306,10 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
: error.message;
|
: error.message;
|
||||||
|
|
||||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
|
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
|
||||||
|
if (!completeSent && !cursorProcess.aborted) {
|
||||||
|
completeSent = true;
|
||||||
|
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
|
||||||
|
}
|
||||||
notifyTerminalState({ error });
|
notifyTerminalState({ error });
|
||||||
|
|
||||||
settleOnce(() => reject(error));
|
settleOnce(() => reject(error));
|
||||||
@@ -314,6 +327,9 @@ function abortCursorSession(sessionId) {
|
|||||||
const process = activeCursorProcesses.get(sessionId);
|
const process = activeCursorProcesses.get(sessionId);
|
||||||
if (process) {
|
if (process) {
|
||||||
console.log(`Aborting Cursor session: ${sessionId}`);
|
console.log(`Aborting Cursor session: ${sessionId}`);
|
||||||
|
// The abort handler sends the terminal complete (aborted: true); flag the
|
||||||
|
// process so its close handler does not emit a second one.
|
||||||
|
process.aborted = true;
|
||||||
process.kill('SIGTERM');
|
process.kill('SIGTERM');
|
||||||
activeCursorProcesses.delete(sessionId);
|
activeCursorProcesses.delete(sessionId);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import GeminiResponseHandler from './gemini-response-handler.js';
|
|||||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
import { createNormalizedMessage } from './shared/utils.js';
|
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
@@ -129,6 +129,9 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||||
let assistantBlocks = []; // Accumulate the full response blocks including tools
|
let assistantBlocks = []; // Accumulate the full response blocks including tools
|
||||||
|
// Unified lifecycle contract: exactly one terminal `complete` per run
|
||||||
|
// (close and error handlers can both fire for spawn failures).
|
||||||
|
let completeSent = false;
|
||||||
|
|
||||||
// Use tools settings passed from frontend, or defaults
|
// Use tools settings passed from frontend, or defaults
|
||||||
const settings = toolsSettings || {
|
const settings = toolsSettings || {
|
||||||
@@ -486,7 +489,12 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
|
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' }));
|
// Terminal complete — skipped for aborted runs (abort-session
|
||||||
|
// already sent the aborted complete on this run's behalf).
|
||||||
|
if (!completeSent && !geminiProcess.aborted) {
|
||||||
|
completeSent = true;
|
||||||
|
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: finalSessionId, exitCode: code }));
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up temporary image files if any
|
// Clean up temporary image files if any
|
||||||
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
|
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
|
||||||
@@ -566,6 +574,10 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
|
|
||||||
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
||||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
|
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
|
||||||
|
if (!completeSent && !geminiProcess.aborted) {
|
||||||
|
completeSent = true;
|
||||||
|
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: errorSessionId, exitCode: 1 }));
|
||||||
|
}
|
||||||
notifyTerminalState({ error });
|
notifyTerminalState({ error });
|
||||||
|
|
||||||
reject(error);
|
reject(error);
|
||||||
@@ -590,6 +602,9 @@ function abortGeminiSession(sessionId) {
|
|||||||
|
|
||||||
if (geminiProc) {
|
if (geminiProc) {
|
||||||
try {
|
try {
|
||||||
|
// The abort handler sends the terminal complete (aborted: true);
|
||||||
|
// flag the process so its close handler does not emit a second one.
|
||||||
|
geminiProc.aborted = true;
|
||||||
geminiProc.kill('SIGTERM');
|
geminiProc.kill('SIGTERM');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (activeGeminiProcesses.has(processKey)) {
|
if (activeGeminiProcesses.has(processKey)) {
|
||||||
|
|||||||
@@ -22,35 +22,24 @@ import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
|
|||||||
import {
|
import {
|
||||||
queryClaudeSDK,
|
queryClaudeSDK,
|
||||||
abortClaudeSDKSession,
|
abortClaudeSDKSession,
|
||||||
isClaudeSDKSessionActive,
|
|
||||||
getActiveClaudeSDKSessions,
|
|
||||||
resolveToolApproval,
|
resolveToolApproval,
|
||||||
getPendingApprovalsForSession,
|
getPendingApprovalsForSession,
|
||||||
reconnectSessionWriter,
|
|
||||||
} from './claude-sdk.js';
|
} from './claude-sdk.js';
|
||||||
import {
|
import {
|
||||||
spawnCursor,
|
spawnCursor,
|
||||||
abortCursorSession,
|
abortCursorSession,
|
||||||
isCursorSessionActive,
|
|
||||||
getActiveCursorSessions,
|
|
||||||
} from './cursor-cli.js';
|
} from './cursor-cli.js';
|
||||||
import {
|
import {
|
||||||
queryCodex,
|
queryCodex,
|
||||||
abortCodexSession,
|
abortCodexSession,
|
||||||
isCodexSessionActive,
|
|
||||||
getActiveCodexSessions,
|
|
||||||
} from './openai-codex.js';
|
} from './openai-codex.js';
|
||||||
import {
|
import {
|
||||||
spawnGemini,
|
spawnGemini,
|
||||||
abortGeminiSession,
|
abortGeminiSession,
|
||||||
isGeminiSessionActive,
|
|
||||||
getActiveGeminiSessions,
|
|
||||||
} from './gemini-cli.js';
|
} from './gemini-cli.js';
|
||||||
import {
|
import {
|
||||||
spawnOpenCode,
|
spawnOpenCode,
|
||||||
abortOpenCodeSession,
|
abortOpenCodeSession,
|
||||||
isOpenCodeSessionActive,
|
|
||||||
getActiveOpenCodeSessions,
|
|
||||||
} from './opencode-cli.js';
|
} from './opencode-cli.js';
|
||||||
import sessionManager from './sessionManager.js';
|
import sessionManager from './sessionManager.js';
|
||||||
import {
|
import {
|
||||||
@@ -105,29 +94,22 @@ const wss = createWebSocketServer(server, {
|
|||||||
authenticateWebSocket,
|
authenticateWebSocket,
|
||||||
},
|
},
|
||||||
chat: {
|
chat: {
|
||||||
queryClaudeSDK,
|
spawnFns: {
|
||||||
spawnCursor,
|
claude: queryClaudeSDK,
|
||||||
queryCodex,
|
cursor: spawnCursor,
|
||||||
spawnGemini,
|
codex: queryCodex,
|
||||||
spawnOpenCode,
|
gemini: spawnGemini,
|
||||||
abortClaudeSDKSession,
|
opencode: spawnOpenCode,
|
||||||
abortCursorSession,
|
},
|
||||||
abortCodexSession,
|
abortFns: {
|
||||||
abortGeminiSession,
|
claude: abortClaudeSDKSession,
|
||||||
abortOpenCodeSession,
|
cursor: abortCursorSession,
|
||||||
|
codex: abortCodexSession,
|
||||||
|
gemini: abortGeminiSession,
|
||||||
|
opencode: abortOpenCodeSession,
|
||||||
|
},
|
||||||
resolveToolApproval,
|
resolveToolApproval,
|
||||||
isClaudeSDKSessionActive,
|
|
||||||
isCursorSessionActive,
|
|
||||||
isCodexSessionActive,
|
|
||||||
isGeminiSessionActive,
|
|
||||||
isOpenCodeSessionActive,
|
|
||||||
reconnectSessionWriter,
|
|
||||||
getPendingApprovalsForSession,
|
getPendingApprovalsForSession,
|
||||||
getActiveClaudeSDKSessions,
|
|
||||||
getActiveCursorSessions,
|
|
||||||
getActiveCodexSessions,
|
|
||||||
getActiveGeminiSessions,
|
|
||||||
getActiveOpenCodeSessions,
|
|
||||||
},
|
},
|
||||||
shell: {
|
shell: {
|
||||||
getSessionById: (sessionId) => sessionManager.getSession(sessionId),
|
getSessionById: (sessionId) => sessionManager.getSession(sessionId),
|
||||||
@@ -1152,6 +1134,12 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provider artifacts on disk (JSONL file names, OpenCode sqlite rows)
|
||||||
|
// are keyed by the provider-native session id, while the caller sends
|
||||||
|
// the app-facing id. Resolve the mapping once for all branches below.
|
||||||
|
const sessionRow = sessionsDb.getSessionById(safeSessionId);
|
||||||
|
const providerNativeSessionId = sessionRow?.provider_session_id || safeSessionId;
|
||||||
|
|
||||||
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
||||||
if (provider === 'cursor') {
|
if (provider === 'cursor') {
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -1252,7 +1240,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
tokens_cache_write AS cacheWriteTokens
|
tokens_cache_write AS cacheWriteTokens
|
||||||
FROM session
|
FROM session
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).get(safeSessionId);
|
`).get(providerNativeSessionId);
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return res.status(404).json({ error: 'OpenCode session not found', sessionId: safeSessionId });
|
return res.status(404).json({ error: 'OpenCode session not found', sessionId: safeSessionId });
|
||||||
@@ -1293,7 +1281,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
const found = await findSessionFile(fullPath);
|
const found = await findSessionFile(fullPath);
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
} else if (entry.name.includes(providerNativeSessionId) && entry.name.endsWith('.jsonl')) {
|
||||||
return fullPath;
|
return fullPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1377,12 +1365,19 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
|
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||||
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
||||||
|
|
||||||
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
// Prefer the indexed transcript path (already produced by the trusted
|
||||||
|
// session synchronizer); fall back to the conventional location
|
||||||
|
// derived from the provider-native session id.
|
||||||
|
let jsonlPath = sessionRow?.jsonl_path;
|
||||||
|
if (!jsonlPath) {
|
||||||
|
jsonlPath = path.join(projectDir, `${providerNativeSessionId}.jsonl`);
|
||||||
|
|
||||||
// Constrain to projectDir
|
// Constrain the constructed path to projectDir (the id is
|
||||||
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
// caller-influenced in this fallback branch).
|
||||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
||||||
return res.status(400).json({ error: 'Invalid path' });
|
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid path' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and parse the JSONL file
|
// Read and parse the JSONL file
|
||||||
|
|||||||
@@ -382,6 +382,25 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the `provider_session_id` mapping column used by the session gateway.
|
||||||
|
*
|
||||||
|
* Rows that existed before this migration were always keyed directly by the
|
||||||
|
* provider-native session id, so backfilling `provider_session_id` with
|
||||||
|
* `session_id` keeps every legacy row resolvable through the new mapping.
|
||||||
|
*/
|
||||||
|
const addProviderSessionIdMapping = (db: Database): void => {
|
||||||
|
const sessionsTableInfo = getTableInfo(db, 'sessions');
|
||||||
|
const columnNames = sessionsTableInfo.map((column) => column.name);
|
||||||
|
|
||||||
|
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'provider_session_id', 'TEXT');
|
||||||
|
db.exec(`
|
||||||
|
UPDATE sessions
|
||||||
|
SET provider_session_id = session_id
|
||||||
|
WHERE provider_session_id IS NULL
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
const ensureProjectsForSessionPaths = (db: Database): void => {
|
const ensureProjectsForSessionPaths = (db: Database): void => {
|
||||||
if (!tableExists(db, 'sessions')) {
|
if (!tableExists(db, 'sessions')) {
|
||||||
return;
|
return;
|
||||||
@@ -428,9 +447,11 @@ export const runMigrations = (db: Database) => {
|
|||||||
migrateLegacyWorkspaceTableIntoProjects(db);
|
migrateLegacyWorkspaceTableIntoProjects(db);
|
||||||
rebuildSessionsTableWithProjectSchema(db);
|
rebuildSessionsTableWithProjectSchema(db);
|
||||||
migrateLegacySessionNames(db);
|
migrateLegacySessionNames(db);
|
||||||
|
addProviderSessionIdMapping(db);
|
||||||
ensureProjectsForSessionPaths(db);
|
ensureProjectsForSessionPaths(db);
|
||||||
|
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_provider_session_id ON sessions(provider_session_id)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { normalizeProjectPath } from '@/shared/utils.js';
|
|||||||
type SessionRow = {
|
type SessionRow = {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
provider_session_id: string | null;
|
||||||
project_path: string | null;
|
project_path: string | null;
|
||||||
jsonl_path: string | null;
|
jsonl_path: string | null;
|
||||||
custom_name: string | null;
|
custom_name: string | null;
|
||||||
@@ -13,10 +14,8 @@ type SessionRow = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SessionMetadataLookupRow = Pick<
|
const SESSION_ROW_COLUMNS =
|
||||||
SessionRow,
|
'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at';
|
||||||
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at'
|
|
||||||
>;
|
|
||||||
|
|
||||||
function normalizeTimestamp(value?: string): string | null {
|
function normalizeTimestamp(value?: string): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
@@ -35,8 +34,16 @@ function normalizeProjectPathForProvider(provider: string, projectPath: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const sessionsDb = {
|
export const sessionsDb = {
|
||||||
|
/**
|
||||||
|
* Upserts one session row discovered on disk by a provider synchronizer.
|
||||||
|
*
|
||||||
|
* The given id is the provider-native session id. Rows are keyed by
|
||||||
|
* `provider_session_id` so a session that was first created by the app
|
||||||
|
* (with an app-allocated `session_id`) is updated in place once its
|
||||||
|
* transcript shows up on disk, instead of producing a duplicate row.
|
||||||
|
*/
|
||||||
createSession(
|
createSession(
|
||||||
sessionId: string,
|
providerSessionId: string,
|
||||||
provider: string,
|
provider: string,
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
customName?: string,
|
customName?: string,
|
||||||
@@ -53,19 +60,54 @@ export const sessionsDb = {
|
|||||||
// since it's a foreign key in the sessions table.
|
// since it's a foreign key in the sessions table.
|
||||||
projectsDb.createProjectPath(normalizedProjectPath);
|
projectsDb.createProjectPath(normalizedProjectPath);
|
||||||
|
|
||||||
|
const existing = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id FROM sessions
|
||||||
|
WHERE provider_session_id = ? AND provider = ?
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(providerSessionId, provider) as { session_id: string } | undefined;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE sessions SET
|
||||||
|
provider = ?,
|
||||||
|
updated_at = COALESCE(?, CURRENT_TIMESTAMP),
|
||||||
|
project_path = ?,
|
||||||
|
jsonl_path = ?,
|
||||||
|
isArchived = 0,
|
||||||
|
custom_name = COALESCE(?, custom_name)
|
||||||
|
WHERE session_id = ?`
|
||||||
|
).run(
|
||||||
|
provider,
|
||||||
|
updatedAtValue,
|
||||||
|
normalizedProjectPath,
|
||||||
|
jsonlPath ?? null,
|
||||||
|
customName ?? null,
|
||||||
|
existing.session_id
|
||||||
|
);
|
||||||
|
|
||||||
|
return existing.session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessions created outside the app (directly via the provider CLI) are
|
||||||
|
// keyed by the provider-native id for both columns. The ON CONFLICT path
|
||||||
|
// covers legacy rows that predate the provider_session_id mapping.
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
|
`INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
|
VALUES (?, ?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
|
||||||
ON CONFLICT(session_id) DO UPDATE SET
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
provider = excluded.provider,
|
provider = excluded.provider,
|
||||||
|
provider_session_id = excluded.provider_session_id,
|
||||||
updated_at = excluded.updated_at,
|
updated_at = excluded.updated_at,
|
||||||
project_path = excluded.project_path,
|
project_path = excluded.project_path,
|
||||||
jsonl_path = excluded.jsonl_path,
|
jsonl_path = excluded.jsonl_path,
|
||||||
isArchived = 0,
|
isArchived = 0,
|
||||||
custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
|
custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
|
||||||
).run(
|
).run(
|
||||||
sessionId,
|
providerSessionId,
|
||||||
provider,
|
provider,
|
||||||
|
providerSessionId,
|
||||||
customName ?? null,
|
customName ?? null,
|
||||||
normalizedProjectPath,
|
normalizedProjectPath,
|
||||||
jsonlPath ?? null,
|
jsonlPath ?? null,
|
||||||
@@ -73,9 +115,77 @@ export const sessionsDb = {
|
|||||||
updatedAtValue
|
updatedAtValue
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return providerSessionId;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts one app-allocated session row before any provider run happens.
|
||||||
|
*
|
||||||
|
* The session gateway uses this when the frontend starts a brand-new chat:
|
||||||
|
* `session_id` is the stable app-facing id, while `provider_session_id`
|
||||||
|
* stays NULL until the provider runtime announces its own id and
|
||||||
|
* `assignProviderSessionId` records the mapping.
|
||||||
|
*/
|
||||||
|
createAppSession(sessionId: string, provider: string, projectPath: string): string {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
|
||||||
|
|
||||||
|
projectsDb.createProjectPath(normalizedProjectPath);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
|
||||||
|
VALUES (?, ?, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
|
||||||
|
).run(sessionId, provider, normalizedProjectPath);
|
||||||
|
|
||||||
return sessionId;
|
return sessionId;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the provider-native session id for one app-allocated session.
|
||||||
|
*
|
||||||
|
* If the filesystem watcher indexed the provider transcript before this
|
||||||
|
* mapping was recorded (a duplicate row keyed by the provider id exists),
|
||||||
|
* the duplicate is merged into the app row: its transcript path and name
|
||||||
|
* are adopted and the duplicate row is removed. Runs in a transaction so
|
||||||
|
* the sidebar can never observe both rows at once.
|
||||||
|
*/
|
||||||
|
assignProviderSessionId(sessionId: string, providerSessionId: string): void {
|
||||||
|
const db = getConnection();
|
||||||
|
|
||||||
|
const merge = db.transaction(() => {
|
||||||
|
const duplicate = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${SESSION_ROW_COLUMNS} FROM sessions
|
||||||
|
WHERE (session_id = ? OR provider_session_id = ?)
|
||||||
|
AND session_id <> ?
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(providerSessionId, providerSessionId, sessionId) as SessionRow | undefined;
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE session_id = ?').run(duplicate.session_id);
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE sessions SET
|
||||||
|
provider_session_id = ?,
|
||||||
|
jsonl_path = COALESCE(jsonl_path, ?),
|
||||||
|
custom_name = COALESCE(custom_name, ?),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE session_id = ?`
|
||||||
|
).run(providerSessionId, duplicate.jsonl_path, duplicate.custom_name, sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE sessions SET
|
||||||
|
provider_session_id = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE session_id = ?`
|
||||||
|
).run(providerSessionId, sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
merge();
|
||||||
|
},
|
||||||
|
|
||||||
updateSessionCustomName(sessionId: string, customName: string): void {
|
updateSessionCustomName(sessionId: string, customName: string): void {
|
||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -85,17 +195,39 @@ export const sessionsDb = {
|
|||||||
).run(customName, sessionId);
|
).run(customName, sessionId);
|
||||||
},
|
},
|
||||||
|
|
||||||
getSessionById(sessionId: string): SessionMetadataLookupRow | null {
|
getSessionById(sessionId: string): SessionRow | null {
|
||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE session_id = ?
|
WHERE session_id = ?
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
LIMIT 1`
|
LIMIT 1`
|
||||||
)
|
)
|
||||||
.get(sessionId) as SessionMetadataLookupRow | undefined;
|
.get(sessionId) as SessionRow | undefined;
|
||||||
|
|
||||||
|
return row ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves one session row through the provider-native id.
|
||||||
|
*
|
||||||
|
* The filesystem watcher only knows provider ids (they come from transcript
|
||||||
|
* file names), so it uses this lookup to translate disk artifacts back to
|
||||||
|
* the app-facing session row before broadcasting sidebar updates.
|
||||||
|
*/
|
||||||
|
getSessionByProviderSessionId(providerSessionId: string): SessionRow | null {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
|
FROM sessions
|
||||||
|
WHERE provider_session_id = ?
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(providerSessionId) as SessionRow | undefined;
|
||||||
|
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
},
|
},
|
||||||
@@ -104,7 +236,7 @@ export const sessionsDb = {
|
|||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE isArchived = 0`
|
WHERE isArchived = 0`
|
||||||
)
|
)
|
||||||
@@ -119,7 +251,7 @@ export const sessionsDb = {
|
|||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE isArchived = 1
|
WHERE isArchived = 1
|
||||||
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
|
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
|
||||||
@@ -132,7 +264,7 @@ export const sessionsDb = {
|
|||||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE project_path = ?
|
WHERE project_path = ?
|
||||||
AND isArchived = 0`
|
AND isArchived = 0`
|
||||||
@@ -149,7 +281,7 @@ export const sessionsDb = {
|
|||||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE project_path = ?`
|
WHERE project_path = ?`
|
||||||
)
|
)
|
||||||
@@ -161,7 +293,7 @@ export const sessionsDb = {
|
|||||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
`SELECT ${SESSION_ROW_COLUMNS}
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE project_path = ?
|
WHERE project_path = ?
|
||||||
AND isArchived = 0
|
AND isArchived = 0
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export const SESSIONS_TABLE_SCHEMA_SQL = `
|
|||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
session_id TEXT NOT NULL,
|
session_id TEXT NOT NULL,
|
||||||
provider TEXT NOT NULL DEFAULT 'claude',
|
provider TEXT NOT NULL DEFAULT 'claude',
|
||||||
|
-- The session id used by the provider CLI/SDK on disk (JSONL file name,
|
||||||
|
-- store.db folder, sqlite row id, ...). \`session_id\` is the stable
|
||||||
|
-- app-facing id that the frontend uses for the whole session lifetime;
|
||||||
|
-- \`provider_session_id\` is filled in once the provider announces its own
|
||||||
|
-- id mid-run, or equals \`session_id\` for sessions discovered on disk.
|
||||||
|
provider_session_id TEXT,
|
||||||
custom_name TEXT,
|
custom_name TEXT,
|
||||||
project_path TEXT,
|
project_path TEXT,
|
||||||
jsonl_path TEXT,
|
jsonl_path TEXT,
|
||||||
|
|||||||
108
server/modules/database/tests/sessions-provider-mapping.test.ts
Normal file
108
server/modules/database/tests/sessions-provider-mapping.test.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { closeConnection } from '@/modules/database/connection.js';
|
||||||
|
import { initializeDatabase } from '@/modules/database/init-db.js';
|
||||||
|
import { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
|
||||||
|
|
||||||
|
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||||
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
|
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'sessions-mapping-'));
|
||||||
|
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||||
|
|
||||||
|
closeConnection();
|
||||||
|
process.env.DATABASE_PATH = databasePath;
|
||||||
|
await initializeDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runTest();
|
||||||
|
} finally {
|
||||||
|
closeConnection();
|
||||||
|
if (previousDatabasePath === undefined) {
|
||||||
|
delete process.env.DATABASE_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.DATABASE_PATH = previousDatabasePath;
|
||||||
|
}
|
||||||
|
await rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('disk-discovered sessions are keyed by the provider id for both columns', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createSession('provider-abc', 'claude', '/workspace/demo', 'From Disk');
|
||||||
|
|
||||||
|
const row = sessionsDb.getSessionById('provider-abc');
|
||||||
|
assert.equal(row?.session_id, 'provider-abc');
|
||||||
|
assert.equal(row?.provider_session_id, 'provider-abc');
|
||||||
|
|
||||||
|
const byProviderId = sessionsDb.getSessionByProviderSessionId('provider-abc');
|
||||||
|
assert.equal(byProviderId?.session_id, 'provider-abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app sessions get the provider id assigned without creating a duplicate row', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-id-1', 'claude', '/workspace/demo');
|
||||||
|
sessionsDb.assignProviderSessionId('app-id-1', 'provider-xyz');
|
||||||
|
|
||||||
|
// A later synchronizer pass that discovers the transcript on disk must
|
||||||
|
// update the app row in place instead of inserting a provider-keyed row.
|
||||||
|
const returnedId = sessionsDb.createSession(
|
||||||
|
'provider-xyz',
|
||||||
|
'claude',
|
||||||
|
'/workspace/demo',
|
||||||
|
'Synced Name',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'/fake/path/provider-xyz.jsonl',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(returnedId, 'app-id-1');
|
||||||
|
assert.equal(sessionsDb.getAllSessions().length, 1);
|
||||||
|
|
||||||
|
const row = sessionsDb.getSessionById('app-id-1');
|
||||||
|
assert.equal(row?.provider_session_id, 'provider-xyz');
|
||||||
|
assert.equal(row?.jsonl_path, '/fake/path/provider-xyz.jsonl');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assignProviderSessionId merges a watcher-created duplicate into the app row', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-id-2', 'codex', '/workspace/demo');
|
||||||
|
|
||||||
|
// Simulate the race: the filesystem watcher indexed the provider
|
||||||
|
// transcript before the runtime announced its session id to the gateway.
|
||||||
|
sessionsDb.createSession(
|
||||||
|
'provider-race',
|
||||||
|
'codex',
|
||||||
|
'/workspace/demo',
|
||||||
|
'Watcher Name',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'/fake/provider-race.jsonl',
|
||||||
|
);
|
||||||
|
assert.equal(sessionsDb.getAllSessions().length, 2);
|
||||||
|
|
||||||
|
sessionsDb.assignProviderSessionId('app-id-2', 'provider-race');
|
||||||
|
|
||||||
|
const rows = sessionsDb.getAllSessions();
|
||||||
|
assert.equal(rows.length, 1);
|
||||||
|
assert.equal(rows[0]?.session_id, 'app-id-2');
|
||||||
|
assert.equal(rows[0]?.provider_session_id, 'provider-race');
|
||||||
|
// Transcript path and name from the duplicate are adopted.
|
||||||
|
assert.equal(rows[0]?.jsonl_path, '/fake/provider-race.jsonl');
|
||||||
|
assert.equal(rows[0]?.custom_name, 'Watcher Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy provider-keyed rows stay resolvable through both lookups', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createSession('legacy-1', 'gemini', '/workspace/demo');
|
||||||
|
|
||||||
|
assert.equal(sessionsDb.getSessionById('legacy-1')?.provider, 'gemini');
|
||||||
|
assert.equal(sessionsDb.getSessionByProviderSessionId('legacy-1')?.session_id, 'legacy-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -189,10 +189,11 @@ function readProjectSessionsPageByPath(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast progress to all connected WebSocket clients
|
// Broadcast progress to all connected WebSocket clients.
|
||||||
|
// Uses the unified `kind` envelope like every other websocket frame.
|
||||||
function broadcastProgress(progress: ProgressUpdate) {
|
function broadcastProgress(progress: ProgressUpdate) {
|
||||||
const message = JSON.stringify({
|
const message = JSON.stringify({
|
||||||
type: 'loading_progress',
|
kind: 'loading_progress',
|
||||||
...progress,
|
...progress,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,10 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
// App-created sessions are keyed by an app id, so disk-discovered provider
|
||||||
|
// ids must be resolved through the provider-id mapping first.
|
||||||
|
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
|
||||||
|
?? sessionsDb.getSessionById(parsed.sessionId);
|
||||||
const existingSessionName = existingSession?.custom_name;
|
const existingSessionName = existingSession?.custom_name;
|
||||||
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
|
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import readline from 'node:readline';
|
|||||||
|
|
||||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||||
import { sessionsDb } from '@/modules/database/index.js';
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
|
|
||||||
const PROVIDER = 'claude';
|
const PROVIDER = 'claude';
|
||||||
@@ -103,10 +103,13 @@ async function parseAgentTools(filePath: string): Promise<AnyRecord[]> {
|
|||||||
|
|
||||||
async function getSessionMessages(
|
async function getSessionMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
providerSessionId: string,
|
||||||
limit: number | null,
|
limit: number | null,
|
||||||
offset: number,
|
offset: number,
|
||||||
): Promise<ClaudeHistoryMessagesResult> {
|
): Promise<ClaudeHistoryMessagesResult> {
|
||||||
try {
|
try {
|
||||||
|
// The DB row is keyed by the app-facing session id, while the JSONL rows
|
||||||
|
// on disk carry the provider-native id — both ids are needed here.
|
||||||
const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||||
|
|
||||||
if (!jsonLPath) {
|
if (!jsonLPath) {
|
||||||
@@ -133,7 +136,7 @@ async function getSessionMessages(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const entry = JSON.parse(line) as AnyRecord;
|
const entry = JSON.parse(line) as AnyRecord;
|
||||||
if (entry.sessionId === sessionId) {
|
if (entry.sessionId === providerSessionId) {
|
||||||
messages.push(entry);
|
messages.push(entry);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -553,12 +556,13 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
|||||||
options: FetchHistoryOptions = {},
|
options: FetchHistoryOptions = {},
|
||||||
): Promise<FetchHistoryResult> {
|
): Promise<FetchHistoryResult> {
|
||||||
const { limit = null, offset = 0 } = options;
|
const { limit = null, offset = 0 } = options;
|
||||||
|
const providerSessionId = options.providerSessionId ?? sessionId;
|
||||||
|
|
||||||
let result: ClaudeHistoryResult;
|
let result: ClaudeHistoryResult;
|
||||||
try {
|
try {
|
||||||
// Load full history first so `total` reflects frontend-normalized messages,
|
// Load full history first so `total` reflects frontend-normalized messages,
|
||||||
// not raw JSONL records.
|
// not raw JSONL records.
|
||||||
result = await getSessionMessages(sessionId, null, 0);
|
result = await getSessionMessages(sessionId, providerSessionId, null, 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
|
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
|
||||||
@@ -606,7 +610,6 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalNormalized = normalized.length;
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const msg of normalized) {
|
for (const msg of normalized) {
|
||||||
if (msg.kind !== 'tool_result') {
|
if (msg.kind !== 'tool_result') {
|
||||||
@@ -615,18 +618,10 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
const normalizedOffset = Math.max(0, offset);
|
const normalizedOffset = Math.max(0, offset);
|
||||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||||
const messages = normalizedLimit === null
|
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
|
||||||
? normalized
|
|
||||||
: normalized.slice(
|
|
||||||
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
|
|
||||||
Math.max(0, totalNormalized - normalizedOffset),
|
|
||||||
);
|
|
||||||
const hasMore = normalizedLimit === null
|
|
||||||
? false
|
|
||||||
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages: page,
|
||||||
total,
|
total,
|
||||||
hasMore,
|
hasMore,
|
||||||
offset: normalizedOffset,
|
offset: normalizedOffset,
|
||||||
|
|||||||
@@ -43,11 +43,12 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
|
||||||
|
?? sessionsDb.getSessionById(parsed.sessionId);
|
||||||
if (existingSession) {
|
if (existingSession) {
|
||||||
// If session name is untitled and we now have a name, update it
|
// If session name is untitled and we now have a name, update it
|
||||||
if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') {
|
if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') {
|
||||||
sessionsDb.updateSessionCustomName(parsed.sessionId, parsed.sessionName);
|
sessionsDb.updateSessionCustomName(existingSession.session_id, parsed.sessionName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +121,10 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
// App-created sessions are keyed by an app id, so disk-discovered provider
|
||||||
|
// ids must be resolved through the provider-id mapping first.
|
||||||
|
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
|
||||||
|
?? sessionsDb.getSessionById(parsed.sessionId);
|
||||||
const existingSessionName = existingSession?.custom_name;
|
const existingSessionName = existingSession?.custom_name;
|
||||||
if (existingSessionName && existingSessionName !== 'Untitled Codex Session') {
|
if (existingSessionName && existingSessionName !== 'Untitled Codex Session') {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import readline from 'node:readline';
|
|||||||
import { sessionsDb } from '@/modules/database/index.js';
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||||
|
|
||||||
const PROVIDER = 'codex';
|
const PROVIDER = 'codex';
|
||||||
|
|
||||||
@@ -552,7 +552,6 @@ export class CodexSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalNormalized = normalized.length;
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const msg of normalized) {
|
for (const msg of normalized) {
|
||||||
if (msg.kind !== 'tool_result') {
|
if (msg.kind !== 'tool_result') {
|
||||||
@@ -561,18 +560,10 @@ export class CodexSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
const normalizedOffset = Math.max(0, offset);
|
const normalizedOffset = Math.max(0, offset);
|
||||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||||
const messages = normalizedLimit === null
|
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
|
||||||
? normalized
|
|
||||||
: normalized.slice(
|
|
||||||
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
|
|
||||||
Math.max(0, totalNormalized - normalizedOffset),
|
|
||||||
);
|
|
||||||
const hasMore = normalizedLimit === null
|
|
||||||
? false
|
|
||||||
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages: page,
|
||||||
total,
|
total,
|
||||||
hasMore,
|
hasMore,
|
||||||
offset: normalizedOffset,
|
offset: normalizedOffset,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
generateMessageId,
|
generateMessageId,
|
||||||
readObjectRecord,
|
readObjectRecord,
|
||||||
sanitizeLeafDirectoryName,
|
sanitizeLeafDirectoryName,
|
||||||
|
sliceTailPage,
|
||||||
} from '@/shared/utils.js';
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
const PROVIDER = 'cursor';
|
const PROVIDER = 'cursor';
|
||||||
@@ -363,42 +364,32 @@ export class CursorSessionsProvider implements IProviderSessions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches and paginates Cursor session history from its project-scoped store.db.
|
* Fetches and paginates Cursor session history from its project-scoped store.db.
|
||||||
|
*
|
||||||
|
* Pagination follows the shared tail contract (`sliceTailPage`): offset 0 is
|
||||||
|
* the most recent page, matching every other provider.
|
||||||
*/
|
*/
|
||||||
async fetchHistory(
|
async fetchHistory(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
options: FetchHistoryOptions = {},
|
options: FetchHistoryOptions = {},
|
||||||
): Promise<FetchHistoryResult> {
|
): Promise<FetchHistoryResult> {
|
||||||
const { projectPath = '', limit = null, offset = 0 } = options;
|
const { projectPath = '', limit = null, offset = 0 } = options;
|
||||||
|
// The store.db folder on disk is named after the provider-native id, not
|
||||||
|
// the app-facing session id this method is addressed with.
|
||||||
|
const providerSessionId = options.providerSessionId ?? sessionId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
|
const blobs = await this.loadCursorBlobs(providerSessionId, projectPath);
|
||||||
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
|
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
|
||||||
const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result');
|
const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result');
|
||||||
const total = renderableMessages.length;
|
const total = renderableMessages.length;
|
||||||
|
const { page, hasMore } = sliceTailPage(renderableMessages, limit, offset);
|
||||||
if (limit !== null) {
|
|
||||||
const start = offset;
|
|
||||||
const page = limit === 0
|
|
||||||
? []
|
|
||||||
: renderableMessages.slice(start, start + limit);
|
|
||||||
const hasMore = limit === 0
|
|
||||||
? start < total
|
|
||||||
: start + limit < total;
|
|
||||||
return {
|
|
||||||
messages: page,
|
|
||||||
total,
|
|
||||||
hasMore,
|
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: renderableMessages,
|
messages: page,
|
||||||
total,
|
total,
|
||||||
hasMore: false,
|
hasMore,
|
||||||
offset: 0,
|
offset,
|
||||||
limit: null,
|
limit,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -12,17 +12,14 @@ import {
|
|||||||
|
|
||||||
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
|
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||||
OPTIONS: [
|
OPTIONS: [
|
||||||
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
|
|
||||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
|
|
||||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||||
|
{ value: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite Preview' },
|
||||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
|
||||||
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
|
{ value: 'gemma-4-31b-it', label: 'Gemma 4 31B IT' },
|
||||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
{ value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 26B A4B IT' },
|
||||||
{ value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
|
|
||||||
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' },
|
|
||||||
],
|
],
|
||||||
DEFAULT: 'gemini-3.1-pro-preview',
|
DEFAULT: 'gemini-3-flash-preview',
|
||||||
};
|
};
|
||||||
|
|
||||||
export class GeminiProviderModels implements IProviderModels {
|
export class GeminiProviderModels implements IProviderModels {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import readline from 'node:readline';
|
|||||||
import { sessionsDb } from '@/modules/database/index.js';
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||||
|
|
||||||
const PROVIDER = 'gemini';
|
const PROVIDER = 'gemini';
|
||||||
|
|
||||||
@@ -518,9 +518,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
|||||||
|
|
||||||
const start = Math.max(0, offset);
|
const start = Math.max(0, offset);
|
||||||
const pageLimit = limit === null ? null : Math.max(0, limit);
|
const pageLimit = limit === null ? null : Math.max(0, limit);
|
||||||
const messages = pageLimit === null
|
// Tail pagination via the shared contract: offset 0 returns the most
|
||||||
? normalized.slice(start)
|
// recent page, matching every other provider.
|
||||||
: normalized.slice(start, start + pageLimit);
|
const { page, hasMore } = sliceTailPage(normalized, pageLimit, start);
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const msg of normalized) {
|
for (const msg of normalized) {
|
||||||
if (msg.kind !== 'tool_result') {
|
if (msg.kind !== 'tool_result') {
|
||||||
@@ -529,9 +529,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages: page,
|
||||||
total,
|
total,
|
||||||
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
|
hasMore,
|
||||||
offset: start,
|
offset: start,
|
||||||
limit: pageLimit,
|
limit: pageLimit,
|
||||||
tokenUsage: result.tokenUsage,
|
tokenUsage: result.tokenUsage,
|
||||||
|
|||||||
@@ -112,7 +112,10 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fallbackTitle = 'Untitled OpenCode Session';
|
const fallbackTitle = 'Untitled OpenCode Session';
|
||||||
const existingSession = sessionsDb.getSessionById(sessionId);
|
// App-created sessions are keyed by an app id, so disk-discovered provider
|
||||||
|
// ids must be resolved through the provider-id mapping first.
|
||||||
|
const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId)
|
||||||
|
?? sessionsDb.getSessionById(sessionId);
|
||||||
const existingName = existingSession?.custom_name;
|
const existingName = existingSession?.custom_name;
|
||||||
const nextName = existingName && existingName !== fallbackTitle
|
const nextName = existingName && existingName !== fallbackTitle
|
||||||
? existingName
|
? existingName
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
readObjectRecord,
|
readObjectRecord,
|
||||||
readJsonRecord,
|
readJsonRecord,
|
||||||
readOptionalString,
|
readOptionalString,
|
||||||
|
sliceTailPage,
|
||||||
} from '@/shared/utils.js';
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
const PROVIDER = 'opencode';
|
const PROVIDER = 'opencode';
|
||||||
@@ -325,6 +326,9 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
|
|||||||
options: FetchHistoryOptions = {},
|
options: FetchHistoryOptions = {},
|
||||||
): Promise<FetchHistoryResult> {
|
): Promise<FetchHistoryResult> {
|
||||||
const { limit = null, offset = 0 } = options;
|
const { limit = null, offset = 0 } = options;
|
||||||
|
// OpenCode's shared sqlite database keys messages by the provider-native
|
||||||
|
// session id, not the app-facing id this method is addressed with.
|
||||||
|
const providerSessionId = options.providerSessionId ?? sessionId;
|
||||||
const db = openOpenCodeDatabase();
|
const db = openOpenCodeDatabase();
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||||
@@ -349,27 +353,20 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
|
|||||||
m.id,
|
m.id,
|
||||||
COALESCE(p.time_created, 0),
|
COALESCE(p.time_created, 0),
|
||||||
p.id
|
p.id
|
||||||
`).all(sessionId) as OpenCodeHistoryRow[];
|
`).all(providerSessionId) as OpenCodeHistoryRow[];
|
||||||
|
|
||||||
const normalized = this.normalizeHistoryRows(rows, sessionId);
|
const normalized = this.normalizeHistoryRows(rows, sessionId);
|
||||||
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId);
|
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, providerSessionId);
|
||||||
|
|
||||||
const normalizedOffset = Math.max(0, offset);
|
const normalizedOffset = Math.max(0, offset);
|
||||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||||
const total = normalized.length;
|
const total = normalized.length;
|
||||||
const messages = normalizedLimit === null
|
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
|
||||||
? normalized
|
|
||||||
: normalized.slice(
|
|
||||||
Math.max(0, total - normalizedOffset - normalizedLimit),
|
|
||||||
Math.max(0, total - normalizedOffset),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages: page,
|
||||||
total,
|
total,
|
||||||
hasMore: normalizedLimit === null
|
hasMore,
|
||||||
? false
|
|
||||||
: Math.max(0, total - normalizedOffset - normalizedLimit) > 0,
|
|
||||||
offset: normalizedOffset,
|
offset: normalizedOffset,
|
||||||
limit: normalizedLimit,
|
limit: normalizedLimit,
|
||||||
tokenUsage,
|
tokenUsage,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express, { type Request, type Response } from 'express';
|
import express, { type Request, type Response } from 'express';
|
||||||
|
|
||||||
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
||||||
|
import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js';
|
||||||
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
||||||
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
|
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
|
||||||
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
|
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
|
||||||
@@ -382,7 +383,51 @@ router.post(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/capabilities',
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
res.json(createApiSuccessResponse({
|
||||||
|
providers: providerCapabilitiesService.listAllProviderCapabilities(),
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:provider/capabilities',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const provider = parseProvider(req.params.provider);
|
||||||
|
res.json(createApiSuccessResponse(
|
||||||
|
providerCapabilitiesService.getProviderCapabilities(provider),
|
||||||
|
));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// ----------------- Session routes -----------------
|
// ----------------- Session routes -----------------
|
||||||
|
/**
|
||||||
|
* Session gateway entry point: allocates the stable app-facing session id for
|
||||||
|
* a brand-new chat. The frontend must call this before the first `chat.send`
|
||||||
|
* so the session id in the URL, the store, and the websocket all agree from
|
||||||
|
* the very first message — there is no client-visible session-id handoff.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/sessions',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
const provider = parseProvider(body.provider);
|
||||||
|
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : '';
|
||||||
|
const result = sessionsService.createAppSession(provider, projectPath);
|
||||||
|
res.status(201).json(createApiSuccessResponse(result));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/sessions/running',
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
const sessions = sessionsService.listRunningSessions();
|
||||||
|
res.json(createApiSuccessResponse({ sessions }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/sessions/archived',
|
'/sessions/archived',
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
@@ -459,7 +504,7 @@ router.get(
|
|||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
});
|
});
|
||||||
res.json(result);
|
res.json(createApiSuccessResponse(result));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { LLMProvider } from '@/shared/types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static, backend-owned description of what one provider integration supports.
|
||||||
|
*
|
||||||
|
* The frontend renders its composer UI (permission mode picker, image upload,
|
||||||
|
* abort button, ...) purely from this shape, which is what keeps the frontend
|
||||||
|
* free of per-provider conditionals. New provider features should be exposed
|
||||||
|
* here instead of branching on the provider id in React components.
|
||||||
|
*/
|
||||||
|
type ProviderCapabilities = {
|
||||||
|
provider: LLMProvider;
|
||||||
|
/** Permission modes the provider runtime understands, in cycle order. */
|
||||||
|
permissionModes: string[];
|
||||||
|
defaultPermissionMode: string;
|
||||||
|
/** Whether image attachments can be included in a chat.send. */
|
||||||
|
supportsImages: boolean;
|
||||||
|
/** Whether an in-flight run can be cancelled via chat.abort. */
|
||||||
|
supportsAbort: boolean;
|
||||||
|
/** Whether interactive tool permission prompts can reach the UI. */
|
||||||
|
supportsPermissionRequests: boolean;
|
||||||
|
/** Whether the token-usage endpoint has data for this provider. */
|
||||||
|
supportsTokenUsage: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The capability matrix mirrors what each runtime actually implements today:
|
||||||
|
* - permission modes match the option sets accepted by each CLI/SDK.
|
||||||
|
* - only the Claude SDK integration surfaces interactive permission requests.
|
||||||
|
* - Cursor has no token usage endpoint support (its store.db has no usage rows).
|
||||||
|
*/
|
||||||
|
const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||||
|
claude: {
|
||||||
|
provider: 'claude',
|
||||||
|
permissionModes: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||||
|
defaultPermissionMode: 'default',
|
||||||
|
supportsImages: true,
|
||||||
|
supportsAbort: true,
|
||||||
|
supportsPermissionRequests: true,
|
||||||
|
supportsTokenUsage: true,
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
provider: 'cursor',
|
||||||
|
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||||
|
defaultPermissionMode: 'default',
|
||||||
|
supportsImages: false,
|
||||||
|
supportsAbort: true,
|
||||||
|
supportsPermissionRequests: false,
|
||||||
|
supportsTokenUsage: false,
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
provider: 'codex',
|
||||||
|
permissionModes: ['default', 'acceptEdits', 'bypassPermissions'],
|
||||||
|
defaultPermissionMode: 'default',
|
||||||
|
supportsImages: false,
|
||||||
|
supportsAbort: true,
|
||||||
|
supportsPermissionRequests: false,
|
||||||
|
supportsTokenUsage: true,
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
provider: 'gemini',
|
||||||
|
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||||
|
defaultPermissionMode: 'default',
|
||||||
|
supportsImages: false,
|
||||||
|
supportsAbort: true,
|
||||||
|
supportsPermissionRequests: false,
|
||||||
|
supportsTokenUsage: true,
|
||||||
|
},
|
||||||
|
opencode: {
|
||||||
|
provider: 'opencode',
|
||||||
|
permissionModes: ['default'],
|
||||||
|
defaultPermissionMode: 'default',
|
||||||
|
supportsImages: false,
|
||||||
|
supportsAbort: true,
|
||||||
|
supportsPermissionRequests: false,
|
||||||
|
supportsTokenUsage: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application service exposing the provider capability matrix.
|
||||||
|
*/
|
||||||
|
export const providerCapabilitiesService = {
|
||||||
|
getProviderCapabilities(provider: LLMProvider): ProviderCapabilities {
|
||||||
|
return PROVIDER_CAPABILITIES[provider];
|
||||||
|
},
|
||||||
|
|
||||||
|
listAllProviderCapabilities(): ProviderCapabilities[] {
|
||||||
|
return Object.values(PROVIDER_CAPABILITIES);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -17,7 +17,7 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
|
|||||||
|
|
||||||
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
||||||
const PROVIDER_MODELS_CACHE_VERSION = 1;
|
const PROVIDER_MODELS_CACHE_VERSION = 1;
|
||||||
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
|
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']);
|
||||||
|
|
||||||
type ProviderModelsServiceDependencies = {
|
type ProviderModelsServiceDependencies = {
|
||||||
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { promises as fsPromises } from 'node:fs';
|
|||||||
|
|
||||||
import chokidar, { type FSWatcher } from 'chokidar';
|
import chokidar, { type FSWatcher } from 'chokidar';
|
||||||
|
|
||||||
|
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||||
import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js';
|
import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js';
|
||||||
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
||||||
import type { LLMProvider } from '@/shared/types.js';
|
import type { LLMProvider } from '@/shared/types.js';
|
||||||
import { getProjectsWithSessions } from '@/modules/projects/index.js';
|
import { generateDisplayName } from '@/modules/projects/index.js';
|
||||||
|
|
||||||
type WatcherEventType = 'add' | 'change';
|
type WatcherEventType = 'add' | 'change';
|
||||||
|
|
||||||
@@ -58,6 +59,11 @@ const watchers: FSWatcher[] = [];
|
|||||||
type PendingWatcherUpdate = {
|
type PendingWatcherUpdate = {
|
||||||
providers: Set<LLMProvider>;
|
providers: Set<LLMProvider>;
|
||||||
changeTypes: Set<WatcherEventType>;
|
changeTypes: Set<WatcherEventType>;
|
||||||
|
/**
|
||||||
|
* Provider-native session ids reported by the synchronizers. They are
|
||||||
|
* translated back to app-facing session rows at flush time, because the
|
||||||
|
* transcript file names on disk only ever contain provider ids.
|
||||||
|
*/
|
||||||
updatedSessionIds: Set<string>;
|
updatedSessionIds: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,6 +137,50 @@ function queuePendingWatcherUpdate(
|
|||||||
schedulePendingWatcherFlush();
|
schedulePendingWatcherFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds one `session_upserted` delta event for a provider-native session id.
|
||||||
|
*
|
||||||
|
* The event carries everything a sidebar needs to upsert the session in place
|
||||||
|
* (session summary plus owning-project metadata), so clients never need a full
|
||||||
|
* project-list refetch when a transcript file changes on disk. Returns `null`
|
||||||
|
* when the id cannot be resolved to an indexed session row.
|
||||||
|
*/
|
||||||
|
async function buildSessionUpsertedEvent(updatedProviderSessionId: string): Promise<string | null> {
|
||||||
|
const row = sessionsDb.getSessionByProviderSessionId(updatedProviderSessionId)
|
||||||
|
?? sessionsDb.getSessionById(updatedProviderSessionId);
|
||||||
|
if (!row || row.isArchived) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectPath = row.project_path;
|
||||||
|
const project = projectPath ? projectsDb.getProjectPath(projectPath) : null;
|
||||||
|
const displayName = project?.custom_project_name?.trim()
|
||||||
|
? project.custom_project_name
|
||||||
|
: await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath);
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
kind: 'session_upserted',
|
||||||
|
sessionId: row.session_id,
|
||||||
|
provider: row.provider,
|
||||||
|
session: {
|
||||||
|
id: row.session_id,
|
||||||
|
summary: row.custom_name || '',
|
||||||
|
messageCount: 0,
|
||||||
|
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
||||||
|
},
|
||||||
|
project: project
|
||||||
|
? {
|
||||||
|
projectId: project.project_id,
|
||||||
|
path: project.project_path,
|
||||||
|
fullPath: project.project_path,
|
||||||
|
displayName,
|
||||||
|
isStarred: Boolean(project.isStarred),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function flushPendingWatcherUpdate(): Promise<void> {
|
async function flushPendingWatcherUpdate(): Promise<void> {
|
||||||
clearPendingWatcherFlushTimer();
|
clearPendingWatcherFlushTimer();
|
||||||
|
|
||||||
@@ -149,33 +199,29 @@ async function flushPendingWatcherUpdate(): Promise<void> {
|
|||||||
watcherRefreshInFlight = true;
|
watcherRefreshInFlight = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedProjects = await getProjectsWithSessions({ skipSynchronization: true });
|
// Per-session deltas instead of full project snapshots: an upsert of one
|
||||||
const changeTypes = Array.from(queuedUpdate.changeTypes);
|
// session can never clobber unrelated client state, so the frontend needs
|
||||||
const watchProviders = Array.from(queuedUpdate.providers);
|
// no "suppress updates while a run is active" protection logic.
|
||||||
const updatedSessionIds = Array.from(queuedUpdate.updatedSessionIds);
|
const events: string[] = [];
|
||||||
|
for (const updatedSessionId of queuedUpdate.updatedSessionIds) {
|
||||||
// Backward-compatible fields stay populated with the first queued values.
|
const event = await buildSessionUpsertedEvent(updatedSessionId);
|
||||||
const updateMessage = JSON.stringify({
|
if (event) {
|
||||||
type: 'projects_updated',
|
events.push(event);
|
||||||
projects: updatedProjects,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
changeType: changeTypes[0] ?? 'change',
|
|
||||||
updatedSessionId: updatedSessionIds[0] ?? undefined,
|
|
||||||
watchProvider: watchProviders[0] ?? undefined,
|
|
||||||
changeTypes,
|
|
||||||
updatedSessionIds,
|
|
||||||
watchProviders,
|
|
||||||
batched: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
connectedClients.forEach(client => {
|
|
||||||
if (client.readyState === WS_OPEN_STATE) {
|
|
||||||
client.send(updateMessage);
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
connectedClients.forEach(client => {
|
||||||
|
if (client.readyState === WS_OPEN_STATE) {
|
||||||
|
for (const event of events) {
|
||||||
|
client.send(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.error('Session watcher refresh failed while broadcasting projects_updated', { error: message });
|
console.error('Session watcher refresh failed while broadcasting session_upserted', { error: message });
|
||||||
} finally {
|
} finally {
|
||||||
watcherRefreshInFlight = false;
|
watcherRefreshInFlight = false;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import fsp from 'node:fs/promises';
|
import fsp from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import { chatRunRegistry } from '@/modules/websocket/index.js';
|
||||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||||
import type {
|
import type {
|
||||||
FetchHistoryOptions,
|
FetchHistoryOptions,
|
||||||
@@ -11,6 +13,12 @@ import type {
|
|||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
import { AppError } from '@/shared/utils.js';
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type CreateAppSessionResult = {
|
||||||
|
sessionId: string;
|
||||||
|
provider: LLMProvider;
|
||||||
|
projectPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ArchivedSessionListItem = {
|
type ArchivedSessionListItem = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
provider: LLMProvider;
|
provider: LLMProvider;
|
||||||
@@ -77,6 +85,21 @@ export const sessionsService = {
|
|||||||
return providerRegistry.listProviders().map((provider) => provider.id);
|
return providerRegistry.listProviders().map((provider) => provider.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns app-facing ids for provider runs that are currently processing.
|
||||||
|
*
|
||||||
|
* This is intentionally status-only: callers that only need sidebar activity
|
||||||
|
* indicators should not attach to chat streams or request replayed messages.
|
||||||
|
*/
|
||||||
|
listRunningSessions(): Array<{
|
||||||
|
sessionId: string;
|
||||||
|
provider: LLMProvider;
|
||||||
|
startedAt: number;
|
||||||
|
lastSeq: number;
|
||||||
|
}> {
|
||||||
|
return chatRunRegistry.listRunningRuns();
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes one provider-native event into frontend session message events.
|
* Normalizes one provider-native event into frontend session message events.
|
||||||
*/
|
*/
|
||||||
@@ -89,12 +112,43 @@ export const sessionsService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches persisted history by session id.
|
* Allocates a stable app-facing session id before any provider run happens.
|
||||||
|
*
|
||||||
|
* This is the entry point of the session gateway: the frontend calls this
|
||||||
|
* (via `POST /api/providers/sessions`) when the user starts a brand-new
|
||||||
|
* chat, navigates to the returned id immediately, and the id never changes
|
||||||
|
* for the lifetime of the conversation. The provider-native id is mapped to
|
||||||
|
* this row later, when the provider runtime announces it mid-run.
|
||||||
|
*/
|
||||||
|
createAppSession(provider: LLMProvider, projectPath: string): CreateAppSessionResult {
|
||||||
|
const normalizedProjectPath = projectPath.trim();
|
||||||
|
if (!normalizedProjectPath) {
|
||||||
|
throw new AppError('projectPath is required.', {
|
||||||
|
code: 'PROJECT_PATH_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
sessionsDb.createAppSession(sessionId, provider, normalizedProjectPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
provider,
|
||||||
|
projectPath: normalizedProjectPath,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches persisted history by app session id.
|
||||||
*
|
*
|
||||||
* Provider and provider-specific lookup hints are resolved from the indexed
|
* Provider and provider-specific lookup hints are resolved from the indexed
|
||||||
* session metadata in the database.
|
* session metadata in the database. The provider adapter receives the
|
||||||
|
* provider-native session id (the one written into transcripts on disk),
|
||||||
|
* and every returned message is remapped back to the app session id so
|
||||||
|
* provider ids never reach the frontend.
|
||||||
*/
|
*/
|
||||||
fetchHistory(
|
async fetchHistory(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
options: Pick<FetchHistoryOptions, 'limit' | 'offset'> = {},
|
options: Pick<FetchHistoryOptions, 'limit' | 'offset'> = {},
|
||||||
): Promise<FetchHistoryResult> {
|
): Promise<FetchHistoryResult> {
|
||||||
@@ -106,12 +160,33 @@ export const sessionsService = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App-created sessions that never produced a provider transcript yet
|
||||||
|
// (e.g. first message still streaming) simply have no history.
|
||||||
|
if (!session.provider_session_id) {
|
||||||
|
return {
|
||||||
|
messages: [],
|
||||||
|
total: 0,
|
||||||
|
hasMore: false,
|
||||||
|
offset: options.offset ?? 0,
|
||||||
|
limit: options.limit ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const provider = session.provider as LLMProvider;
|
const provider = session.provider as LLMProvider;
|
||||||
return providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
|
const result = await providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
|
||||||
limit: options.limit ?? null,
|
limit: options.limit ?? null,
|
||||||
offset: options.offset ?? 0,
|
offset: options.offset ?? 0,
|
||||||
projectPath: session.project_path ?? '',
|
projectPath: session.project_path ?? '',
|
||||||
|
providerSessionId: session.provider_session_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
messages: result.messages.map((message) => ({
|
||||||
|
...message,
|
||||||
|
sessionId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ Benefits:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `services/websocket-server.service.ts` | Creates `WebSocketServer`, binds `verifyClient`, routes connection by pathname |
|
| `services/websocket-server.service.ts` | Creates `WebSocketServer`, binds `verifyClient`, routes connection by pathname |
|
||||||
| `services/websocket-auth.service.ts` | Authenticates upgrade requests and attaches `request.user` |
|
| `services/websocket-auth.service.ts` | Authenticates upgrade requests and attaches `request.user` |
|
||||||
| `services/chat-websocket.service.ts` | Handles `/ws` chat protocol and provider command/session control messages |
|
| `services/chat-websocket.service.ts` | Handles the `/ws` chat protocol (`chat.send` / `chat.abort` / `chat.subscribe` / `chat.permission-response`) |
|
||||||
|
| `services/chat-run-registry.service.ts` | Tracks live provider runs per app session id: seq numbering, event replay buffer, provider-id mapping, completion state |
|
||||||
|
| `services/chat-session-writer.service.ts` | Gateway writer handed to provider runtimes: remaps provider session ids to app ids, swallows `session_created`, assigns `seq` |
|
||||||
| `services/shell-websocket.service.ts` | Handles `/shell` PTY lifecycle, reconnect buffering, auth URL detection |
|
| `services/shell-websocket.service.ts` | Handles `/shell` PTY lifecycle, reconnect buffering, auth URL detection |
|
||||||
| `services/plugin-websocket-proxy.service.ts` | Bridges client socket to plugin socket |
|
| `services/plugin-websocket-proxy.service.ts` | Bridges client socket to plugin socket |
|
||||||
| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) |
|
| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) for non-chat writer consumers |
|
||||||
| `services/websocket-state.service.ts` | Holds shared chat client set and open-state constant |
|
| `services/websocket-state.service.ts` | Holds shared chat client set and open-state constant |
|
||||||
|
|
||||||
## High-Level Architecture
|
## High-Level Architecture
|
||||||
@@ -52,12 +54,12 @@ flowchart LR
|
|||||||
D -->|other| H[close()]
|
D -->|other| H[close()]
|
||||||
|
|
||||||
E --> I[connectedClients Set]
|
E --> I[connectedClients Set]
|
||||||
E --> J[WebSocketWriter]
|
E --> J[chatRunRegistry + ChatSessionWriter]
|
||||||
F --> K[ptySessionsMap]
|
F --> K[ptySessionsMap]
|
||||||
G --> L[Upstream Plugin ws://127.0.0.1:port/ws]
|
G --> L[Upstream Plugin ws://127.0.0.1:port/ws]
|
||||||
|
|
||||||
I --> M[projects.service broadcastProgress]
|
I --> M[projects.service loading_progress]
|
||||||
I --> N[sessions-watcher.service projects_updated]
|
I --> N[sessions-watcher.service session_upserted]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Connection Handshake + Routing
|
## Connection Handshake + Routing
|
||||||
@@ -105,37 +107,41 @@ sequenceDiagram
|
|||||||
When a chat socket connects:
|
When a chat socket connects:
|
||||||
|
|
||||||
1. Add socket to `connectedClients`.
|
1. Add socket to `connectedClients`.
|
||||||
2. Build `WebSocketWriter` (captures `userId` from authenticated request).
|
2. Parse each incoming message with `parseIncomingJsonObject`.
|
||||||
3. Parse each incoming message with `parseIncomingJsonObject`.
|
3. Dispatch by `data.type` (four message types, none provider-specific).
|
||||||
4. Dispatch by `data.type`.
|
4. On close, remove socket from `connectedClients`.
|
||||||
5. On close, remove socket from `connectedClients`.
|
|
||||||
|
### Session identity model
|
||||||
|
|
||||||
|
The frontend only ever knows the **app session id** (allocated by
|
||||||
|
`POST /api/providers/sessions` or discovered via the session index). The
|
||||||
|
provider-native id (JSONL file name, CLI resume id) stays inside the backend:
|
||||||
|
|
||||||
|
1. `chat.send` resolves the app id to `{ provider, provider_session_id, project_path }` from the sessions DB.
|
||||||
|
2. The provider runtime receives the provider-native id for resume.
|
||||||
|
3. The `ChatSessionWriter` remaps every outbound event back to the app id, and turns `session_created` announcements into a DB mapping update instead of forwarding them.
|
||||||
|
|
||||||
### Chat Message Dispatch
|
### Chat Message Dispatch
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A[Incoming WS message] --> B[parseIncomingJsonObject]
|
A[Incoming WS message] --> B[parseIncomingJsonObject]
|
||||||
B -->|invalid| C[send {type:error}]
|
B -->|invalid| C[send kind:protocol_error]
|
||||||
B -->|ok| D{data.type}
|
B -->|ok| D{data.type}
|
||||||
|
|
||||||
D -->|claude-command| E[queryClaudeSDK]
|
D -->|chat.send| E[resolve session row -> startRun -> spawnFns provider]
|
||||||
D -->|cursor-command| F[spawnCursor]
|
D -->|chat.abort| F[abortFns provider + synthetic complete]
|
||||||
D -->|codex-command| G[queryCodex]
|
D -->|chat.subscribe| G[chat_subscribed ack + attach socket + replay events seq > lastSeq]
|
||||||
D -->|gemini-command| H[spawnGemini]
|
D -->|chat.permission-response| H[resolveToolApproval]
|
||||||
D -->|cursor-resume| I[spawnCursor resume]
|
D -->|other| I[send kind:protocol_error]
|
||||||
D -->|abort-session| J[abort by provider]
|
|
||||||
D -->|claude-permission-response| K[resolveToolApproval]
|
|
||||||
D -->|cursor-abort| L[abortCursorSession]
|
|
||||||
D -->|check-session-status| M[is*SessionActive + optional reconnectSessionWriter]
|
|
||||||
D -->|get-pending-permissions| N[getPendingApprovalsForSession]
|
|
||||||
D -->|get-active-sessions| O[getActive*Sessions]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Chat Notes
|
### Chat Notes
|
||||||
|
|
||||||
1. `abort-session` returns a normalized `complete` message with `aborted: true`.
|
1. **Unified envelope**: every server-to-client frame carries a `kind` — either a provider `NormalizedMessage` kind or a gateway kind (`chat_subscribed`, `session_upserted`, `loading_progress`, `protocol_error`). There is no second `type`-based protocol.
|
||||||
2. `check-session-status` returns `{ type: "session-status", isProcessing }`.
|
2. **Unified terminal lifecycle**: every provider run ends with exactly one `complete` message built by `createCompleteMessage()` (`server/shared/utils.ts`): `{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }`. The chat handler emits a synthetic `complete` for runs that crash or get aborted, and the run registry drops duplicate completes.
|
||||||
3. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
|
3. **Per-run event log**: every live event gets a monotonically increasing `seq`. `chat.subscribe { sessions: [{ sessionId, lastSeq }] }` re-attaches the live stream to the requesting socket (any provider, not just Claude) and replays events with `seq > lastSeq`. If the buffer no longer covers `lastSeq`, the client refreshes over REST.
|
||||||
|
4. `chat_subscribed` includes `isProcessing` (replaces `check-session-status`) and `pendingPermissions` (replaces `get-pending-permissions`).
|
||||||
|
|
||||||
## `/shell` Terminal Flow
|
## `/shell` Terminal Flow
|
||||||
|
|
||||||
@@ -223,9 +229,9 @@ Only chat sockets (`/ws`) are tracked in `connectedClients`.
|
|||||||
That shared set is consumed by:
|
That shared set is consumed by:
|
||||||
|
|
||||||
1. `modules/projects/services/projects-with-sessions-fetch.service.ts`
|
1. `modules/projects/services/projects-with-sessions-fetch.service.ts`
|
||||||
Broadcasts `loading_progress` while project snapshots are being built.
|
Broadcasts `kind: loading_progress` while project snapshots are being built.
|
||||||
2. `modules/providers/services/sessions-watcher.service.ts`
|
2. `modules/providers/services/sessions-watcher.service.ts`
|
||||||
Broadcasts `projects_updated` when provider session artifacts change.
|
Broadcasts per-session `kind: session_upserted` deltas when provider session artifacts change (no full project snapshots).
|
||||||
|
|
||||||
This design centralizes cross-module realtime fanout without requiring route-local references to WebSocket internals.
|
This design centralizes cross-module realtime fanout without requiring route-local references to WebSocket internals.
|
||||||
|
|
||||||
@@ -252,7 +258,7 @@ Current explicit close codes in this module:
|
|||||||
|
|
||||||
Other errors:
|
Other errors:
|
||||||
|
|
||||||
1. Chat handler catches and emits `{ type: "error", error }`.
|
1. Chat handler catches and emits `{ kind: "protocol_error", code, error }`.
|
||||||
2. Shell handler catches and writes terminal-visible error output.
|
2. Shell handler catches and writes terminal-visible error output.
|
||||||
3. Unknown websocket paths are closed immediately.
|
3. Unknown websocket paths are closed immediately.
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { WS_OPEN_STATE, connectedClients } from './services/websocket-state.service.js';
|
export { WS_OPEN_STATE, connectedClients } from './services/websocket-state.service.js';
|
||||||
export { createWebSocketServer } from './services/websocket-server.service.js';
|
export { createWebSocketServer } from './services/websocket-server.service.js';
|
||||||
|
export { chatRunRegistry } from './services/chat-run-registry.service.js';
|
||||||
|
|||||||
273
server/modules/websocket/services/chat-run-registry.service.ts
Normal file
273
server/modules/websocket/services/chat-run-registry.service.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import { ChatSessionWriter } from '@/modules/websocket/services/chat-session-writer.service.js';
|
||||||
|
import type {
|
||||||
|
LLMProvider,
|
||||||
|
NormalizedMessage,
|
||||||
|
RealtimeClientConnection,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
|
||||||
|
type ChatRunStatus = 'running' | 'completed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One live (or recently finished) provider run for a single app session.
|
||||||
|
*
|
||||||
|
* State notes — why each mutable field is essential:
|
||||||
|
* - `providerSessionId`: the provider-native id captured mid-run. The abort
|
||||||
|
* handler needs it to address the provider runtime, and the DB mapping is
|
||||||
|
* written from it so history/resume work after the run.
|
||||||
|
* - `status`: drives `chat_subscribed.isProcessing`, prevents double sends
|
||||||
|
* into the same session, and guards the synthetic-complete fallback in the
|
||||||
|
* chat handler (only emitted when a runtime died without completing).
|
||||||
|
* - `lastSeq` / `events`: the per-run event log. Every live event gets a
|
||||||
|
* monotonically increasing `seq` and is buffered so a reconnecting client
|
||||||
|
* can replay exactly the events it missed via `chat.subscribe`.
|
||||||
|
*/
|
||||||
|
type ChatRun = {
|
||||||
|
appSessionId: string;
|
||||||
|
provider: LLMProvider;
|
||||||
|
providerSessionId: string | null;
|
||||||
|
status: ChatRunStatus;
|
||||||
|
lastSeq: number;
|
||||||
|
events: NormalizedMessage[];
|
||||||
|
writer: ChatSessionWriter;
|
||||||
|
startedAt: number;
|
||||||
|
completedAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a completed run stays available for replay. Covers the window
|
||||||
|
* between a run finishing and the client refreshing history over REST (for
|
||||||
|
* example when the browser tab was asleep while the run completed).
|
||||||
|
*/
|
||||||
|
const COMPLETED_RUN_RETENTION_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on buffered events per run so a very long tool-heavy run cannot
|
||||||
|
* grow memory unbounded. When exceeded, the oldest events are dropped —
|
||||||
|
* a reconnecting client whose `lastSeq` predates the buffer falls back to a
|
||||||
|
* REST history refresh, which is always the authoritative source.
|
||||||
|
*/
|
||||||
|
const MAX_BUFFERED_EVENTS_PER_RUN = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active and recently-completed runs keyed by app session id.
|
||||||
|
*
|
||||||
|
* This map is the single in-memory source of truth for "is something running
|
||||||
|
* for this session" — the chat websocket handler, abort path, and subscribe
|
||||||
|
* path all consult it instead of asking each provider runtime individually.
|
||||||
|
*/
|
||||||
|
const runs = new Map<string, ChatRun>();
|
||||||
|
|
||||||
|
function evictRunLater(appSessionId: string): void {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const run = runs.get(appSessionId);
|
||||||
|
if (run && run.status === 'completed') {
|
||||||
|
runs.delete(appSessionId);
|
||||||
|
}
|
||||||
|
}, COMPLETED_RUN_RETENTION_MS);
|
||||||
|
|
||||||
|
// Never keep the process alive just to evict a buffered run.
|
||||||
|
timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorates one outbound live event for a run and records it in the event log.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* 1. Remap `sessionId` (and `actualSessionId` on `complete`) to the stable
|
||||||
|
* app session id — provider-native ids never leave the backend.
|
||||||
|
* 2. Assign the next `seq` so clients can detect/replay gaps.
|
||||||
|
* 3. Buffer the event for `chat.subscribe` replay.
|
||||||
|
* 4. Flip the run to `completed` when the terminal `complete` event passes by.
|
||||||
|
*/
|
||||||
|
function decorateAndRecordEvent(run: ChatRun, message: NormalizedMessage): NormalizedMessage | null {
|
||||||
|
// Exactly-one-complete contract: when a run is aborted the chat handler
|
||||||
|
// emits the terminal `complete` immediately, but the killed runtime may
|
||||||
|
// still emit its own `complete` from its exit handler moments later.
|
||||||
|
// Whichever arrives first wins; the duplicate is dropped here.
|
||||||
|
if (message.kind === 'complete' && run.status === 'completed') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
run.lastSeq += 1;
|
||||||
|
|
||||||
|
const outbound: NormalizedMessage = {
|
||||||
|
...message,
|
||||||
|
sessionId: run.appSessionId,
|
||||||
|
seq: run.lastSeq,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (message.kind === 'complete') {
|
||||||
|
// The provider may report its own id here; the frontend only ever knows
|
||||||
|
// the app id, so the "actual" id is by definition the app id as well.
|
||||||
|
outbound.actualSessionId = run.appSessionId;
|
||||||
|
run.status = 'completed';
|
||||||
|
run.completedAt = Date.now();
|
||||||
|
evictRunLater(run.appSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
run.events.push(outbound);
|
||||||
|
if (run.events.length > MAX_BUFFERED_EVENTS_PER_RUN) {
|
||||||
|
run.events.splice(0, run.events.length - MAX_BUFFERED_EVENTS_PER_RUN);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outbound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the provider-native session id for a run and persists the
|
||||||
|
* app-id-to-provider-id mapping so history fetches and future resumes can
|
||||||
|
* address the provider transcript.
|
||||||
|
*
|
||||||
|
* Called from the gateway writer when the runtime either calls
|
||||||
|
* `setSessionId(...)` or emits its `session_created` event — whichever
|
||||||
|
* happens first wins; later calls with the same id are no-ops.
|
||||||
|
*/
|
||||||
|
function recordProviderSessionId(run: ChatRun, providerSessionId: string): void {
|
||||||
|
if (!providerSessionId || run.providerSessionId === providerSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
run.providerSessionId = providerSessionId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionsDb.assignProviderSessionId(run.appSessionId, providerSessionId);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[ChatRunRegistry] Failed to persist provider session id mapping', {
|
||||||
|
appSessionId: run.appSessionId,
|
||||||
|
providerSessionId,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of live provider runs keyed by the stable app session id.
|
||||||
|
*
|
||||||
|
* The registry is what makes the websocket protocol provider-independent:
|
||||||
|
* every run gets a `ChatSessionWriter` that remaps provider-native session
|
||||||
|
* ids to the app id, assigns `seq` numbers, and buffers events for replay —
|
||||||
|
* regardless of which provider runtime produced them.
|
||||||
|
*/
|
||||||
|
export const chatRunRegistry = {
|
||||||
|
/**
|
||||||
|
* Starts tracking a run and returns it, or `null` when a run is already in
|
||||||
|
* progress for the session (callers must reject the duplicate send).
|
||||||
|
*/
|
||||||
|
startRun(input: {
|
||||||
|
appSessionId: string;
|
||||||
|
provider: LLMProvider;
|
||||||
|
providerSessionId: string | null;
|
||||||
|
connection: RealtimeClientConnection;
|
||||||
|
userId: string | number | null;
|
||||||
|
}): ChatRun | null {
|
||||||
|
const existing = runs.get(input.appSessionId);
|
||||||
|
if (existing && existing.status === 'running') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run: ChatRun = {
|
||||||
|
appSessionId: input.appSessionId,
|
||||||
|
provider: input.provider,
|
||||||
|
providerSessionId: input.providerSessionId,
|
||||||
|
status: 'running',
|
||||||
|
lastSeq: 0,
|
||||||
|
events: [],
|
||||||
|
writer: null as unknown as ChatSessionWriter,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
run.writer = new ChatSessionWriter({
|
||||||
|
connection: input.connection,
|
||||||
|
userId: input.userId,
|
||||||
|
provider: input.provider,
|
||||||
|
providerSessionId: input.providerSessionId,
|
||||||
|
onProviderSessionId: (providerSessionId) => {
|
||||||
|
recordProviderSessionId(run, providerSessionId);
|
||||||
|
},
|
||||||
|
decorateOutboundEvent: (message) => decorateAndRecordEvent(run, message),
|
||||||
|
});
|
||||||
|
|
||||||
|
runs.set(input.appSessionId, run);
|
||||||
|
return run;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRun(appSessionId: string): ChatRun | undefined {
|
||||||
|
return runs.get(appSessionId);
|
||||||
|
},
|
||||||
|
|
||||||
|
isProcessing(appSessionId: string): boolean {
|
||||||
|
return runs.get(appSessionId)?.status === 'running';
|
||||||
|
},
|
||||||
|
|
||||||
|
listRunningRuns(): Array<{
|
||||||
|
sessionId: string;
|
||||||
|
provider: LLMProvider;
|
||||||
|
startedAt: number;
|
||||||
|
lastSeq: number;
|
||||||
|
}> {
|
||||||
|
return Array.from(runs.values())
|
||||||
|
.filter((run) => run.status === 'running')
|
||||||
|
.map((run) => ({
|
||||||
|
sessionId: run.appSessionId,
|
||||||
|
provider: run.provider,
|
||||||
|
startedAt: run.startedAt,
|
||||||
|
lastSeq: run.lastSeq,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-attaches a run's outbound stream to a (new) websocket connection.
|
||||||
|
*
|
||||||
|
* This is the generic replacement for the Claude-only writer reconnect:
|
||||||
|
* after a page refresh the new socket subscribes and immediately starts
|
||||||
|
* receiving the still-running stream, for every provider.
|
||||||
|
*/
|
||||||
|
attachConnection(appSessionId: string, connection: RealtimeClientConnection): boolean {
|
||||||
|
const run = runs.get(appSessionId);
|
||||||
|
if (!run) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
run.writer.updateWebSocket(connection);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns buffered events with `seq` greater than `afterSeq` for replay.
|
||||||
|
*
|
||||||
|
* An empty array with `run.lastSeq > afterSeq` not covered by the buffer
|
||||||
|
* means the buffer was truncated; the client should refresh over REST.
|
||||||
|
*/
|
||||||
|
replayEvents(appSessionId: string, afterSeq: number): NormalizedMessage[] {
|
||||||
|
const run = runs.get(appSessionId);
|
||||||
|
if (!run) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return run.events.filter((event) => typeof event.seq === 'number' && event.seq > afterSeq);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits a synthetic terminal `complete` if (and only if) the run is still
|
||||||
|
* marked running. Used when a provider runtime throws or resolves without
|
||||||
|
* having produced its own terminal event, and by the abort path.
|
||||||
|
*/
|
||||||
|
completeRun(appSessionId: string, opts: { exitCode: number; aborted?: boolean }): void {
|
||||||
|
const run = runs.get(appSessionId);
|
||||||
|
if (!run || run.status !== 'running') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
run.writer.sendComplete(opts);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only escape hatch: clears every tracked run.
|
||||||
|
*/
|
||||||
|
clearAll(): void {
|
||||||
|
runs.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
145
server/modules/websocket/services/chat-session-writer.service.ts
Normal file
145
server/modules/websocket/services/chat-session-writer.service.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
|
||||||
|
import type {
|
||||||
|
LLMProvider,
|
||||||
|
NormalizedMessage,
|
||||||
|
RealtimeClientConnection,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
import { createCompleteMessage, readObjectRecord } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type ChatSessionWriterOptions = {
|
||||||
|
connection: RealtimeClientConnection;
|
||||||
|
userId: string | number | null;
|
||||||
|
provider: LLMProvider;
|
||||||
|
/** Provider-native id when resuming an existing session, otherwise null. */
|
||||||
|
providerSessionId: string | null;
|
||||||
|
/**
|
||||||
|
* Invoked the moment the provider runtime reveals its native session id
|
||||||
|
* (either via `setSessionId` or a `session_created` event). The registry
|
||||||
|
* persists the app-id-to-provider-id mapping from this callback.
|
||||||
|
*/
|
||||||
|
onProviderSessionId: (providerSessionId: string) => void;
|
||||||
|
/**
|
||||||
|
* Remaps/sequences/buffers one outbound live event. Implemented by the chat
|
||||||
|
* run registry; the writer never forwards a provider event untouched.
|
||||||
|
* Returns `null` when the event must be dropped (duplicate terminal
|
||||||
|
* `complete` after an abort already completed the run).
|
||||||
|
*/
|
||||||
|
decorateOutboundEvent: (message: NormalizedMessage) => NormalizedMessage | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway writer handed to provider runtimes instead of a raw websocket writer.
|
||||||
|
*
|
||||||
|
* It exposes the exact same surface as `WebSocketWriter` (`send`,
|
||||||
|
* `setSessionId`, `getSessionId`, `updateWebSocket`, `userId`,
|
||||||
|
* `isWebSocketWriter`) so the provider runtimes (`claude-sdk.js`,
|
||||||
|
* `cursor-cli.js`, ...) need zero changes — but everything that flows through
|
||||||
|
* it is translated from the provider's world into the app's protocol:
|
||||||
|
*
|
||||||
|
* - `session_created` events are swallowed and turned into a provider-id
|
||||||
|
* mapping; the frontend never learns provider-native ids.
|
||||||
|
* - every other event gets `sessionId` remapped to the app session id and a
|
||||||
|
* per-run `seq` assigned before being forwarded.
|
||||||
|
* - `setSessionId(...)` calls (used by runtimes to label captured ids) are
|
||||||
|
* intercepted and recorded as the provider-id mapping as well.
|
||||||
|
*/
|
||||||
|
export class ChatSessionWriter {
|
||||||
|
ws: RealtimeClientConnection;
|
||||||
|
userId: string | number | null;
|
||||||
|
/**
|
||||||
|
* Some runtimes feature-detect their writer with this flag; keep it so the
|
||||||
|
* gateway writer is a drop-in replacement for `WebSocketWriter`.
|
||||||
|
*/
|
||||||
|
isWebSocketWriter = true;
|
||||||
|
|
||||||
|
private readonly options: ChatSessionWriterOptions;
|
||||||
|
/**
|
||||||
|
* The provider-native session id as the runtime knows it. Kept locally
|
||||||
|
* (besides the registry) because runtimes read it back via `getSessionId()`
|
||||||
|
* to label their own outgoing events — those labels are remapped on send
|
||||||
|
* anyway, but the runtime-visible value must stay provider-native.
|
||||||
|
*/
|
||||||
|
private providerSessionId: string | null;
|
||||||
|
|
||||||
|
constructor(options: ChatSessionWriterOptions) {
|
||||||
|
this.options = options;
|
||||||
|
this.ws = options.connection;
|
||||||
|
this.userId = options.userId;
|
||||||
|
this.providerSessionId = options.providerSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: unknown): void {
|
||||||
|
const record = readObjectRecord(data);
|
||||||
|
if (!record || typeof record.kind !== 'string') {
|
||||||
|
// Provider runtimes only emit kind-based normalized messages. Anything
|
||||||
|
// else indicates a programming error; drop it rather than leaking an
|
||||||
|
// un-remapped payload to the client.
|
||||||
|
console.error('[ChatSessionWriter] Dropping non-normalized outbound payload', data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = record as NormalizedMessage;
|
||||||
|
|
||||||
|
if (message.kind === 'session_created') {
|
||||||
|
const announcedId =
|
||||||
|
typeof message.newSessionId === 'string' && message.newSessionId
|
||||||
|
? message.newSessionId
|
||||||
|
: message.sessionId;
|
||||||
|
if (announcedId) {
|
||||||
|
this.captureProviderSessionId(announcedId);
|
||||||
|
}
|
||||||
|
// Swallowed on purpose: the frontend already has the stable app session
|
||||||
|
// id, so there is no client-side handoff to perform anymore.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outbound = this.options.decorateOutboundEvent(message);
|
||||||
|
if (outbound) {
|
||||||
|
this.forward(outbound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the synthetic terminal `complete` for runs that ended without one
|
||||||
|
* (runtime crash before completing, or user abort).
|
||||||
|
*/
|
||||||
|
sendComplete(opts: { exitCode: number; aborted?: boolean }): void {
|
||||||
|
const message = createCompleteMessage({
|
||||||
|
provider: this.options.provider,
|
||||||
|
sessionId: this.providerSessionId,
|
||||||
|
exitCode: opts.exitCode,
|
||||||
|
aborted: opts.aborted,
|
||||||
|
});
|
||||||
|
const outbound = this.options.decorateOutboundEvent(message);
|
||||||
|
if (outbound) {
|
||||||
|
this.forward(outbound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWebSocket(newConnection: RealtimeClientConnection): void {
|
||||||
|
this.ws = newConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionId(sessionId: string): void {
|
||||||
|
this.captureProviderSessionId(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionId(): string | null {
|
||||||
|
return this.providerSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private captureProviderSessionId(providerSessionId: string): void {
|
||||||
|
if (!providerSessionId || this.providerSessionId === providerSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.providerSessionId = providerSessionId;
|
||||||
|
this.options.onProviderSessionId(providerSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private forward(message: NormalizedMessage): void {
|
||||||
|
if (this.ws.readyState === WS_OPEN_STATE) {
|
||||||
|
this.ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,35 @@
|
|||||||
import type { WebSocket } from 'ws';
|
import type { WebSocket } from 'ws';
|
||||||
|
|
||||||
import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js';
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
import { WebSocketWriter } from '@/modules/websocket/services/websocket-writer.service.js';
|
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
|
||||||
|
import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
|
||||||
import type {
|
import type {
|
||||||
AnyRecord,
|
AnyRecord,
|
||||||
AuthenticatedWebSocketRequest,
|
AuthenticatedWebSocketRequest,
|
||||||
LLMProvider,
|
LLMProvider,
|
||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js';
|
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
||||||
|
|
||||||
type ChatIncomingMessage = AnyRecord & {
|
/**
|
||||||
type?: string;
|
* One provider runtime entry point. All five runtimes share this signature,
|
||||||
command?: string;
|
* which lets the chat handler dispatch through a provider-keyed map instead
|
||||||
options?: AnyRecord;
|
* of provider-specific branches.
|
||||||
provider?: string;
|
*/
|
||||||
sessionId?: string;
|
type ProviderSpawnFn = (
|
||||||
requestId?: string;
|
command: string,
|
||||||
allow?: unknown;
|
options: AnyRecord,
|
||||||
updatedInput?: unknown;
|
writer: unknown
|
||||||
message?: unknown;
|
) => Promise<unknown>;
|
||||||
rememberEntry?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_PROVIDER: LLMProvider = 'claude';
|
|
||||||
|
|
||||||
type ChatWebSocketDependencies = {
|
type ChatWebSocketDependencies = {
|
||||||
queryClaudeSDK: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
/** Provider runtimes keyed by provider id. */
|
||||||
spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
spawnFns: Record<LLMProvider, ProviderSpawnFn>;
|
||||||
queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
/**
|
||||||
spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
* Abort functions keyed by provider id. They are addressed with the
|
||||||
spawnOpenCode: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
* provider-native session id (that is how runtimes key their process maps).
|
||||||
abortClaudeSDKSession: (sessionId: string) => Promise<boolean>;
|
* The Claude abort is async; the rest are sync — both shapes are accepted.
|
||||||
abortCursorSession: (sessionId: string) => boolean;
|
*/
|
||||||
abortCodexSession: (sessionId: string) => boolean;
|
abortFns: Record<LLMProvider, (providerSessionId: string) => boolean | Promise<boolean>>;
|
||||||
abortGeminiSession: (sessionId: string) => boolean;
|
|
||||||
abortOpenCodeSession: (sessionId: string) => boolean;
|
|
||||||
resolveToolApproval: (
|
resolveToolApproval: (
|
||||||
requestId: string,
|
requestId: string,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -44,31 +39,10 @@ type ChatWebSocketDependencies = {
|
|||||||
rememberEntry?: unknown;
|
rememberEntry?: unknown;
|
||||||
}
|
}
|
||||||
) => void;
|
) => void;
|
||||||
isClaudeSDKSessionActive: (sessionId: string) => boolean;
|
/** Claude-only today: pending tool approvals included in `chat_subscribed`. */
|
||||||
isCursorSessionActive: (sessionId: string) => boolean;
|
getPendingApprovalsForSession: (providerSessionId: string) => unknown[];
|
||||||
isCodexSessionActive: (sessionId: string) => boolean;
|
|
||||||
isGeminiSessionActive: (sessionId: string) => boolean;
|
|
||||||
isOpenCodeSessionActive: (sessionId: string) => boolean;
|
|
||||||
reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean;
|
|
||||||
getPendingApprovalsForSession: (sessionId: string) => unknown[];
|
|
||||||
getActiveClaudeSDKSessions: () => unknown;
|
|
||||||
getActiveCursorSessions: () => unknown;
|
|
||||||
getActiveCodexSessions: () => unknown;
|
|
||||||
getActiveGeminiSessions: () => unknown;
|
|
||||||
getActiveOpenCodeSessions: () => unknown;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes potentially invalid provider names coming from websocket payloads.
|
|
||||||
*/
|
|
||||||
function readProvider(value: unknown): LLMProvider {
|
|
||||||
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini' || value === 'opencode') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEFAULT_PROVIDER;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the authenticated request user id in the formats currently produced
|
* Extracts the authenticated request user id in the formats currently produced
|
||||||
* by platform and OSS auth code paths.
|
* by platform and OSS auth code paths.
|
||||||
@@ -92,8 +66,258 @@ function readRequestUserId(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendJson(ws: WebSocket, payload: unknown): void {
|
||||||
|
if (ws.readyState === WS_OPEN_STATE) {
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports a protocol-level failure to the requesting client.
|
||||||
|
*
|
||||||
|
* Protocol errors deliberately use their own `kind` (instead of the provider
|
||||||
|
* `error` message kind) so the frontend can distinguish "your request was
|
||||||
|
* invalid" from "the model run produced an error" without inspecting text.
|
||||||
|
*/
|
||||||
|
function sendProtocolError(
|
||||||
|
ws: WebSocket,
|
||||||
|
code: string,
|
||||||
|
error: string,
|
||||||
|
sessionId?: string
|
||||||
|
): void {
|
||||||
|
sendJson(ws, {
|
||||||
|
kind: 'protocol_error',
|
||||||
|
code,
|
||||||
|
error,
|
||||||
|
sessionId: sessionId ?? null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequiredSessionId(data: AnyRecord): string | null {
|
||||||
|
const sessionId = typeof data.sessionId === 'string' ? data.sessionId.trim() : '';
|
||||||
|
return sessionId.length > 0 ? sessionId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles `chat.send`: resolves the session row (provider, project path, and
|
||||||
|
* provider-native id all come from the database — never from the client),
|
||||||
|
* registers the run, and dispatches to the provider runtime.
|
||||||
|
*/
|
||||||
|
async function handleChatSend(
|
||||||
|
ws: WebSocket,
|
||||||
|
userId: string | number | null,
|
||||||
|
data: AnyRecord,
|
||||||
|
dependencies: ChatWebSocketDependencies
|
||||||
|
): Promise<void> {
|
||||||
|
const sessionId = readRequiredSessionId(data);
|
||||||
|
if (!sessionId) {
|
||||||
|
sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.send requires a sessionId.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sessionsDb.getSessionById(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
sendProtocolError(
|
||||||
|
ws,
|
||||||
|
'SESSION_NOT_FOUND',
|
||||||
|
`Session "${sessionId}" was not found. Create it via POST /api/providers/sessions first.`,
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = session.provider as LLMProvider;
|
||||||
|
const spawnFn = dependencies.spawnFns[provider];
|
||||||
|
if (!spawnFn) {
|
||||||
|
sendProtocolError(ws, 'UNSUPPORTED_PROVIDER', `Provider "${provider}" is not available.`, sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = chatRunRegistry.startRun({
|
||||||
|
appSessionId: sessionId,
|
||||||
|
provider,
|
||||||
|
providerSessionId: session.provider_session_id,
|
||||||
|
connection: ws,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!run) {
|
||||||
|
sendProtocolError(
|
||||||
|
ws,
|
||||||
|
'RUN_IN_PROGRESS',
|
||||||
|
`Session "${sessionId}" already has a run in progress.`,
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientOptions = (data.options ?? {}) as AnyRecord;
|
||||||
|
const command = typeof data.content === 'string' ? data.content : '';
|
||||||
|
|
||||||
|
// The provider runtimes receive the provider-native session id (that is the
|
||||||
|
// id their CLI/SDK understands for resume). Brand-new sessions have no
|
||||||
|
// provider id yet, so the runtime starts fresh and announces one, which the
|
||||||
|
// gateway writer captures and maps back to the app session id.
|
||||||
|
const runtimeOptions: AnyRecord = {
|
||||||
|
...clientOptions,
|
||||||
|
sessionId: session.provider_session_id ?? undefined,
|
||||||
|
resume: Boolean(session.provider_session_id),
|
||||||
|
cwd: clientOptions.cwd ?? session.project_path ?? undefined,
|
||||||
|
projectPath: session.project_path ?? clientOptions.projectPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await spawnFn(command, runtimeOptions, run.writer);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[Chat] Provider runtime "${provider}" failed`, { sessionId, error: message });
|
||||||
|
} finally {
|
||||||
|
// Safety net: a runtime that crashed (or resolved) without emitting its
|
||||||
|
// terminal `complete` would otherwise leave the session stuck in
|
||||||
|
// "processing" forever on every connected client.
|
||||||
|
chatRunRegistry.completeRun(sessionId, { exitCode: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles `chat.abort`: cancels the run for one app session and emits the
|
||||||
|
* terminal `complete` on its behalf (runtimes skip their own complete for
|
||||||
|
* aborted runs, and the registry drops any duplicate).
|
||||||
|
*/
|
||||||
|
async function handleChatAbort(
|
||||||
|
ws: WebSocket,
|
||||||
|
data: AnyRecord,
|
||||||
|
dependencies: ChatWebSocketDependencies
|
||||||
|
): Promise<void> {
|
||||||
|
const sessionId = readRequiredSessionId(data);
|
||||||
|
if (!sessionId) {
|
||||||
|
sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.abort requires a sessionId.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = chatRunRegistry.getRun(sessionId);
|
||||||
|
if (!run || run.status !== 'running') {
|
||||||
|
sendProtocolError(ws, 'NO_ACTIVE_RUN', `Session "${sessionId}" has no active run.`, sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortFn = dependencies.abortFns[run.provider];
|
||||||
|
let success = false;
|
||||||
|
if (abortFn && run.providerSessionId) {
|
||||||
|
success = Boolean(await abortFn(run.providerSessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
chatRunRegistry.completeRun(sessionId, {
|
||||||
|
exitCode: success ? 0 : 1,
|
||||||
|
aborted: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles `chat.subscribe`: for each requested session, reports whether a run
|
||||||
|
* is processing, re-attaches the live stream to this socket, replays missed
|
||||||
|
* events (seq > lastSeq), and includes pending permission requests.
|
||||||
|
*
|
||||||
|
* This single message replaces the old `check-session-status`,
|
||||||
|
* `get-pending-permissions`, and Claude-only writer reconnect flows.
|
||||||
|
*/
|
||||||
|
function handleChatSubscribe(
|
||||||
|
ws: WebSocket,
|
||||||
|
data: AnyRecord,
|
||||||
|
dependencies: ChatWebSocketDependencies
|
||||||
|
): void {
|
||||||
|
const targets = Array.isArray(data.sessions) ? data.sessions : [];
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!target || typeof target !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = typeof (target as AnyRecord).sessionId === 'string'
|
||||||
|
? ((target as AnyRecord).sessionId as string).trim()
|
||||||
|
: '';
|
||||||
|
if (!sessionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSeqRaw = (target as AnyRecord).lastSeq;
|
||||||
|
const lastSeq = typeof lastSeqRaw === 'number' && Number.isFinite(lastSeqRaw)
|
||||||
|
? Math.max(0, Math.floor(lastSeqRaw))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const run = chatRunRegistry.getRun(sessionId);
|
||||||
|
const isProcessing = chatRunRegistry.isProcessing(sessionId);
|
||||||
|
|
||||||
|
// Future live events for this run should land on the socket that asked —
|
||||||
|
// this is what makes mid-stream page refreshes work for all providers.
|
||||||
|
if (isProcessing) {
|
||||||
|
chatRunRegistry.attachConnection(sessionId, ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending approvals are tracked under the provider-native id inside the
|
||||||
|
// Claude runtime; remap their sessionId so the client only sees app ids.
|
||||||
|
const pendingPermissions = (run?.providerSessionId
|
||||||
|
? dependencies.getPendingApprovalsForSession(run.providerSessionId)
|
||||||
|
: []
|
||||||
|
).map((approval) =>
|
||||||
|
approval && typeof approval === 'object'
|
||||||
|
? { ...(approval as AnyRecord), sessionId }
|
||||||
|
: approval,
|
||||||
|
);
|
||||||
|
|
||||||
|
sendJson(ws, {
|
||||||
|
kind: 'chat_subscribed',
|
||||||
|
sessionId,
|
||||||
|
isProcessing,
|
||||||
|
lastSeq: run?.lastSeq ?? 0,
|
||||||
|
pendingPermissions,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replay only for RUNNING runs, strictly after the ack. Completed runs
|
||||||
|
// are fully persisted to the provider transcript and served over REST —
|
||||||
|
// replaying them (e.g. after a page reload where the client's lastSeq is
|
||||||
|
// 0) would duplicate messages the history fetch already returned.
|
||||||
|
if (isProcessing) {
|
||||||
|
for (const event of chatRunRegistry.replayEvents(sessionId, lastSeq)) {
|
||||||
|
sendJson(ws, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles `chat.permission-response`: forwards a tool-approval decision to the
|
||||||
|
* pending approval resolver (Claude is the only provider with interactive
|
||||||
|
* approvals today, but the message is intentionally provider-neutral).
|
||||||
|
*/
|
||||||
|
function handlePermissionResponse(data: AnyRecord, dependencies: ChatWebSocketDependencies): void {
|
||||||
|
if (typeof data.requestId !== 'string' || data.requestId.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies.resolveToolApproval(data.requestId, {
|
||||||
|
allow: Boolean(data.allow),
|
||||||
|
updatedInput: data.updatedInput,
|
||||||
|
message: typeof data.message === 'string' ? data.message : undefined,
|
||||||
|
rememberEntry: data.rememberEntry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles authenticated chat websocket messages used by the main chat panel.
|
* Handles authenticated chat websocket messages used by the main chat panel.
|
||||||
|
*
|
||||||
|
* Inbound protocol (client to server):
|
||||||
|
* - `chat.send` { sessionId, content, options? }
|
||||||
|
* - `chat.abort` { sessionId }
|
||||||
|
* - `chat.subscribe` { sessions: [{ sessionId, lastSeq? }] }
|
||||||
|
* - `chat.permission-response` { requestId, allow, updatedInput?, message?, rememberEntry? }
|
||||||
|
*
|
||||||
|
* Outbound protocol (server to client): every frame is `kind`-based — either
|
||||||
|
* a provider `NormalizedMessage` (with `seq`) or a gateway event
|
||||||
|
* (`chat_subscribed`, `session_upserted`, `loading_progress`,
|
||||||
|
* `protocol_error`).
|
||||||
*/
|
*/
|
||||||
export function handleChatConnection(
|
export function handleChatConnection(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
@@ -103,7 +327,7 @@ export function handleChatConnection(
|
|||||||
console.log('[INFO] Chat WebSocket connected');
|
console.log('[INFO] Chat WebSocket connected');
|
||||||
connectedClients.add(ws);
|
connectedClients.add(ws);
|
||||||
|
|
||||||
const writer = new WebSocketWriter(ws, readRequestUserId(request));
|
const userId = readRequestUserId(request);
|
||||||
|
|
||||||
ws.on('message', async (rawMessage) => {
|
ws.on('message', async (rawMessage) => {
|
||||||
try {
|
try {
|
||||||
@@ -112,169 +336,30 @@ export function handleChatConnection(
|
|||||||
throw new Error('Invalid websocket payload');
|
throw new Error('Invalid websocket payload');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = parsed as ChatIncomingMessage;
|
const data = parsed as AnyRecord;
|
||||||
const messageType = data.type;
|
const messageType = typeof data.type === 'string' ? data.type : '';
|
||||||
if (!messageType) {
|
|
||||||
throw new Error('Message type is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'claude-command') {
|
switch (messageType) {
|
||||||
await dependencies.queryClaudeSDK(data.command ?? '', data.options, writer);
|
case 'chat.send':
|
||||||
return;
|
await handleChatSend(ws, userId, data, dependencies);
|
||||||
}
|
return;
|
||||||
|
case 'chat.abort':
|
||||||
if (messageType === 'cursor-command') {
|
await handleChatAbort(ws, data, dependencies);
|
||||||
await dependencies.spawnCursor(data.command ?? '', data.options, writer);
|
return;
|
||||||
return;
|
case 'chat.subscribe':
|
||||||
}
|
handleChatSubscribe(ws, data, dependencies);
|
||||||
|
return;
|
||||||
if (messageType === 'codex-command') {
|
case 'chat.permission-response':
|
||||||
await dependencies.queryCodex(data.command ?? '', data.options, writer);
|
handlePermissionResponse(data, dependencies);
|
||||||
return;
|
return;
|
||||||
}
|
default:
|
||||||
|
sendProtocolError(ws, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type "${messageType}".`);
|
||||||
if (messageType === 'gemini-command') {
|
return;
|
||||||
await dependencies.spawnGemini(data.command ?? '', data.options, writer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'opencode-command') {
|
|
||||||
await dependencies.spawnOpenCode(data.command ?? '', data.options, writer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'cursor-resume') {
|
|
||||||
await dependencies.spawnCursor(
|
|
||||||
'',
|
|
||||||
{
|
|
||||||
sessionId: data.sessionId,
|
|
||||||
resume: true,
|
|
||||||
cwd: data.options?.cwd,
|
|
||||||
},
|
|
||||||
writer
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'abort-session') {
|
|
||||||
const provider = readProvider(data.provider);
|
|
||||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
|
||||||
let success = false;
|
|
||||||
|
|
||||||
if (provider === 'cursor') {
|
|
||||||
success = dependencies.abortCursorSession(sessionId);
|
|
||||||
} else if (provider === 'codex') {
|
|
||||||
success = dependencies.abortCodexSession(sessionId);
|
|
||||||
} else if (provider === 'gemini') {
|
|
||||||
success = dependencies.abortGeminiSession(sessionId);
|
|
||||||
} else if (provider === 'opencode') {
|
|
||||||
success = dependencies.abortOpenCodeSession(sessionId);
|
|
||||||
} else {
|
|
||||||
success = await dependencies.abortClaudeSDKSession(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.send(
|
|
||||||
createNormalizedMessage({
|
|
||||||
kind: 'complete',
|
|
||||||
exitCode: success ? 0 : 1,
|
|
||||||
aborted: true,
|
|
||||||
success,
|
|
||||||
sessionId,
|
|
||||||
provider,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'claude-permission-response') {
|
|
||||||
if (typeof data.requestId === 'string' && data.requestId.length > 0) {
|
|
||||||
dependencies.resolveToolApproval(data.requestId, {
|
|
||||||
allow: Boolean(data.allow),
|
|
||||||
updatedInput: data.updatedInput,
|
|
||||||
message: typeof data.message === 'string' ? data.message : undefined,
|
|
||||||
rememberEntry: data.rememberEntry,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'cursor-abort') {
|
|
||||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
|
||||||
const success = dependencies.abortCursorSession(sessionId);
|
|
||||||
writer.send(
|
|
||||||
createNormalizedMessage({
|
|
||||||
kind: 'complete',
|
|
||||||
exitCode: success ? 0 : 1,
|
|
||||||
aborted: true,
|
|
||||||
success,
|
|
||||||
sessionId,
|
|
||||||
provider: 'cursor',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'check-session-status') {
|
|
||||||
const provider = readProvider(data.provider);
|
|
||||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
|
||||||
let isActive = false;
|
|
||||||
|
|
||||||
if (provider === 'cursor') {
|
|
||||||
isActive = dependencies.isCursorSessionActive(sessionId);
|
|
||||||
} else if (provider === 'codex') {
|
|
||||||
isActive = dependencies.isCodexSessionActive(sessionId);
|
|
||||||
} else if (provider === 'gemini') {
|
|
||||||
isActive = dependencies.isGeminiSessionActive(sessionId);
|
|
||||||
} else if (provider === 'opencode') {
|
|
||||||
isActive = dependencies.isOpenCodeSessionActive(sessionId);
|
|
||||||
} else {
|
|
||||||
isActive = dependencies.isClaudeSDKSessionActive(sessionId);
|
|
||||||
if (isActive) {
|
|
||||||
dependencies.reconnectSessionWriter(sessionId, ws);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.send({
|
|
||||||
type: 'session-status',
|
|
||||||
sessionId,
|
|
||||||
provider,
|
|
||||||
isProcessing: isActive,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'get-pending-permissions') {
|
|
||||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
|
||||||
if (sessionId && dependencies.isClaudeSDKSessionActive(sessionId)) {
|
|
||||||
const pending = dependencies.getPendingApprovalsForSession(sessionId);
|
|
||||||
writer.send({
|
|
||||||
type: 'pending-permissions-response',
|
|
||||||
sessionId,
|
|
||||||
data: pending,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageType === 'get-active-sessions') {
|
|
||||||
writer.send({
|
|
||||||
type: 'active-sessions',
|
|
||||||
sessions: {
|
|
||||||
claude: dependencies.getActiveClaudeSDKSessions(),
|
|
||||||
cursor: dependencies.getActiveCursorSessions(),
|
|
||||||
codex: dependencies.getActiveCodexSessions(),
|
|
||||||
gemini: dependencies.getActiveGeminiSessions(),
|
|
||||||
opencode: dependencies.getActiveOpenCodeSessions(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.error('[ERROR] Chat WebSocket error:', message);
|
console.error('[ERROR] Chat WebSocket error:', message);
|
||||||
writer.send({
|
sendProtocolError(ws, 'INTERNAL_ERROR', message);
|
||||||
type: 'error',
|
|
||||||
error: message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|||||||
import pty, { type IPty } from 'node-pty';
|
import pty, { type IPty } from 'node-pty';
|
||||||
import { WebSocket, type RawData } from 'ws';
|
import { WebSocket, type RawData } from 'ws';
|
||||||
|
|
||||||
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
||||||
|
|
||||||
type ShellIncomingMessage = {
|
type ShellIncomingMessage = {
|
||||||
@@ -76,6 +77,40 @@ function parseShellMessage(rawMessage: RawData): ShellIncomingMessage | null {
|
|||||||
return payload as ShellIncomingMessage;
|
return payload as ShellIncomingMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9_.\-:]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the app-facing session id to the provider-native id used by CLIs.
|
||||||
|
*
|
||||||
|
* Chat history and provider artifacts on disk are keyed by the provider id,
|
||||||
|
* while the shell UI sends the stable app id from the session gateway.
|
||||||
|
*/
|
||||||
|
function resolveResumeSessionId(
|
||||||
|
appSessionId: string,
|
||||||
|
provider: string,
|
||||||
|
dependencies: ShellWebSocketDependencies
|
||||||
|
): string | null {
|
||||||
|
try {
|
||||||
|
const sessionRow = sessionsDb.getSessionById(appSessionId);
|
||||||
|
const providerSessionId = sessionRow?.provider_session_id;
|
||||||
|
if (providerSessionId && SAFE_SESSION_ID_PATTERN.test(providerSessionId)) {
|
||||||
|
return providerSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'gemini') {
|
||||||
|
const geminiSession = dependencies.getSessionById(appSessionId);
|
||||||
|
const cliSessionId = geminiSession?.cliSessionId;
|
||||||
|
if (cliSessionId && SAFE_SESSION_ID_PATTERN.test(cliSessionId)) {
|
||||||
|
return cliSessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to resolve resume session id for ${provider}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves provider command line for plain shell and agent-backed shell modes.
|
* Resolves provider command line for plain shell and agent-backed shell modes.
|
||||||
*/
|
*/
|
||||||
@@ -87,7 +122,6 @@ function buildShellCommand(
|
|||||||
const sessionId = readString(message.sessionId);
|
const sessionId = readString(message.sessionId);
|
||||||
const initialCommand = readString(message.initialCommand);
|
const initialCommand = readString(message.initialCommand);
|
||||||
const provider = readString(message.provider, 'claude');
|
const provider = readString(message.provider, 'claude');
|
||||||
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
|
|
||||||
const isPlainShell =
|
const isPlainShell =
|
||||||
readBoolean(message.isPlainShell) ||
|
readBoolean(message.isPlainShell) ||
|
||||||
(!!initialCommand && !hasSession) ||
|
(!!initialCommand && !hasSession) ||
|
||||||
@@ -97,59 +131,47 @@ function buildShellCommand(
|
|||||||
return initialCommand;
|
return initialCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resumeId =
|
||||||
|
hasSession && sessionId ? resolveResumeSessionId(sessionId, provider, dependencies) : null;
|
||||||
|
|
||||||
if (provider === 'cursor') {
|
if (provider === 'cursor') {
|
||||||
if (hasSession && sessionId) {
|
if (resumeId) {
|
||||||
return `cursor-agent --resume="${sessionId}"`;
|
return `cursor-agent --resume="${resumeId}"`;
|
||||||
}
|
}
|
||||||
return 'cursor-agent';
|
return 'cursor-agent';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'codex') {
|
if (provider === 'codex') {
|
||||||
if (hasSession && sessionId) {
|
if (resumeId) {
|
||||||
if (os.platform() === 'win32') {
|
if (os.platform() === 'win32') {
|
||||||
return `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
return `codex resume "${resumeId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
||||||
}
|
}
|
||||||
return `codex resume "${sessionId}" || codex`;
|
return `codex resume "${resumeId}" || codex`;
|
||||||
}
|
}
|
||||||
return 'codex';
|
return 'codex';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'gemini') {
|
if (provider === 'gemini') {
|
||||||
const command = initialCommand || 'gemini';
|
const command = initialCommand || 'gemini';
|
||||||
let resumeId = sessionId;
|
if (resumeId) {
|
||||||
if (hasSession && sessionId) {
|
|
||||||
try {
|
|
||||||
const existingSession = dependencies.getSessionById(sessionId);
|
|
||||||
if (existingSession && existingSession.cliSessionId) {
|
|
||||||
resumeId = existingSession.cliSessionId;
|
|
||||||
if (!safeSessionIdPattern.test(resumeId)) {
|
|
||||||
resumeId = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get Gemini CLI session ID:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasSession && resumeId) {
|
|
||||||
return `${command} --resume "${resumeId}"`;
|
return `${command} --resume "${resumeId}"`;
|
||||||
}
|
}
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'opencode') {
|
if (provider === 'opencode') {
|
||||||
if (hasSession && sessionId) {
|
if (resumeId) {
|
||||||
return `opencode --session "${sessionId}"`;
|
return `opencode --session "${resumeId}"`;
|
||||||
}
|
}
|
||||||
return initialCommand || 'opencode';
|
return initialCommand || 'opencode';
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = initialCommand || 'claude';
|
const command = initialCommand || 'claude';
|
||||||
if (hasSession && sessionId) {
|
if (resumeId) {
|
||||||
if (os.platform() === 'win32') {
|
if (os.platform() === 'win32') {
|
||||||
return `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
return `claude --resume "${resumeId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
||||||
}
|
}
|
||||||
return `claude --resume "${sessionId}" || claude`;
|
return `claude --resume "${resumeId}" || claude`;
|
||||||
}
|
}
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
@@ -254,8 +276,7 @@ export function handleShellConnection(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
|
if (sessionId && !SAFE_SESSION_ID_PATTERN.test(sessionId)) {
|
||||||
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
|
|
||||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
239
server/modules/websocket/tests/chat-run-registry.test.ts
Normal file
239
server/modules/websocket/tests/chat-run-registry.test.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
|
||||||
|
import type { NormalizedMessage } from '@/shared/types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal stand-in for a websocket connection: collects every JSON frame the
|
||||||
|
* gateway writer forwards so assertions can inspect the outbound protocol.
|
||||||
|
*/
|
||||||
|
class FakeConnection {
|
||||||
|
readyState = 1; // WS_OPEN_STATE
|
||||||
|
frames: NormalizedMessage[] = [];
|
||||||
|
|
||||||
|
send(data: string): void {
|
||||||
|
this.frames.push(JSON.parse(data) as NormalizedMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||||
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
|
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'chat-run-registry-'));
|
||||||
|
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||||
|
|
||||||
|
closeConnection();
|
||||||
|
process.env.DATABASE_PATH = databasePath;
|
||||||
|
await initializeDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runTest();
|
||||||
|
} finally {
|
||||||
|
chatRunRegistry.clearAll();
|
||||||
|
closeConnection();
|
||||||
|
if (previousDatabasePath === undefined) {
|
||||||
|
delete process.env.DATABASE_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.DATABASE_PATH = previousDatabasePath;
|
||||||
|
}
|
||||||
|
await rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('live events are remapped to the app session id and sequenced', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-run-1', 'claude', '/workspace/demo');
|
||||||
|
const connection = new FakeConnection();
|
||||||
|
const run = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-1',
|
||||||
|
provider: 'claude',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
assert.ok(run);
|
||||||
|
|
||||||
|
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'provider-id-9', content: 'hello' });
|
||||||
|
run.writer.send({ kind: 'text', provider: 'claude', sessionId: 'provider-id-9', content: 'hello world' });
|
||||||
|
|
||||||
|
assert.equal(connection.frames.length, 2);
|
||||||
|
assert.equal(connection.frames[0]?.sessionId, 'app-run-1');
|
||||||
|
assert.equal(connection.frames[0]?.seq, 1);
|
||||||
|
assert.equal(connection.frames[1]?.sessionId, 'app-run-1');
|
||||||
|
assert.equal(connection.frames[1]?.seq, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session_created is swallowed and persisted as the provider-id mapping', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo');
|
||||||
|
const connection = new FakeConnection();
|
||||||
|
const run = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-2',
|
||||||
|
provider: 'cursor',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(run);
|
||||||
|
|
||||||
|
run.writer.send({
|
||||||
|
kind: 'session_created',
|
||||||
|
provider: 'cursor',
|
||||||
|
sessionId: 'cursor-native-7',
|
||||||
|
newSessionId: 'cursor-native-7',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Never forwarded to the client...
|
||||||
|
assert.equal(connection.frames.length, 0);
|
||||||
|
// ...but recorded in the registry and persisted in the database.
|
||||||
|
assert.equal(run.providerSessionId, 'cursor-native-7');
|
||||||
|
assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('complete marks the run finished and duplicate completes are dropped', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-run-3', 'codex', '/workspace/demo');
|
||||||
|
const connection = new FakeConnection();
|
||||||
|
const run = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-3',
|
||||||
|
provider: 'codex',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(run);
|
||||||
|
|
||||||
|
run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 0 });
|
||||||
|
// Late duplicate from a killed runtime's exit handler.
|
||||||
|
run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 1 });
|
||||||
|
|
||||||
|
const completes = connection.frames.filter((frame) => frame.kind === 'complete');
|
||||||
|
assert.equal(completes.length, 1);
|
||||||
|
assert.equal(completes[0]?.actualSessionId, 'app-run-3');
|
||||||
|
assert.equal(chatRunRegistry.isProcessing('app-run-3'), false);
|
||||||
|
|
||||||
|
// completeRun is also a no-op once the run already completed.
|
||||||
|
chatRunRegistry.completeRun('app-run-3', { exitCode: 1 });
|
||||||
|
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listRunningRuns returns only currently running app sessions', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo');
|
||||||
|
sessionsDb.createAppSession('app-run-8', 'codex', '/workspace/demo');
|
||||||
|
const connection = new FakeConnection();
|
||||||
|
|
||||||
|
const completedRun = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-7',
|
||||||
|
provider: 'claude',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(completedRun);
|
||||||
|
|
||||||
|
const runningRun = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-8',
|
||||||
|
provider: 'codex',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(runningRun);
|
||||||
|
|
||||||
|
chatRunRegistry.completeRun('app-run-7', { exitCode: 0 });
|
||||||
|
|
||||||
|
const runningSessions = chatRunRegistry.listRunningRuns();
|
||||||
|
assert.deepEqual(runningSessions.map((session) => session.sessionId), ['app-run-8']);
|
||||||
|
assert.equal(runningSessions[0]?.provider, 'codex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replayEvents returns only events after the requested seq', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-run-4', 'claude', '/workspace/demo');
|
||||||
|
const connection = new FakeConnection();
|
||||||
|
const run = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-4',
|
||||||
|
provider: 'claude',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(run);
|
||||||
|
|
||||||
|
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'a' });
|
||||||
|
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'b' });
|
||||||
|
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'c' });
|
||||||
|
|
||||||
|
const replayed = chatRunRegistry.replayEvents('app-run-4', 1);
|
||||||
|
assert.deepEqual(replayed.map((event) => event.content), ['b', 'c']);
|
||||||
|
assert.deepEqual(replayed.map((event) => event.seq), [2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attachConnection reroutes the live stream to a new socket', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-run-5', 'gemini', '/workspace/demo');
|
||||||
|
const firstConnection = new FakeConnection();
|
||||||
|
const run = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-5',
|
||||||
|
provider: 'gemini',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection: firstConnection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(run);
|
||||||
|
|
||||||
|
run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'before' });
|
||||||
|
|
||||||
|
const secondConnection = new FakeConnection();
|
||||||
|
assert.equal(chatRunRegistry.attachConnection('app-run-5', secondConnection), true);
|
||||||
|
run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'after' });
|
||||||
|
|
||||||
|
assert.deepEqual(firstConnection.frames.map((frame) => frame.content), ['before']);
|
||||||
|
assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startRun rejects a second concurrent run for the same session', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createAppSession('app-run-6', 'opencode', '/workspace/demo');
|
||||||
|
const connection = new FakeConnection();
|
||||||
|
const first = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-6',
|
||||||
|
provider: 'opencode',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(first);
|
||||||
|
|
||||||
|
const second = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-6',
|
||||||
|
provider: 'opencode',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.equal(second, null);
|
||||||
|
|
||||||
|
// After the run finishes a new one is allowed again.
|
||||||
|
chatRunRegistry.completeRun('app-run-6', { exitCode: 0 });
|
||||||
|
const third = chatRunRegistry.startRun({
|
||||||
|
appSessionId: 'app-run-6',
|
||||||
|
provider: 'opencode',
|
||||||
|
providerSessionId: null,
|
||||||
|
connection,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
assert.ok(third);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
|
|||||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
import { createNormalizedMessage } from './shared/utils.js';
|
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
// Track active sessions
|
// Track active sessions
|
||||||
const activeCodexSessions = new Map();
|
const activeCodexSessions = new Map();
|
||||||
@@ -352,21 +352,26 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send completion event
|
// Send the terminal completion event — skipped for aborted runs, whose
|
||||||
if (!terminalFailure) {
|
// terminal `complete` (aborted: true) was already sent by abort-session.
|
||||||
sendMessage(ws, createNormalizedMessage({
|
const runSession = capturedSessionId ? activeCodexSessions.get(capturedSessionId) : null;
|
||||||
kind: 'complete',
|
const runAborted = runSession?.status === 'aborted' || abortController.signal.aborted;
|
||||||
actualSessionId: capturedSessionId || thread.id || sessionId || null,
|
if (!runAborted) {
|
||||||
sessionId: capturedSessionId || sessionId || null,
|
sendMessage(ws, createCompleteMessage({
|
||||||
provider: 'codex'
|
|
||||||
}));
|
|
||||||
notifyRunStopped({
|
|
||||||
userId: ws?.userId || null,
|
|
||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
sessionId: capturedSessionId || sessionId || null,
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
sessionName: sessionSummary,
|
actualSessionId: capturedSessionId || thread.id || sessionId || null,
|
||||||
stopReason: 'completed'
|
exitCode: terminalFailure ? 1 : 0,
|
||||||
});
|
}));
|
||||||
|
if (!terminalFailure) {
|
||||||
|
notifyRunStopped({
|
||||||
|
userId: ws?.userId || null,
|
||||||
|
provider: 'codex',
|
||||||
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
|
sessionName: sessionSummary,
|
||||||
|
stopReason: 'completed'
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -386,6 +391,11 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
: error.message;
|
: error.message;
|
||||||
|
|
||||||
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
|
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
|
||||||
|
sendMessage(ws, createCompleteMessage({
|
||||||
|
provider: 'codex',
|
||||||
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
|
exitCode: 1,
|
||||||
|
}));
|
||||||
if (!terminalFailure) {
|
if (!terminalFailure) {
|
||||||
notifyRunFailed({
|
notifyRunFailed({
|
||||||
userId: ws?.userId || null,
|
userId: ws?.userId || null,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { sessionsService } from './modules/providers/services/sessions.service.j
|
|||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
import { createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
|
import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
|
||||||
|
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
|
|
||||||
@@ -92,6 +92,9 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|||||||
let stdoutLineBuffer = '';
|
let stdoutLineBuffer = '';
|
||||||
let terminalNotificationSent = false;
|
let terminalNotificationSent = false;
|
||||||
let opencodeProcess = null;
|
let opencodeProcess = null;
|
||||||
|
// Unified lifecycle contract: exactly one terminal `complete` per run
|
||||||
|
// (close and error handlers can both fire for spawn failures).
|
||||||
|
let completeSent = false;
|
||||||
|
|
||||||
const notifyTerminalState = ({ code = null, error = null } = {}) => {
|
const notifyTerminalState = ({ code = null, error = null } = {}) => {
|
||||||
if (terminalNotificationSent) {
|
if (terminalNotificationSent) {
|
||||||
@@ -256,13 +259,12 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.send(createNormalizedMessage({
|
// Terminal complete — skipped for aborted runs (abort-session
|
||||||
kind: 'complete',
|
// already sent the aborted complete on this run's behalf).
|
||||||
exitCode: code,
|
if (!completeSent && !opencodeProcess.aborted) {
|
||||||
isNewSession: !sessionId && !!command,
|
completeSent = true;
|
||||||
sessionId: finalSessionId,
|
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: code }));
|
||||||
provider: 'opencode',
|
}
|
||||||
}));
|
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
notifyTerminalState({ code });
|
notifyTerminalState({ code });
|
||||||
@@ -302,6 +304,10 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|||||||
sessionId: finalSessionId,
|
sessionId: finalSessionId,
|
||||||
provider: 'opencode',
|
provider: 'opencode',
|
||||||
}));
|
}));
|
||||||
|
if (!completeSent && !opencodeProcess.aborted) {
|
||||||
|
completeSent = true;
|
||||||
|
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: 1 }));
|
||||||
|
}
|
||||||
notifyTerminalState({ error });
|
notifyTerminalState({ error });
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
@@ -315,6 +321,9 @@ function abortOpenCodeSession(sessionId) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The abort handler sends the terminal complete (aborted: true); flag the
|
||||||
|
// process so its close handler does not emit a second one.
|
||||||
|
process.aborted = true;
|
||||||
process.kill('SIGTERM');
|
process.kill('SIGTERM');
|
||||||
activeOpenCodeProcesses.delete(sessionId);
|
activeOpenCodeProcesses.delete(sessionId);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -98,6 +98,44 @@ function normalizeSessionName(sessionName) {
|
|||||||
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
|
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rowMatchesProvider(row, provider) {
|
||||||
|
return row && (!provider || row.provider === provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSessionRow(sessionId, provider) {
|
||||||
|
if (!sessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appSessionRow = sessionsDb.getSessionById(sessionId);
|
||||||
|
if (rowMatchesProvider(appSessionRow, provider)) {
|
||||||
|
return appSessionRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId);
|
||||||
|
if (rowMatchesProvider(providerSessionRow, provider)) {
|
||||||
|
return providerSessionRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNotificationSession(event) {
|
||||||
|
if (!event?.sessionId || !event.provider || event.provider === 'system') {
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = resolveSessionRow(event.sessionId, event.provider);
|
||||||
|
if (!row || row.session_id === event.sessionId) {
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
sessionId: row.session_id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSessionName(event) {
|
function resolveSessionName(event) {
|
||||||
const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
|
const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
|
||||||
if (explicitSessionName) {
|
if (explicitSessionName) {
|
||||||
@@ -112,28 +150,29 @@ function resolveSessionName(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPushBody(event) {
|
function buildPushBody(event) {
|
||||||
|
const normalizedEvent = normalizeNotificationSession(event);
|
||||||
const CODE_MAP = {
|
const CODE_MAP = {
|
||||||
'permission.required': event.meta?.toolName
|
'permission.required': normalizedEvent.meta?.toolName
|
||||||
? `Action Required: Tool "${event.meta.toolName}" needs approval`
|
? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval`
|
||||||
: 'Action Required: A tool needs your approval',
|
: 'Action Required: A tool needs your approval',
|
||||||
'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped',
|
'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped',
|
||||||
'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error',
|
'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error',
|
||||||
'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification',
|
'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification',
|
||||||
'push.enabled': 'Push notifications are now enabled!'
|
'push.enabled': 'Push notifications are now enabled!'
|
||||||
};
|
};
|
||||||
const providerLabel = PROVIDER_LABELS[event.provider] || 'Assistant';
|
const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant';
|
||||||
const sessionName = resolveSessionName(event);
|
const sessionName = resolveSessionName(normalizedEvent);
|
||||||
const message = CODE_MAP[event.code] || 'You have a new notification';
|
const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: sessionName || 'CloudCLI',
|
title: sessionName || 'CloudCLI',
|
||||||
body: `${providerLabel}: ${message}`,
|
body: `${providerLabel}: ${message}`,
|
||||||
data: {
|
data: {
|
||||||
sessionId: event.sessionId || null,
|
sessionId: normalizedEvent.sessionId || null,
|
||||||
code: event.code,
|
code: normalizedEvent.code,
|
||||||
provider: event.provider || null,
|
provider: normalizedEvent.provider || null,
|
||||||
sessionName,
|
sessionName,
|
||||||
tag: `${event.provider || 'assistant'}:${event.sessionId || 'none'}:${event.code}`
|
tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -175,15 +214,16 @@ function notifyUserIfEnabled({ userId, event }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedEvent = normalizeNotificationSession(event);
|
||||||
const preferences = notificationPreferencesDb.getPreferences(userId);
|
const preferences = notificationPreferencesDb.getPreferences(userId);
|
||||||
if (!shouldSendPush(preferences, event)) {
|
if (!shouldSendPush(preferences, normalizedEvent)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isDuplicate(event)) {
|
if (isDuplicate(normalizedEvent)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendWebPush(userId, event).catch((err) => {
|
sendWebPush(userId, normalizedEvent).catch((err) => {
|
||||||
console.error('Web push send error:', err);
|
console.error('Web push send error:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
80
server/services/notification-orchestrator.test.js
Normal file
80
server/services/notification-orchestrator.test.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import webPush from 'web-push';
|
||||||
|
|
||||||
|
import {
|
||||||
|
closeConnection,
|
||||||
|
initializeDatabase,
|
||||||
|
notificationPreferencesDb,
|
||||||
|
pushSubscriptionsDb,
|
||||||
|
sessionsDb,
|
||||||
|
userDb,
|
||||||
|
} from '../modules/database/index.js';
|
||||||
|
|
||||||
|
import { notifyRunStopped } from './notification-orchestrator.js';
|
||||||
|
|
||||||
|
async function withIsolatedDatabase(runTest) {
|
||||||
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
|
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'notification-orchestrator-'));
|
||||||
|
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||||
|
|
||||||
|
closeConnection();
|
||||||
|
process.env.DATABASE_PATH = databasePath;
|
||||||
|
await initializeDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runTest();
|
||||||
|
} finally {
|
||||||
|
closeConnection();
|
||||||
|
if (previousDatabasePath === undefined) {
|
||||||
|
delete process.env.DATABASE_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.DATABASE_PATH = previousDatabasePath;
|
||||||
|
}
|
||||||
|
await rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('push payload uses the app session id when notified with a provider session id', async () => {
|
||||||
|
const originalSendNotification = webPush.sendNotification;
|
||||||
|
const sentPayloads = [];
|
||||||
|
|
||||||
|
webPush.sendNotification = async (_subscription, payload) => {
|
||||||
|
sentPayloads.push(JSON.parse(payload));
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withIsolatedDatabase(async () => {
|
||||||
|
const user = userDb.createUser('notify-user', 'hash');
|
||||||
|
const userId = Number(user.id);
|
||||||
|
|
||||||
|
notificationPreferencesDb.updatePreferences(userId, {
|
||||||
|
channels: { webPush: true },
|
||||||
|
events: { actionRequired: true, stop: true, error: true },
|
||||||
|
});
|
||||||
|
pushSubscriptionsDb.saveSubscription(userId, 'https://example.test/push', 'p256dh', 'auth');
|
||||||
|
sessionsDb.createAppSession('app-session-1', 'claude', '/workspace/demo');
|
||||||
|
sessionsDb.assignProviderSessionId('app-session-1', 'claude-native-1');
|
||||||
|
|
||||||
|
notifyRunStopped({
|
||||||
|
userId,
|
||||||
|
provider: 'claude',
|
||||||
|
sessionId: 'claude-native-1',
|
||||||
|
stopReason: 'completed',
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.equal(sentPayloads.length, 1);
|
||||||
|
assert.equal(sentPayloads[0]?.data?.sessionId, 'app-session-1');
|
||||||
|
assert.match(sentPayloads[0]?.data?.tag, /app-session-1/);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
webPush.sendNotification = originalSendNotification;
|
||||||
|
}
|
||||||
|
});
|
||||||
42
server/shared/tests/slice-tail-page.test.ts
Normal file
42
server/shared/tests/slice-tail-page.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { sliceTailPage } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
const ITEMS = ['a', 'b', 'c', 'd', 'e'];
|
||||||
|
|
||||||
|
test('offset 0 returns the most recent page', () => {
|
||||||
|
const { page, hasMore } = sliceTailPage(ITEMS, 2, 0);
|
||||||
|
assert.deepEqual(page, ['d', 'e']);
|
||||||
|
assert.equal(hasMore, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('increasing offsets walk backwards in time', () => {
|
||||||
|
const { page, hasMore } = sliceTailPage(ITEMS, 2, 2);
|
||||||
|
assert.deepEqual(page, ['b', 'c']);
|
||||||
|
assert.equal(hasMore, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the oldest page reports hasMore false', () => {
|
||||||
|
const { page, hasMore } = sliceTailPage(ITEMS, 2, 4);
|
||||||
|
assert.deepEqual(page, ['a']);
|
||||||
|
assert.equal(hasMore, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null limit returns everything', () => {
|
||||||
|
const { page, hasMore } = sliceTailPage(ITEMS, null, 0);
|
||||||
|
assert.deepEqual(page, ITEMS);
|
||||||
|
assert.equal(hasMore, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offsets past the start return an empty page', () => {
|
||||||
|
const { page, hasMore } = sliceTailPage(ITEMS, 3, 10);
|
||||||
|
assert.deepEqual(page, []);
|
||||||
|
assert.equal(hasMore, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zero limit returns an empty page but keeps hasMore accurate', () => {
|
||||||
|
const { page, hasMore } = sliceTailPage(ITEMS, 0, 0);
|
||||||
|
assert.deepEqual(page, []);
|
||||||
|
assert.equal(hasMore, true);
|
||||||
|
});
|
||||||
@@ -175,6 +175,30 @@ export type MessageKind =
|
|||||||
| 'interactive_prompt'
|
| 'interactive_prompt'
|
||||||
| 'task_notification';
|
| 'task_notification';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event kinds added by the chat gateway layer on top of provider message kinds.
|
||||||
|
*
|
||||||
|
* These are app-level realtime events (subscription acks, sidebar deltas,
|
||||||
|
* project loading progress, protocol failures) that are not produced by any
|
||||||
|
* provider adapter. Together with `MessageKind` they form the complete set of
|
||||||
|
* `kind` values a websocket client can receive, so the frontend only ever
|
||||||
|
* needs one kind-based switch.
|
||||||
|
*/
|
||||||
|
export type GatewayEventKind =
|
||||||
|
| 'chat_subscribed'
|
||||||
|
| 'session_upserted'
|
||||||
|
| 'loading_progress'
|
||||||
|
| 'protocol_error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete set of `kind` values emitted to websocket clients.
|
||||||
|
*
|
||||||
|
* Every server-to-client websocket frame carries a `kind` from this union.
|
||||||
|
* Provider runtimes emit `MessageKind` values; gateway services emit
|
||||||
|
* `GatewayEventKind` values.
|
||||||
|
*/
|
||||||
|
export type ServerEventKind = MessageKind | GatewayEventKind;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider-neutral message envelope used in REST responses and realtime channels.
|
* Provider-neutral message envelope used in REST responses and realtime channels.
|
||||||
*
|
*
|
||||||
@@ -187,6 +211,13 @@ export type NormalizedMessage = {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
provider: LLMProvider;
|
provider: LLMProvider;
|
||||||
kind: MessageKind;
|
kind: MessageKind;
|
||||||
|
/**
|
||||||
|
* Monotonic per-run sequence number assigned by the chat run registry when a
|
||||||
|
* live event is forwarded to the websocket. History messages loaded over
|
||||||
|
* REST do not carry it. Clients use it with `chat.subscribe` to replay only
|
||||||
|
* the live events they missed across websocket reconnects.
|
||||||
|
*/
|
||||||
|
seq?: number;
|
||||||
role?: 'user' | 'assistant';
|
role?: 'user' | 'assistant';
|
||||||
content?: string;
|
content?: string;
|
||||||
/**
|
/**
|
||||||
@@ -237,11 +268,18 @@ export type NormalizedMessage = {
|
|||||||
*
|
*
|
||||||
* Consumers should pass provider-specific lookup hints (`projectPath`) only
|
* Consumers should pass provider-specific lookup hints (`projectPath`) only
|
||||||
* when the selected provider requires them.
|
* when the selected provider requires them.
|
||||||
|
*
|
||||||
|
* `providerSessionId` is the provider-native session id from the sessions
|
||||||
|
* index (transcript file name / provider database key). Provider adapters
|
||||||
|
* must use it — never the app-facing session id they were called with — when
|
||||||
|
* matching transcript rows on disk, because app-created sessions use an
|
||||||
|
* app-allocated id that the provider has never seen.
|
||||||
*/
|
*/
|
||||||
export type FetchHistoryOptions = {
|
export type FetchHistoryOptions = {
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
limit?: number | null;
|
limit?: number | null;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
providerSessionId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -346,6 +346,84 @@ export function createNormalizedMessage(fields: NormalizedMessageInput): Normali
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the unified terminal `complete` lifecycle message.
|
||||||
|
*
|
||||||
|
* Contract: every provider run ends with exactly one `complete` (the
|
||||||
|
* abort-session handler emits it on behalf of cancelled runs, so aborted runs
|
||||||
|
* must NOT emit their own). The frontend treats `complete` as the only
|
||||||
|
* terminal signal and never needs provider-specific handling:
|
||||||
|
*
|
||||||
|
* - `sessionId` — the id the client knows this run by ('' if never discovered)
|
||||||
|
* - `actualSessionId` — canonical id after the run; equals `sessionId` unless
|
||||||
|
* the provider rewrote it mid-run
|
||||||
|
* - `exitCode` — 0 on success; a missing/null code (e.g. killed process)
|
||||||
|
* is reported as failure
|
||||||
|
* - `success` — exitCode === 0 and not aborted
|
||||||
|
* - `aborted` — run was cancelled by the user
|
||||||
|
*/
|
||||||
|
export function createCompleteMessage(opts: {
|
||||||
|
provider: NormalizedMessage['provider'];
|
||||||
|
sessionId?: string | null;
|
||||||
|
actualSessionId?: string | null;
|
||||||
|
exitCode?: number | null;
|
||||||
|
aborted?: boolean;
|
||||||
|
}): NormalizedMessage {
|
||||||
|
const exitCode = typeof opts.exitCode === 'number' ? opts.exitCode : 1;
|
||||||
|
const aborted = Boolean(opts.aborted);
|
||||||
|
|
||||||
|
return createNormalizedMessage({
|
||||||
|
kind: 'complete',
|
||||||
|
provider: opts.provider,
|
||||||
|
sessionId: opts.sessionId || null,
|
||||||
|
actualSessionId: opts.actualSessionId || opts.sessionId || null,
|
||||||
|
exitCode,
|
||||||
|
success: exitCode === 0 && !aborted,
|
||||||
|
aborted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
//----------------- CONVERSATION HISTORY PAGINATION UTILITIES ------------
|
||||||
|
/**
|
||||||
|
* Slices one page from the END of a chronologically ordered message list.
|
||||||
|
*
|
||||||
|
* This is the single pagination contract for conversation history across all
|
||||||
|
* providers: `offset = 0` returns the most recent `limit` items, increasing
|
||||||
|
* offsets walk backwards in time (for "scroll up to load older" UIs), and a
|
||||||
|
* `null` limit returns everything. Items must already be sorted oldest-first;
|
||||||
|
* the returned page preserves that order.
|
||||||
|
*
|
||||||
|
* Every provider history reader must use this helper instead of slicing
|
||||||
|
* manually so `offset`/`limit` query params behave identically regardless of
|
||||||
|
* which provider produced the session.
|
||||||
|
*/
|
||||||
|
export function sliceTailPage<T>(
|
||||||
|
items: T[],
|
||||||
|
limit: number | null,
|
||||||
|
offset: number,
|
||||||
|
): { page: T[]; hasMore: boolean } {
|
||||||
|
const total = items.length;
|
||||||
|
const normalizedOffset = Math.max(0, offset);
|
||||||
|
|
||||||
|
if (limit === null) {
|
||||||
|
// A null limit returns the full list; offset still trims newest entries
|
||||||
|
// so "everything before the page I already have" stays expressible.
|
||||||
|
const end = Math.max(0, total - normalizedOffset);
|
||||||
|
return {
|
||||||
|
page: items.slice(0, end),
|
||||||
|
hasMore: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = Math.max(0, total - normalizedOffset);
|
||||||
|
const start = Math.max(0, end - Math.max(0, limit));
|
||||||
|
return {
|
||||||
|
page: items.slice(start, end),
|
||||||
|
hasMore: start > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
//----------------- MCP CONFIG PARSING UTILITIES ------------
|
//----------------- MCP CONFIG PARSING UTILITIES ------------
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -10,6 +10,33 @@ import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/Palett
|
|||||||
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
||||||
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
||||||
import { useProjectsState } from '../../hooks/useProjectsState';
|
import { useProjectsState } from '../../hooks/useProjectsState';
|
||||||
|
import { api } from '../../utils/api';
|
||||||
|
|
||||||
|
type RunningSessionApiItem = {
|
||||||
|
sessionId?: unknown;
|
||||||
|
startedAt?: unknown;
|
||||||
|
statusText?: unknown;
|
||||||
|
canInterrupt?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunningSessionsApiPayload = {
|
||||||
|
data?: {
|
||||||
|
sessions?: RunningSessionApiItem[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseStartedAt = (value: unknown): number | undefined => {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export default function AppContent() {
|
export default function AppContent() {
|
||||||
return (
|
return (
|
||||||
@@ -24,16 +51,13 @@ function AppContentInner() {
|
|||||||
const { sessionId } = useParams<{ sessionId?: string }>();
|
const { sessionId } = useParams<{ sessionId?: string }>();
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
||||||
const { ws, sendMessage, latestMessage, isConnected } = useWebSocket();
|
const { ws, sendMessage, subscribe } = useWebSocket();
|
||||||
const wasConnectedRef = useRef(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeSessions,
|
|
||||||
processingSessions,
|
processingSessions,
|
||||||
markSessionAsActive,
|
markSessionProcessing,
|
||||||
markSessionAsInactive,
|
markSessionIdle,
|
||||||
markSessionAsProcessing,
|
syncProcessingSessions,
|
||||||
markSessionAsNotProcessing,
|
|
||||||
} = useSessionProtection();
|
} = useSessionProtection();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -50,16 +74,64 @@ function AppContentInner() {
|
|||||||
setShowSettings,
|
setShowSettings,
|
||||||
openSettings,
|
openSettings,
|
||||||
refreshProjectsSilently,
|
refreshProjectsSilently,
|
||||||
|
registerOptimisticSession,
|
||||||
sidebarSharedProps,
|
sidebarSharedProps,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
} = useProjectsState({
|
} = useProjectsState({
|
||||||
sessionId,
|
sessionId,
|
||||||
navigate,
|
navigate,
|
||||||
latestMessage,
|
subscribe,
|
||||||
isMobile,
|
isMobile,
|
||||||
activeSessions,
|
activeSessions: processingSessions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const refreshRunningSessions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.runningSessions();
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as RunningSessionsApiPayload;
|
||||||
|
const sessions = Array.isArray(payload.data?.sessions) ? payload.data.sessions : [];
|
||||||
|
|
||||||
|
syncProcessingSessions(
|
||||||
|
sessions
|
||||||
|
.map((session) => {
|
||||||
|
if (typeof session.sessionId !== 'string' || !session.sessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
startedAt: parseStartedAt(session.startedAt),
|
||||||
|
statusText: typeof session.statusText === 'string' ? session.statusText : undefined,
|
||||||
|
canInterrupt: typeof session.canInterrupt === 'boolean' ? session.canInterrupt : undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((session): session is NonNullable<typeof session> => Boolean(session)),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AppContent] Failed to sync running sessions:', error);
|
||||||
|
}
|
||||||
|
}, [syncProcessingSessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshRunningSessions();
|
||||||
|
}, [refreshRunningSessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (processingSessions.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void refreshRunningSessions();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [processingSessions.size, refreshRunningSessions]);
|
||||||
|
|
||||||
usePaletteOpsRegister({
|
usePaletteOpsRegister({
|
||||||
openSettings,
|
openSettings,
|
||||||
refreshProjects: refreshProjectsSilently,
|
refreshProjects: refreshProjectsSilently,
|
||||||
@@ -99,23 +171,9 @@ function AppContentInner() {
|
|||||||
};
|
};
|
||||||
}, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]);
|
}, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]);
|
||||||
|
|
||||||
// Permission recovery: query pending permissions on WebSocket reconnect or session change
|
// Pending tool permissions are recovered through the `chat.subscribe` flow:
|
||||||
useEffect(() => {
|
// the `chat_subscribed` ack carries them on session open and on reconnect,
|
||||||
const isReconnect = isConnected && !wasConnectedRef.current;
|
// so no separate permission-recovery message is needed here.
|
||||||
|
|
||||||
if (isReconnect) {
|
|
||||||
wasConnectedRef.current = true;
|
|
||||||
} else if (!isConnected) {
|
|
||||||
wasConnectedRef.current = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isConnected && selectedSession?.id) {
|
|
||||||
sendMessage({
|
|
||||||
type: 'get-pending-permissions',
|
|
||||||
sessionId: selectedSession.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isConnected, selectedSession?.id, sendMessage]);
|
|
||||||
|
|
||||||
// Adjust the app container to stay above the virtual keyboard on iOS Safari.
|
// Adjust the app container to stay above the virtual keyboard on iOS Safari.
|
||||||
// On Chrome for Android the layout viewport already shrinks when the keyboard opens,
|
// On Chrome for Android the layout viewport already shrinks when the keyboard opens,
|
||||||
@@ -180,19 +238,19 @@ function AppContentInner() {
|
|||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
ws={ws}
|
ws={ws}
|
||||||
sendMessage={sendMessage}
|
sendMessage={sendMessage}
|
||||||
latestMessage={latestMessage}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onMenuClick={() => setSidebarOpen(true)}
|
onMenuClick={() => setSidebarOpen(true)}
|
||||||
isLoading={isLoadingProjects}
|
isLoading={isLoadingProjects}
|
||||||
onInputFocusChange={setIsInputFocused}
|
onInputFocusChange={setIsInputFocused}
|
||||||
onSessionActive={markSessionAsActive}
|
onSessionProcessing={markSessionProcessing}
|
||||||
onSessionInactive={markSessionAsInactive}
|
onSessionIdle={markSessionIdle}
|
||||||
onSessionProcessing={markSessionAsProcessing}
|
|
||||||
onSessionNotProcessing={markSessionAsNotProcessing}
|
|
||||||
processingSessions={processingSessions}
|
processingSessions={processingSessions}
|
||||||
onNavigateToSession={(targetSessionId: string, options) =>
|
onNavigateToSession={(targetSessionId: string, options) =>
|
||||||
navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) })
|
navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) })
|
||||||
}
|
}
|
||||||
|
onSessionEstablished={(targetSessionId, context) =>
|
||||||
|
registerOptimisticSession({ sessionId: targetSessionId, ...context })
|
||||||
|
}
|
||||||
onShowSettings={() => setShowSettings(true)}
|
onShowSettings={() => setShowSettings(true)}
|
||||||
externalMessageUpdate={externalMessageUpdate}
|
externalMessageUpdate={externalMessageUpdate}
|
||||||
newSessionTrigger={newSessionTrigger}
|
newSessionTrigger={newSessionTrigger}
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import type {
|
|||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
||||||
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
||||||
import { safeLocalStorage } from '../utils/chatStorage';
|
import { safeLocalStorage } from '../utils/chatStorage';
|
||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
PendingPermissionRequest,
|
PendingPermissionRequest,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
|
SessionEstablishedContext,
|
||||||
} from '../types/types';
|
} from '../types/types';
|
||||||
import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } from '../../../types/app';
|
import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } from '../../../types/app';
|
||||||
import { escapeRegExp } from '../utils/chatFormatting';
|
import { escapeRegExp } from '../utils/chatFormatting';
|
||||||
@@ -25,10 +27,6 @@ import { escapeRegExp } from '../utils/chatFormatting';
|
|||||||
import { useFileMentions } from './useFileMentions';
|
import { useFileMentions } from './useFileMentions';
|
||||||
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
|
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
|
||||||
|
|
||||||
type PendingViewSession = {
|
|
||||||
startedAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UseChatComposerStateArgs {
|
interface UseChatComposerStateArgs {
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
@@ -46,17 +44,20 @@ interface UseChatComposerStateArgs {
|
|||||||
tokenBudget: Record<string, unknown> | null;
|
tokenBudget: Record<string, unknown> | null;
|
||||||
sendMessage: (message: unknown) => void;
|
sendMessage: (message: unknown) => void;
|
||||||
sendByCtrlEnter?: boolean;
|
sendByCtrlEnter?: boolean;
|
||||||
onSessionActive?: (sessionId?: string | null) => void;
|
onSessionProcessing?: MarkSessionProcessing;
|
||||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
/**
|
||||||
|
* Invoked with the freshly allocated session id when the user sends the
|
||||||
|
* first message of a brand-new conversation. The backend allocates the id
|
||||||
|
* via POST /api/providers/sessions BEFORE the websocket send, so the id is
|
||||||
|
* stable for the conversation's whole lifetime — the consumer navigates to
|
||||||
|
* /session/:id and records it as the current session.
|
||||||
|
*/
|
||||||
|
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||||
onInputFocusChange?: (focused: boolean) => void;
|
onInputFocusChange?: (focused: boolean) => void;
|
||||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
pendingViewSessionRef: { current: PendingViewSession | null };
|
|
||||||
scrollToBottom: () => void;
|
scrollToBottom: () => void;
|
||||||
addMessage: (msg: ChatMessage) => void;
|
addMessage: (msg: ChatMessage) => void;
|
||||||
setIsLoading: (loading: boolean) => void;
|
|
||||||
setCanAbortSession: (canAbort: boolean) => void;
|
|
||||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
|
||||||
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
|
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
|
||||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||||
}
|
}
|
||||||
@@ -177,17 +178,13 @@ export function useChatComposerState({
|
|||||||
tokenBudget,
|
tokenBudget,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
onSessionActive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
|
onSessionEstablished,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
onFileOpen,
|
onFileOpen,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
pendingViewSessionRef,
|
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
addMessage,
|
addMessage,
|
||||||
setIsLoading,
|
|
||||||
setCanAbortSession,
|
|
||||||
setClaudeStatus,
|
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
}: UseChatComposerStateArgs) {
|
}: UseChatComposerStateArgs) {
|
||||||
@@ -609,8 +606,54 @@ export function useChatComposerState({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveSessionId =
|
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
||||||
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
|
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
|
||||||
|
|
||||||
|
// The conversation always has a stable backend-allocated session id
|
||||||
|
// BEFORE the first websocket send: brand-new chats allocate one here
|
||||||
|
// via the session gateway. There is no client-visible session-id
|
||||||
|
// handoff later — this id stays valid for the conversation's lifetime.
|
||||||
|
let targetSessionId = selectedSession?.id || currentSessionId || null;
|
||||||
|
if (!targetSessionId) {
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch('/api/providers/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider,
|
||||||
|
projectPath: resolvedProjectPath,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to create session (${response.status})`);
|
||||||
|
}
|
||||||
|
const body = await response.json();
|
||||||
|
targetSessionId = body?.data?.sessionId || null;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error('Session creation failed:', error);
|
||||||
|
addMessage({
|
||||||
|
type: 'error',
|
||||||
|
content: `Failed to start a new session: ${message}`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetSessionId) {
|
||||||
|
addMessage({
|
||||||
|
type: 'error',
|
||||||
|
content: 'Failed to start a new session: no session id returned.',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSessionEstablished?.(targetSessionId, {
|
||||||
|
provider,
|
||||||
|
project: selectedProject,
|
||||||
|
summary: sessionSummary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
const userMessage: ChatMessage = {
|
||||||
type: 'user',
|
type: 'user',
|
||||||
@@ -620,27 +663,17 @@ export function useChatComposerState({
|
|||||||
};
|
};
|
||||||
|
|
||||||
addMessage(userMessage);
|
addMessage(userMessage);
|
||||||
setIsLoading(true); // Processing banner starts
|
// Mark this request as processing in the per-session activity map (the
|
||||||
setCanAbortSession(true);
|
// single source of truth the indicator derives from). The id is always
|
||||||
setClaudeStatus({
|
// concrete at this point — no pending placeholder exists anymore.
|
||||||
text: 'Processing',
|
onSessionProcessing?.(targetSessionId, {
|
||||||
tokens: 0,
|
statusText: null,
|
||||||
can_interrupt: true,
|
canInterrupt: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
setTimeout(() => scrollToBottom(), 100);
|
setTimeout(() => scrollToBottom(), 100);
|
||||||
|
|
||||||
if (!effectiveSessionId && !selectedSession?.id) {
|
|
||||||
// This tracks only that a request is in flight before the provider has
|
|
||||||
// emitted its real session id; routing still waits for session_created.
|
|
||||||
pendingViewSessionRef.current = { startedAt: Date.now() };
|
|
||||||
}
|
|
||||||
if (effectiveSessionId) {
|
|
||||||
onSessionActive?.(effectiveSessionId);
|
|
||||||
onSessionProcessing?.(effectiveSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getToolsSettings = () => {
|
const getToolsSettings = () => {
|
||||||
try {
|
try {
|
||||||
const settingsKey =
|
const settingsKey =
|
||||||
@@ -669,87 +702,35 @@ export function useChatComposerState({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toolsSettings = getToolsSettings();
|
const toolsSettings = getToolsSettings();
|
||||||
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
const model =
|
||||||
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
|
provider === 'cursor'
|
||||||
|
? cursorModel
|
||||||
|
: provider === 'codex'
|
||||||
|
? codexModel
|
||||||
|
: provider === 'gemini'
|
||||||
|
? geminiModel
|
||||||
|
: provider === 'opencode'
|
||||||
|
? opencodeModel
|
||||||
|
: claudeModel;
|
||||||
|
|
||||||
if (provider === 'cursor') {
|
// One message shape for every provider. The backend resolves the
|
||||||
sendMessage({
|
// provider, project path, and provider-native resume id from the
|
||||||
type: 'cursor-command',
|
// session row; `options` only carries composer-level preferences.
|
||||||
command: messageContent,
|
sendMessage({
|
||||||
sessionId: effectiveSessionId,
|
type: 'chat.send',
|
||||||
options: {
|
sessionId: targetSessionId,
|
||||||
cwd: resolvedProjectPath,
|
content: messageContent,
|
||||||
projectPath: resolvedProjectPath,
|
options: {
|
||||||
sessionId: effectiveSessionId,
|
model,
|
||||||
resume: Boolean(effectiveSessionId),
|
// Codex has no plan mode; downgrade rather than sending an
|
||||||
model: cursorModel,
|
// unsupported value to its runtime.
|
||||||
skipPermissions: toolsSettings?.skipPermissions || false,
|
permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode,
|
||||||
sessionSummary,
|
toolsSettings,
|
||||||
toolsSettings,
|
skipPermissions: toolsSettings?.skipPermissions || false,
|
||||||
},
|
sessionSummary,
|
||||||
});
|
images: uploadedImages,
|
||||||
} else if (provider === 'codex') {
|
},
|
||||||
sendMessage({
|
});
|
||||||
type: 'codex-command',
|
|
||||||
command: messageContent,
|
|
||||||
sessionId: effectiveSessionId,
|
|
||||||
options: {
|
|
||||||
cwd: resolvedProjectPath,
|
|
||||||
projectPath: resolvedProjectPath,
|
|
||||||
sessionId: effectiveSessionId,
|
|
||||||
resume: Boolean(effectiveSessionId),
|
|
||||||
model: codexModel,
|
|
||||||
sessionSummary,
|
|
||||||
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (provider === 'gemini') {
|
|
||||||
sendMessage({
|
|
||||||
type: 'gemini-command',
|
|
||||||
command: messageContent,
|
|
||||||
sessionId: effectiveSessionId,
|
|
||||||
options: {
|
|
||||||
cwd: resolvedProjectPath,
|
|
||||||
projectPath: resolvedProjectPath,
|
|
||||||
sessionId: effectiveSessionId,
|
|
||||||
resume: Boolean(effectiveSessionId),
|
|
||||||
model: geminiModel,
|
|
||||||
sessionSummary,
|
|
||||||
permissionMode,
|
|
||||||
toolsSettings,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (provider === 'opencode') {
|
|
||||||
sendMessage({
|
|
||||||
type: 'opencode-command',
|
|
||||||
command: messageContent,
|
|
||||||
sessionId: effectiveSessionId,
|
|
||||||
options: {
|
|
||||||
cwd: resolvedProjectPath,
|
|
||||||
projectPath: resolvedProjectPath,
|
|
||||||
sessionId: effectiveSessionId,
|
|
||||||
resume: Boolean(effectiveSessionId),
|
|
||||||
model: opencodeModel,
|
|
||||||
sessionSummary,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
sendMessage({
|
|
||||||
type: 'claude-command',
|
|
||||||
command: messageContent,
|
|
||||||
options: {
|
|
||||||
projectPath: resolvedProjectPath,
|
|
||||||
cwd: resolvedProjectPath,
|
|
||||||
sessionId: effectiveSessionId,
|
|
||||||
resume: Boolean(effectiveSessionId),
|
|
||||||
toolsSettings,
|
|
||||||
permissionMode,
|
|
||||||
model: claudeModel,
|
|
||||||
sessionSummary,
|
|
||||||
images: uploadedImages,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setInput('');
|
setInput('');
|
||||||
inputValueRef.current = '';
|
inputValueRef.current = '';
|
||||||
@@ -776,19 +757,15 @@ export function useChatComposerState({
|
|||||||
geminiModel,
|
geminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
isLoading,
|
isLoading,
|
||||||
onSessionActive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
pendingViewSessionRef,
|
onSessionEstablished,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
provider,
|
provider,
|
||||||
resetCommandMenuState,
|
resetCommandMenuState,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
setCanAbortSession,
|
|
||||||
addMessage,
|
addMessage,
|
||||||
setClaudeStatus,
|
|
||||||
setIsLoading,
|
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
],
|
],
|
||||||
@@ -944,29 +921,19 @@ export function useChatComposerState({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cursorSessionId =
|
const targetSessionId = selectedSession?.id || currentSessionId || null;
|
||||||
typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;
|
|
||||||
|
|
||||||
const candidateSessionIds = [
|
|
||||||
currentSessionId,
|
|
||||||
provider === 'cursor' ? cursorSessionId : null,
|
|
||||||
selectedSession?.id || null,
|
|
||||||
];
|
|
||||||
|
|
||||||
const targetSessionId =
|
|
||||||
candidateSessionIds.find((sessionId) => Boolean(sessionId)) || null;
|
|
||||||
|
|
||||||
if (!targetSessionId) {
|
if (!targetSessionId) {
|
||||||
console.warn('Abort requested but no concrete session ID is available yet.');
|
console.warn('Abort requested but no session ID is available.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The backend resolves the provider from the session row, so no provider
|
||||||
|
// field is needed here.
|
||||||
sendMessage({
|
sendMessage({
|
||||||
type: 'abort-session',
|
type: 'chat.abort',
|
||||||
sessionId: targetSessionId,
|
sessionId: targetSessionId,
|
||||||
provider,
|
|
||||||
});
|
});
|
||||||
}, [canAbortSession, currentSessionId, provider, selectedSession?.id, sendMessage]);
|
}, [canAbortSession, currentSessionId, selectedSession?.id, sendMessage]);
|
||||||
|
|
||||||
const handleGrantToolPermission = useCallback(
|
const handleGrantToolPermission = useCallback(
|
||||||
(suggestion: { entry: string; toolName: string }) => {
|
(suggestion: { entry: string; toolName: string }) => {
|
||||||
@@ -991,7 +958,7 @@ export function useChatComposerState({
|
|||||||
|
|
||||||
validIds.forEach((requestId) => {
|
validIds.forEach((requestId) => {
|
||||||
sendMessage({
|
sendMessage({
|
||||||
type: 'claude-permission-response',
|
type: 'chat.permission-response',
|
||||||
requestId,
|
requestId,
|
||||||
allow: Boolean(decision?.allow),
|
allow: Boolean(decision?.allow),
|
||||||
updatedInput: decision?.updatedInput,
|
updatedInput: decision?.updatedInput,
|
||||||
@@ -1000,15 +967,11 @@ export function useChatComposerState({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setPendingPermissionRequests((previous) => {
|
setPendingPermissionRequests((previous) =>
|
||||||
const next = previous.filter((request) => !validIds.includes(request.requestId));
|
previous.filter((request) => !validIds.includes(request.requestId)),
|
||||||
if (next.length === 0) {
|
);
|
||||||
setClaudeStatus(null);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
|
[sendMessage, setPendingPermissionRequests],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
|
|||||||
@@ -17,17 +17,35 @@ const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
|
|||||||
opencode: 'anthropic/claude-sonnet-4-5',
|
opencode: 'anthropic/claude-sonnet-4-5',
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
|
/**
|
||||||
if (provider === 'codex') {
|
* Fallback permission-mode matrix used only until the backend capability
|
||||||
return ['default', 'acceptEdits', 'bypassPermissions'];
|
* matrix (`GET /api/providers/capabilities`) has loaded. The backend is the
|
||||||
}
|
* source of truth; this mirror exists so the composer renders sensibly on
|
||||||
if (provider === 'claude') {
|
* first paint and when the capabilities request fails.
|
||||||
return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'];
|
*/
|
||||||
}
|
const FALLBACK_PERMISSION_MODES: Record<LLMProvider, PermissionMode[]> = {
|
||||||
if (provider === 'opencode') {
|
claude: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||||
return ['default'];
|
cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||||
}
|
codex: ['default', 'acceptEdits', 'bypassPermissions'],
|
||||||
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||||
|
opencode: ['default'],
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderCapabilities = {
|
||||||
|
provider: LLMProvider;
|
||||||
|
permissionModes: string[];
|
||||||
|
defaultPermissionMode: string;
|
||||||
|
supportsImages: boolean;
|
||||||
|
supportsAbort: boolean;
|
||||||
|
supportsPermissionRequests: boolean;
|
||||||
|
supportsTokenUsage: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderCapabilitiesApiResponse = {
|
||||||
|
success?: boolean;
|
||||||
|
data?: {
|
||||||
|
providers?: ProviderCapabilities[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UseChatProviderStateArgs {
|
interface UseChatProviderStateArgs {
|
||||||
@@ -76,6 +94,17 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
|
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend-owned capability matrix keyed by provider. Drives the permission
|
||||||
|
* mode picker (and is the extension point for future per-provider UI
|
||||||
|
* differences) so the frontend stays free of hardcoded provider branching.
|
||||||
|
* Null until `/api/providers/capabilities` resolves; the static fallback
|
||||||
|
* map covers that window.
|
||||||
|
*/
|
||||||
|
const [providerCapabilities, setProviderCapabilities] = useState<
|
||||||
|
Partial<Record<LLMProvider, ProviderCapabilities>> | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const [providerModelCatalog, setProviderModelCatalog] = useState<
|
const [providerModelCatalog, setProviderModelCatalog] = useState<
|
||||||
Partial<Record<LLMProvider, ProviderModelsDefinition>>
|
Partial<Record<LLMProvider, ProviderModelsDefinition>>
|
||||||
>({});
|
>({});
|
||||||
@@ -181,6 +210,41 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
void loadProviderModels();
|
void loadProviderModels();
|
||||||
}, [loadProviderModels]);
|
}, [loadProviderModels]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadCapabilities = async () => {
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch('/api/providers/capabilities');
|
||||||
|
const body = (await response.json()) as ProviderCapabilitiesApiResponse;
|
||||||
|
if (cancelled || !body.success || !Array.isArray(body.data?.providers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byProvider: Partial<Record<LLMProvider, ProviderCapabilities>> = {};
|
||||||
|
for (const capabilities of body.data.providers) {
|
||||||
|
byProvider[capabilities.provider] = capabilities;
|
||||||
|
}
|
||||||
|
setProviderCapabilities(byProvider);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading provider capabilities:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadCapabilities();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getPermissionModesForProvider = useCallback((targetProvider: LLMProvider): PermissionMode[] => {
|
||||||
|
const capabilityModes = providerCapabilities?.[targetProvider]?.permissionModes;
|
||||||
|
if (capabilityModes && capabilityModes.length > 0) {
|
||||||
|
return capabilityModes as PermissionMode[];
|
||||||
|
}
|
||||||
|
return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default'];
|
||||||
|
}, [providerCapabilities]);
|
||||||
|
|
||||||
const pickStoredOrCurrent = (
|
const pickStoredOrCurrent = (
|
||||||
storageKey: string,
|
storageKey: string,
|
||||||
current: string,
|
current: string,
|
||||||
@@ -269,7 +333,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
|
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
|
||||||
const validModes = getPermissionModesForProvider(provider);
|
const validModes = getPermissionModesForProvider(provider);
|
||||||
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
|
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
|
||||||
}, [selectedSession?.id, provider]);
|
}, [selectedSession?.id, provider, getPermissionModesForProvider]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
|
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
|
||||||
@@ -327,7 +391,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
if (selectedSession?.id) {
|
if (selectedSession?.id) {
|
||||||
localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
|
localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
|
||||||
}
|
}
|
||||||
}, [permissionMode, provider, selectedSession?.id]);
|
}, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]);
|
||||||
|
|
||||||
const selectProviderModel = useCallback(async (
|
const selectProviderModel = useCallback(async (
|
||||||
targetProvider: LLMProvider,
|
targetProvider: LLMProvider,
|
||||||
|
|||||||
@@ -1,73 +1,34 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect } from 'react';
|
||||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||||
|
|
||||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
import type { ServerEvent } from '../../../contexts/WebSocketContext';
|
||||||
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
|
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
|
||||||
import { playChatCompletionSound } from '../../../utils/notificationSound';
|
import { playChatCompletionSound } from '../../../utils/notificationSound';
|
||||||
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
|
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
||||||
|
import type { PendingPermissionRequest } from '../types/types';
|
||||||
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||||
|
|
||||||
type PendingViewSession = {
|
|
||||||
startedAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LatestChatMessage = {
|
|
||||||
type?: string;
|
|
||||||
kind?: string;
|
|
||||||
data?: any;
|
|
||||||
message?: any;
|
|
||||||
delta?: string;
|
|
||||||
sessionId?: string;
|
|
||||||
session_id?: string;
|
|
||||||
requestId?: string;
|
|
||||||
toolName?: string;
|
|
||||||
input?: unknown;
|
|
||||||
context?: unknown;
|
|
||||||
error?: string;
|
|
||||||
tool?: any;
|
|
||||||
toolId?: string;
|
|
||||||
result?: any;
|
|
||||||
exitCode?: number;
|
|
||||||
isProcessing?: boolean;
|
|
||||||
actualSessionId?: string;
|
|
||||||
event?: string;
|
|
||||||
status?: any;
|
|
||||||
isNewSession?: boolean;
|
|
||||||
resultText?: string;
|
|
||||||
isError?: boolean;
|
|
||||||
success?: boolean;
|
|
||||||
reason?: string;
|
|
||||||
provider?: string;
|
|
||||||
content?: string;
|
|
||||||
text?: string;
|
|
||||||
tokens?: number;
|
|
||||||
canInterrupt?: boolean;
|
|
||||||
tokenBudget?: unknown;
|
|
||||||
newSessionId?: string;
|
|
||||||
aborted?: boolean;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UseChatRealtimeHandlersArgs {
|
interface UseChatRealtimeHandlersArgs {
|
||||||
latestMessage: LatestChatMessage | null;
|
subscribe: (listener: (event: ServerEvent) => void) => () => void;
|
||||||
provider: LLMProvider;
|
provider: LLMProvider;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
setCurrentSessionId: (sessionId: string | null) => void;
|
|
||||||
setIsLoading: (loading: boolean) => void;
|
|
||||||
setCanAbortSession: (canAbort: boolean) => void;
|
|
||||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
|
||||||
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
||||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
|
||||||
streamTimerRef: MutableRefObject<number | null>;
|
streamTimerRef: MutableRefObject<number | null>;
|
||||||
accumulatedStreamRef: MutableRefObject<string>;
|
accumulatedStreamRef: MutableRefObject<string>;
|
||||||
onSessionInactive?: (sessionId?: string | null) => void;
|
/**
|
||||||
onSessionActive?: (sessionId?: string | null) => void;
|
* Highest live `seq` observed per session. Essential for reconnect catch-up:
|
||||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
* `chat.subscribe` sends this value as `lastSeq` so the server replays only
|
||||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
* the events this client actually missed. Written here on every sequenced
|
||||||
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
|
* frame; read wherever a `chat.subscribe` is sent (session open, reconnect).
|
||||||
|
*/
|
||||||
|
lastSeqRef: MutableRefObject<Map<string, number>>;
|
||||||
|
/** When each session's `chat.subscribe` was last sent; guards stale idle acks. */
|
||||||
|
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
|
||||||
|
onSessionProcessing?: MarkSessionProcessing;
|
||||||
|
onSessionIdle?: MarkSessionIdle;
|
||||||
onWebSocketReconnect?: () => void;
|
onWebSocketReconnect?: () => void;
|
||||||
sessionStore: SessionStore;
|
sessionStore: SessionStore;
|
||||||
}
|
}
|
||||||
@@ -76,324 +37,259 @@ interface UseChatRealtimeHandlersArgs {
|
|||||||
/* Hook */
|
/* Hook */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes server events into the session store and processing-state map.
|
||||||
|
*
|
||||||
|
* This is intentionally a thin reducer over the unified `kind`-based
|
||||||
|
* protocol: every frame is keyed by the stable app session id, so there is
|
||||||
|
* no session-id handoff, no provider branching, and no navigation here.
|
||||||
|
* Sidebar events (`session_upserted`, `loading_progress`) are handled by
|
||||||
|
* `useProjectsState`, not in this hook.
|
||||||
|
*/
|
||||||
export function useChatRealtimeHandlers({
|
export function useChatRealtimeHandlers({
|
||||||
latestMessage,
|
subscribe,
|
||||||
provider,
|
provider,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
|
||||||
setIsLoading,
|
|
||||||
setCanAbortSession,
|
|
||||||
setClaudeStatus,
|
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
onSessionInactive,
|
lastSeqRef,
|
||||||
onSessionActive,
|
statusCheckSentAtRef,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
onNavigateToSession,
|
|
||||||
onWebSocketReconnect,
|
onWebSocketReconnect,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
}: UseChatRealtimeHandlersArgs) {
|
}: UseChatRealtimeHandlersArgs) {
|
||||||
const paletteOps = usePaletteOps();
|
|
||||||
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!latestMessage) return;
|
const handleEvent = (msg: ServerEvent) => {
|
||||||
if (lastProcessedMessageRef.current === latestMessage) return;
|
if (!msg.kind) {
|
||||||
lastProcessedMessageRef.current = latestMessage;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const activeViewSessionId =
|
const activeViewSessionId = selectedSession?.id || currentSessionId || null;
|
||||||
selectedSession?.id || currentSessionId || null;
|
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
// Record replay progress for every sequenced live event.
|
||||||
/* Legacy messages (no `kind` field) — handle and return */
|
if (sid && typeof msg.seq === 'number') {
|
||||||
/* ---------------------------------------------------------------- */
|
const known = lastSeqRef.current.get(sid) ?? 0;
|
||||||
|
if (msg.seq > known) {
|
||||||
|
lastSeqRef.current.set(sid, msg.seq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const msg = latestMessage as any;
|
switch (msg.kind) {
|
||||||
|
case 'websocket_reconnected':
|
||||||
if (!msg.kind) {
|
|
||||||
const messageType = String(msg.type || '');
|
|
||||||
|
|
||||||
switch (messageType) {
|
|
||||||
case 'websocket-reconnected':
|
|
||||||
onWebSocketReconnect?.();
|
onWebSocketReconnect?.();
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case 'pending-permissions-response': {
|
case 'chat_subscribed': {
|
||||||
const permSessionId = msg.sessionId;
|
// Ack for chat.subscribe: authoritative processing state plus any
|
||||||
const isCurrentPermSession =
|
// pending tool-permission prompts for the run.
|
||||||
permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id);
|
if (!sid) return;
|
||||||
if (permSessionId && !isCurrentPermSession) return;
|
|
||||||
setPendingPermissionRequests(msg.data || []);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'session-status': {
|
|
||||||
const statusSessionId = msg.sessionId;
|
|
||||||
if (!statusSessionId) return;
|
|
||||||
|
|
||||||
const status = msg.status;
|
|
||||||
if (status) {
|
|
||||||
const statusInfo = {
|
|
||||||
text: status.text || 'Working...',
|
|
||||||
tokens: status.tokens || 0,
|
|
||||||
can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
|
|
||||||
};
|
|
||||||
setClaudeStatus(statusInfo);
|
|
||||||
setIsLoading(true);
|
|
||||||
setCanAbortSession(statusInfo.can_interrupt);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy isProcessing format from check-session-status
|
|
||||||
const isCurrentSession =
|
|
||||||
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
|
|
||||||
|
|
||||||
if (msg.isProcessing) {
|
if (msg.isProcessing) {
|
||||||
onSessionActive?.(statusSessionId);
|
onSessionProcessing?.(sid);
|
||||||
onSessionProcessing?.(statusSessionId);
|
} else {
|
||||||
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
|
// Idle ack: ignore it if a newer request started after the
|
||||||
return;
|
// subscribe was sent — the ack describes the older state.
|
||||||
|
onSessionIdle?.(sid, {
|
||||||
|
ifStartedBefore: statusCheckSentAtRef.current.get(sid),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onSessionInactive?.(statusSessionId);
|
const isViewedSession = sid === activeViewSessionId;
|
||||||
onSessionNotProcessing?.(statusSessionId);
|
if (isViewedSession && Array.isArray(msg.pendingPermissions)) {
|
||||||
if (isCurrentSession) {
|
setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]);
|
||||||
setIsLoading(false);
|
|
||||||
setCanAbortSession(false);
|
|
||||||
setClaudeStatus(null);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'protocol_error': {
|
||||||
|
console.error('[Chat] Protocol error:', msg.code, msg.error);
|
||||||
|
if (sid) {
|
||||||
|
// Surface the failure in the conversation and stop the spinner —
|
||||||
|
// the run never started (or was rejected), so no `complete` follows.
|
||||||
|
onSessionIdle?.(sid);
|
||||||
|
sessionStore.appendRealtime(sid, {
|
||||||
|
id: `protocol_error_${Date.now()}`,
|
||||||
|
sessionId: sid,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider,
|
||||||
|
kind: 'error',
|
||||||
|
content: String(msg.error || 'Request failed'),
|
||||||
|
} as NormalizedMessage);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidebar/global events — owned by useProjectsState.
|
||||||
|
case 'session_upserted':
|
||||||
|
case 'loading_progress':
|
||||||
|
return;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Unknown legacy message type — ignore
|
break;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* -------------------------------------------------------------- */
|
||||||
/* NormalizedMessage handling (has `kind` field) */
|
/* Provider NormalizedMessage handling */
|
||||||
/* ---------------------------------------------------------------- */
|
/* -------------------------------------------------------------- */
|
||||||
|
|
||||||
const sid = msg.sessionId || activeViewSessionId;
|
// --- Streaming: buffer for performance ---
|
||||||
|
if (msg.kind === 'stream_delta') {
|
||||||
// --- Streaming: buffer for performance ---
|
const text = (msg.content as string) || '';
|
||||||
if (msg.kind === 'stream_delta') {
|
if (!text) return;
|
||||||
const text = msg.content || '';
|
accumulatedStreamRef.current += text;
|
||||||
if (!text) return;
|
if (!streamTimerRef.current) {
|
||||||
accumulatedStreamRef.current += text;
|
streamTimerRef.current = window.setTimeout(() => {
|
||||||
if (!streamTimerRef.current) {
|
streamTimerRef.current = null;
|
||||||
streamTimerRef.current = window.setTimeout(() => {
|
if (sid) {
|
||||||
streamTimerRef.current = null;
|
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
||||||
if (sid) {
|
}
|
||||||
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
}, 100);
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
// Also route to store for non-active sessions
|
|
||||||
if (sid && sid !== activeViewSessionId) {
|
|
||||||
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.kind === 'stream_end') {
|
|
||||||
if (streamTimerRef.current) {
|
|
||||||
clearTimeout(streamTimerRef.current);
|
|
||||||
streamTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (sid) {
|
|
||||||
if (accumulatedStreamRef.current) {
|
|
||||||
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
|
||||||
}
|
}
|
||||||
sessionStore.finalizeStreaming(sid);
|
// Also route to store for non-active sessions
|
||||||
}
|
if (sid && sid !== activeViewSessionId) {
|
||||||
accumulatedStreamRef.current = '';
|
sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- All other messages: route to store ---
|
|
||||||
const shouldPersist =
|
|
||||||
msg.kind !== 'session_created'
|
|
||||||
&& msg.kind !== 'complete'
|
|
||||||
&& msg.kind !== 'status'
|
|
||||||
&& msg.kind !== 'permission_request'
|
|
||||||
&& msg.kind !== 'permission_cancelled';
|
|
||||||
|
|
||||||
if (sid && shouldPersist) {
|
|
||||||
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- UI side effects for specific kinds ---
|
|
||||||
switch (msg.kind) {
|
|
||||||
case 'session_created': {
|
|
||||||
const newSessionId = msg.newSessionId;
|
|
||||||
if (!newSessionId) break;
|
|
||||||
|
|
||||||
// We no longer synthesize client-side placeholder IDs. Until the provider
|
|
||||||
// announces `session_created`, the active id is expected to be null.
|
|
||||||
if (!currentSessionId) {
|
|
||||||
console.log('Session created with ID:', newSessionId);
|
|
||||||
console.log('Existing session ID:', currentSessionId);
|
|
||||||
setCurrentSessionId(newSessionId);
|
|
||||||
setPendingPermissionRequests((prev) =>
|
|
||||||
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
pendingViewSessionRef.current = null;
|
return;
|
||||||
onSessionActive?.(newSessionId);
|
|
||||||
onSessionProcessing?.(newSessionId);
|
|
||||||
setIsLoading(true);
|
|
||||||
setCanAbortSession(true);
|
|
||||||
setClaudeStatus({
|
|
||||||
text: 'Processing',
|
|
||||||
tokens: 0,
|
|
||||||
can_interrupt: true,
|
|
||||||
});
|
|
||||||
onNavigateToSession?.(newSessionId);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'complete': {
|
if (msg.kind === 'stream_end') {
|
||||||
// Flush any remaining streaming state
|
|
||||||
if (streamTimerRef.current) {
|
if (streamTimerRef.current) {
|
||||||
clearTimeout(streamTimerRef.current);
|
clearTimeout(streamTimerRef.current);
|
||||||
streamTimerRef.current = null;
|
streamTimerRef.current = null;
|
||||||
}
|
}
|
||||||
if (sid && accumulatedStreamRef.current) {
|
if (sid) {
|
||||||
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
if (accumulatedStreamRef.current) {
|
||||||
|
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
||||||
|
}
|
||||||
sessionStore.finalizeStreaming(sid);
|
sessionStore.finalizeStreaming(sid);
|
||||||
}
|
}
|
||||||
accumulatedStreamRef.current = '';
|
accumulatedStreamRef.current = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
// --- All other messages: route to store ---
|
||||||
setCanAbortSession(false);
|
const shouldPersist =
|
||||||
setClaudeStatus(null);
|
msg.kind !== 'complete'
|
||||||
setPendingPermissionRequests([]);
|
&& msg.kind !== 'status'
|
||||||
onSessionInactive?.(sid);
|
&& msg.kind !== 'permission_request'
|
||||||
onSessionNotProcessing?.(sid);
|
&& msg.kind !== 'permission_cancelled';
|
||||||
pendingViewSessionRef.current = null;
|
|
||||||
|
if (sid && shouldPersist) {
|
||||||
|
sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UI side effects for specific kinds ---
|
||||||
|
switch (msg.kind) {
|
||||||
|
case 'complete': {
|
||||||
|
// Flush any remaining streaming state
|
||||||
|
if (streamTimerRef.current) {
|
||||||
|
clearTimeout(streamTimerRef.current);
|
||||||
|
streamTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (sid && accumulatedStreamRef.current) {
|
||||||
|
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
|
||||||
|
sessionStore.finalizeStreaming(sid);
|
||||||
|
}
|
||||||
|
accumulatedStreamRef.current = '';
|
||||||
|
|
||||||
|
// `complete` is the unified terminal event — every provider run ends
|
||||||
|
// with exactly one, regardless of success, failure, or abort. The
|
||||||
|
// indicator derives from the processing map, so deleting the entry
|
||||||
|
// hides it immediately and atomically.
|
||||||
|
onSessionIdle?.(sid);
|
||||||
|
setPendingPermissionRequests([]);
|
||||||
|
|
||||||
|
if (msg.aborted) {
|
||||||
|
// Abort was requested — the complete event confirms it. No
|
||||||
|
// further UI action is needed beyond clearing the entry above.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Celebrate only successful runs (failed runs end with success: false).
|
||||||
|
if (msg.success !== false) {
|
||||||
|
showCompletionTitleIndicator();
|
||||||
|
void playChatCompletionSound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session id is stable for the whole conversation (allocated
|
||||||
|
// before the first send), so the only follow-up is syncing the
|
||||||
|
// viewed conversation with the now-persisted transcript.
|
||||||
|
if (sid && sid === activeViewSessionId) {
|
||||||
|
void sessionStore.refreshFromServer(sid);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle aborted case
|
|
||||||
if (msg.aborted) {
|
|
||||||
// Abort was requested — the complete event confirms it
|
|
||||||
// No special UI action needed beyond clearing loading state above
|
|
||||||
// The backend already sent any abort-related messages
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
showCompletionTitleIndicator();
|
// 'error' is an informational message row, not a terminal event —
|
||||||
void playChatCompletionSound();
|
// providers emit it for mid-run stderr output too. Run teardown is
|
||||||
|
// always signalled by the unified 'complete' that follows.
|
||||||
|
|
||||||
const actualSessionId =
|
case 'permission_request': {
|
||||||
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
if (!msg.requestId) break;
|
||||||
? msg.actualSessionId
|
setPendingPermissionRequests((prev) => {
|
||||||
: null;
|
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
|
||||||
const isVisibleSession =
|
return [...prev, {
|
||||||
Boolean(
|
requestId: msg.requestId as string,
|
||||||
sid
|
toolName: (msg.toolName as string) || 'UnknownTool',
|
||||||
&& sid === activeViewSessionId,
|
input: msg.input,
|
||||||
);
|
context: msg.context,
|
||||||
|
sessionId: sid || null,
|
||||||
if (actualSessionId && sid && actualSessionId !== sid) {
|
receivedAt: new Date(),
|
||||||
sessionStore.replaceSessionId(sid, actualSessionId);
|
}];
|
||||||
|
|
||||||
if (isVisibleSession) {
|
|
||||||
setCurrentSessionId(actualSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVisibleSession) {
|
|
||||||
onNavigateToSession?.(actualSessionId, { replace: true });
|
|
||||||
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'error': {
|
|
||||||
setIsLoading(false);
|
|
||||||
setCanAbortSession(false);
|
|
||||||
setClaudeStatus(null);
|
|
||||||
onSessionInactive?.(sid);
|
|
||||||
onSessionNotProcessing?.(sid);
|
|
||||||
pendingViewSessionRef.current = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'permission_request': {
|
|
||||||
if (!msg.requestId) break;
|
|
||||||
setPendingPermissionRequests((prev) => {
|
|
||||||
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
|
|
||||||
return [...prev, {
|
|
||||||
requestId: msg.requestId,
|
|
||||||
toolName: msg.toolName || 'UnknownTool',
|
|
||||||
input: msg.input,
|
|
||||||
context: msg.context,
|
|
||||||
sessionId: sid || null,
|
|
||||||
receivedAt: new Date(),
|
|
||||||
}];
|
|
||||||
});
|
|
||||||
setIsLoading(true);
|
|
||||||
setCanAbortSession(true);
|
|
||||||
setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'permission_cancelled': {
|
|
||||||
if (msg.requestId) {
|
|
||||||
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'status': {
|
|
||||||
if (msg.text === 'token_budget' && msg.tokenBudget) {
|
|
||||||
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
|
|
||||||
} else if (msg.text) {
|
|
||||||
setClaudeStatus({
|
|
||||||
text: msg.text,
|
|
||||||
tokens: msg.tokens || 0,
|
|
||||||
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
|
|
||||||
});
|
});
|
||||||
setIsLoading(true);
|
if (sid) {
|
||||||
setCanAbortSession(msg.canInterrupt !== false);
|
onSessionProcessing?.(sid);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// text, tool_use, tool_result, thinking, interactive_prompt, task_notification
|
case 'permission_cancelled': {
|
||||||
// → already routed to store above, no UI side effects needed
|
if (msg.requestId) {
|
||||||
default:
|
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
|
||||||
break;
|
}
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'status': {
|
||||||
|
if (msg.text === 'token_budget' && msg.tokenBudget) {
|
||||||
|
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
|
||||||
|
} else if (msg.text && sid) {
|
||||||
|
onSessionProcessing?.(sid, {
|
||||||
|
statusText: msg.text as string,
|
||||||
|
canInterrupt: msg.canInterrupt !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// text, tool_use, tool_result, thinking, interactive_prompt, task_notification
|
||||||
|
// → already routed to store above, no UI side effects needed
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return subscribe(handleEvent);
|
||||||
}, [
|
}, [
|
||||||
latestMessage,
|
subscribe,
|
||||||
provider,
|
provider,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
|
||||||
setIsLoading,
|
|
||||||
setCanAbortSession,
|
|
||||||
setClaudeStatus,
|
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
onSessionInactive,
|
lastSeqRef,
|
||||||
onSessionActive,
|
statusCheckSentAtRef,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
onNavigateToSession,
|
|
||||||
onWebSocketReconnect,
|
onWebSocketReconnect,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
paletteOps,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
|||||||
import type { MutableRefObject } from 'react';
|
import type { MutableRefObject } from 'react';
|
||||||
|
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection';
|
||||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||||
import type { ChatMessage, Provider } from '../types/types';
|
import type { ChatMessage, Provider } from '../types/types';
|
||||||
@@ -12,10 +13,6 @@ import { normalizedToChatMessages } from './useChatMessages';
|
|||||||
const MESSAGES_PER_PAGE = 20;
|
const MESSAGES_PER_PAGE = 20;
|
||||||
const INITIAL_VISIBLE_MESSAGES = 100;
|
const INITIAL_VISIBLE_MESSAGES = 100;
|
||||||
|
|
||||||
type PendingViewSession = {
|
|
||||||
startedAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UseChatSessionStateArgs {
|
interface UseChatSessionStateArgs {
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
@@ -24,9 +21,13 @@ interface UseChatSessionStateArgs {
|
|||||||
autoScrollToBottom?: boolean;
|
autoScrollToBottom?: boolean;
|
||||||
externalMessageUpdate?: number;
|
externalMessageUpdate?: number;
|
||||||
newSessionTrigger?: number;
|
newSessionTrigger?: number;
|
||||||
processingSessions?: Set<string>;
|
processingSessions?: SessionActivityMap;
|
||||||
|
onSessionIdle?: MarkSessionIdle;
|
||||||
resetStreamingState: () => void;
|
resetStreamingState: () => void;
|
||||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
/** When each session's `chat.subscribe` was last sent; guards stale idle acks. */
|
||||||
|
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
|
||||||
|
/** Highest live seq observed per session; sent as `lastSeq` on subscribe. */
|
||||||
|
lastSeqRef: MutableRefObject<Map<string, number>>;
|
||||||
sessionStore: SessionStore;
|
sessionStore: SessionStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,21 +100,20 @@ export function useChatSessionState({
|
|||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
|
onSessionIdle,
|
||||||
resetStreamingState,
|
resetStreamingState,
|
||||||
pendingViewSessionRef,
|
statusCheckSentAtRef,
|
||||||
|
lastSeqRef,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
}: UseChatSessionStateArgs) {
|
}: UseChatSessionStateArgs) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
|
||||||
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
||||||
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
|
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
|
||||||
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
||||||
const [totalMessages, setTotalMessages] = useState(0);
|
const [totalMessages, setTotalMessages] = useState(0);
|
||||||
const [canAbortSession, setCanAbortSession] = useState(false);
|
|
||||||
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
||||||
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
|
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
|
||||||
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
|
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
|
||||||
const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
|
|
||||||
const [allMessagesLoaded, setAllMessagesLoaded] = useState(false);
|
const [allMessagesLoaded, setAllMessagesLoaded] = useState(false);
|
||||||
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
|
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
|
||||||
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
|
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
|
||||||
@@ -170,13 +170,8 @@ export function useChatSessionState({
|
|||||||
* - No coupling to unrelated external update signals.
|
* - No coupling to unrelated external update signals.
|
||||||
*/
|
*/
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
pendingViewSessionRef.current = null;
|
|
||||||
setClaudeStatus(null);
|
|
||||||
setCanAbortSession(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
setPendingUserMessage(null);
|
setPendingUserMessage(null);
|
||||||
sessionStorage.removeItem('cursorSessionId');
|
|
||||||
messagesOffsetRef.current = 0;
|
messagesOffsetRef.current = 0;
|
||||||
setHasMoreMessages(false);
|
setHasMoreMessages(false);
|
||||||
setTotalMessages(0);
|
setTotalMessages(0);
|
||||||
@@ -204,13 +199,30 @@ export function useChatSessionState({
|
|||||||
clearTimeout(loadAllFinishedTimerRef.current);
|
clearTimeout(loadAllFinishedTimerRef.current);
|
||||||
loadAllFinishedTimerRef.current = null;
|
loadAllFinishedTimerRef.current = null;
|
||||||
}
|
}
|
||||||
}, [newSessionTrigger, pendingViewSessionRef, resetStreamingState]);
|
}, [newSessionTrigger, onSessionIdle, resetStreamingState]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Derive processing state for the viewed session */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const activeSessionId = selectedSession?.id || currentSessionId || null;
|
||||||
|
|
||||||
|
// The activity indicator always reflects the latest status of the session
|
||||||
|
// being viewed — never stale local UI state from the last time it was
|
||||||
|
// open. Session ids are concrete before any send, so no pending
|
||||||
|
// placeholder entry exists anymore.
|
||||||
|
const sessionActivity = (activeSessionId && processingSessions?.get(activeSessionId)) || null;
|
||||||
|
const isProcessing = sessionActivity !== null;
|
||||||
|
const canAbortSession = isProcessing && sessionActivity.canInterrupt;
|
||||||
|
|
||||||
|
// Ref mirror so effects can read the latest map without re-running on
|
||||||
|
// every activity transition.
|
||||||
|
const processingSessionsRef = useRef(processingSessions);
|
||||||
|
processingSessionsRef.current = processingSessions;
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Derive chatMessages from the store */
|
/* Derive chatMessages from the store */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
const activeSessionId = selectedSession?.id || currentSessionId || null;
|
|
||||||
const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);
|
const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);
|
||||||
const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null);
|
const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null);
|
||||||
|
|
||||||
@@ -429,19 +441,15 @@ export function useChatSessionState({
|
|||||||
// Main session loading effect — store-based
|
// Main session loading effect — store-based
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSession || !selectedProject) {
|
if (!selectedSession || !selectedProject) {
|
||||||
// A new provider run can be in flight before the router has a canonical
|
// A freshly created session can be mid-run before the router has a
|
||||||
// selectedSession. Keep the processing banner alive until complete/error.
|
// canonical selectedSession (the URL effect synthesizes one on the
|
||||||
if (pendingViewSessionRef.current) {
|
// next render). Keep the active view intact instead of wiping it.
|
||||||
|
if (currentSessionId && processingSessionsRef.current?.has(currentSessionId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
pendingViewSessionRef.current = null;
|
|
||||||
setClaudeStatus(null);
|
|
||||||
setCanAbortSession(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
sessionStorage.removeItem('cursorSessionId');
|
|
||||||
messagesOffsetRef.current = 0;
|
messagesOffsetRef.current = 0;
|
||||||
setHasMoreMessages(false);
|
setHasMoreMessages(false);
|
||||||
setTotalMessages(0);
|
setTotalMessages(0);
|
||||||
@@ -461,9 +469,6 @@ export function useChatSessionState({
|
|||||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
||||||
if (sessionChanged) {
|
if (sessionChanged) {
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
pendingViewSessionRef.current = null;
|
|
||||||
setClaudeStatus(null);
|
|
||||||
setCanAbortSession(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset pagination/scroll state
|
// Reset pagination/scroll state
|
||||||
@@ -482,17 +487,24 @@ export function useChatSessionState({
|
|||||||
|
|
||||||
if (sessionChanged) {
|
if (sessionChanged) {
|
||||||
setTokenBudget(null);
|
setTokenBudget(null);
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentSessionId(selectedSession.id);
|
setCurrentSessionId(selectedSession.id);
|
||||||
if (provider === 'cursor') {
|
|
||||||
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check session status
|
// Subscribe to the session's live run (if any): the ack reconciles the
|
||||||
|
// processing indicator, re-attaches a mid-flight stream to this socket,
|
||||||
|
// and replays any live events missed since `lastSeq`. Recording the send
|
||||||
|
// time lets the ack handler discard idle acks that a newer request has
|
||||||
|
// since outdated.
|
||||||
if (ws) {
|
if (ws) {
|
||||||
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });
|
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
|
||||||
|
sendMessage({
|
||||||
|
type: 'chat.subscribe',
|
||||||
|
sessions: [{
|
||||||
|
sessionId: selectedSession.id,
|
||||||
|
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
|
||||||
|
}],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
lastLoadedSessionKeyRef.current = sessionKey;
|
lastLoadedSessionKeyRef.current = sessionKey;
|
||||||
@@ -516,11 +528,12 @@ export function useChatSessionState({
|
|||||||
setIsLoadingSessionMessages(false);
|
setIsLoadingSessionMessages(false);
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
pendingViewSessionRef,
|
|
||||||
resetStreamingState,
|
resetStreamingState,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession?.id,
|
selectedSession?.id,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
statusCheckSentAtRef,
|
||||||
|
lastSeqRef,
|
||||||
ws,
|
ws,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
]);
|
]);
|
||||||
@@ -534,7 +547,7 @@ export function useChatSessionState({
|
|||||||
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||||
|
|
||||||
// Skip store refresh during active streaming
|
// Skip store refresh during active streaming
|
||||||
if (!isLoading) {
|
if (!isProcessing) {
|
||||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||||
provider: (selectedSession.__provider || provider) as LLMProvider,
|
provider: (selectedSession.__provider || provider) as LLMProvider,
|
||||||
projectId: selectedProject.projectId,
|
projectId: selectedProject.projectId,
|
||||||
@@ -559,7 +572,7 @@ export function useChatSessionState({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
isLoading,
|
isProcessing,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Search navigation target
|
// Search navigation target
|
||||||
@@ -726,16 +739,6 @@ export function useChatSessionState({
|
|||||||
return () => container.removeEventListener('scroll', handleScroll);
|
return () => container.removeEventListener('scroll', handleScroll);
|
||||||
}, [handleScroll]);
|
}, [handleScroll]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const activeViewSessionId = selectedSession?.id || currentSessionId;
|
|
||||||
if (!activeViewSessionId || !processingSessions) return;
|
|
||||||
const shouldBeProcessing = processingSessions.has(activeViewSessionId);
|
|
||||||
if (shouldBeProcessing && !isLoading) {
|
|
||||||
setIsLoading(true);
|
|
||||||
setCanAbortSession(true);
|
|
||||||
}
|
|
||||||
}, [currentSessionId, isLoading, processingSessions, selectedSession?.id]);
|
|
||||||
|
|
||||||
// "Load all" overlay
|
// "Load all" overlay
|
||||||
const prevLoadingRef = useRef(false);
|
const prevLoadingRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -817,16 +820,15 @@ export function useChatSessionState({
|
|||||||
addMessage,
|
addMessage,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
rewindMessages,
|
rewindMessages,
|
||||||
isLoading,
|
sessionActivity,
|
||||||
setIsLoading,
|
isProcessing,
|
||||||
|
canAbortSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
isLoadingSessionMessages,
|
isLoadingSessionMessages,
|
||||||
isLoadingMoreMessages,
|
isLoadingMoreMessages,
|
||||||
hasMoreMessages,
|
hasMoreMessages,
|
||||||
totalMessages,
|
totalMessages,
|
||||||
canAbortSession,
|
|
||||||
setCanAbortSession,
|
|
||||||
isUserScrolledUp,
|
isUserScrolledUp,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
@@ -839,8 +841,6 @@ export function useChatSessionState({
|
|||||||
isLoadingAllMessages,
|
isLoadingAllMessages,
|
||||||
loadAllJustFinished,
|
loadAllJustFinished,
|
||||||
showLoadAllOverlay,
|
showLoadAllOverlay,
|
||||||
claudeStatus,
|
|
||||||
setClaudeStatus,
|
|
||||||
createDiff,
|
createDiff,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||||
|
import type {
|
||||||
|
MarkSessionIdle,
|
||||||
|
MarkSessionProcessing,
|
||||||
|
SessionActivityMap,
|
||||||
|
} from '../../../hooks/useSessionProtection';
|
||||||
|
|
||||||
export type Provider = LLMProvider;
|
export type Provider = LLMProvider;
|
||||||
|
|
||||||
@@ -102,20 +107,24 @@ export type SessionNavigationOptions = {
|
|||||||
replace?: boolean;
|
replace?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SessionEstablishedContext = {
|
||||||
|
provider: LLMProvider;
|
||||||
|
project: Project;
|
||||||
|
summary?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ChatInterfaceProps {
|
export interface ChatInterfaceProps {
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
ws: WebSocket | null;
|
ws: WebSocket | null;
|
||||||
sendMessage: (message: unknown) => void;
|
sendMessage: (message: unknown) => void;
|
||||||
latestMessage: any;
|
|
||||||
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
||||||
onInputFocusChange?: (focused: boolean) => void;
|
onInputFocusChange?: (focused: boolean) => void;
|
||||||
onSessionActive?: (sessionId?: string | null) => void;
|
onSessionProcessing?: MarkSessionProcessing;
|
||||||
onSessionInactive?: (sessionId?: string | null) => void;
|
onSessionIdle?: MarkSessionIdle;
|
||||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
processingSessions?: SessionActivityMap;
|
||||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
|
||||||
processingSessions?: Set<string>;
|
|
||||||
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||||
|
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
autoExpandTools?: boolean;
|
autoExpandTools?: boolean;
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||||
|
import { useWebSocket } from '../../../contexts/WebSocketContext';
|
||||||
import PermissionContext from '../../../contexts/PermissionContext';
|
import PermissionContext from '../../../contexts/PermissionContext';
|
||||||
import { QuickSettingsPanel } from '../../quick-settings-panel';
|
import { QuickSettingsPanel } from '../../quick-settings-panel';
|
||||||
import type { ChatInterfaceProps, Provider } from '../types/types';
|
import type { ChatInterfaceProps, Provider } from '../types/types';
|
||||||
@@ -17,24 +18,18 @@ import ChatComposer from './subcomponents/ChatComposer';
|
|||||||
import CommandResultModal from './subcomponents/CommandResultModal';
|
import CommandResultModal from './subcomponents/CommandResultModal';
|
||||||
|
|
||||||
|
|
||||||
type PendingViewSession = {
|
|
||||||
startedAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ChatInterface({
|
function ChatInterface({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
ws,
|
ws,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
latestMessage,
|
|
||||||
onFileOpen,
|
onFileOpen,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
onSessionActive,
|
|
||||||
onSessionInactive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
|
onSessionEstablished,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
autoExpandTools,
|
autoExpandTools,
|
||||||
showRawParameters,
|
showRawParameters,
|
||||||
@@ -46,12 +41,19 @@ function ChatInterface({
|
|||||||
onShowAllTasks,
|
onShowAllTasks,
|
||||||
}: ChatInterfaceProps) {
|
}: ChatInterfaceProps) {
|
||||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
||||||
|
const { subscribe } = useWebSocket();
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
|
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
const streamTimerRef = useRef<number | null>(null);
|
const streamTimerRef = useRef<number | null>(null);
|
||||||
const accumulatedStreamRef = useRef('');
|
const accumulatedStreamRef = useRef('');
|
||||||
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
|
// When each session's `chat.subscribe` was last sent; idle acks older than
|
||||||
|
// a later local request are discarded as stale.
|
||||||
|
const statusCheckSentAtRef = useRef(new Map<string, number>());
|
||||||
|
// Highest live `seq` observed per session. Written by the realtime handler
|
||||||
|
// on every sequenced frame, read whenever a `chat.subscribe` is sent so the
|
||||||
|
// server replays only the events this client actually missed.
|
||||||
|
const lastSeqRef = useRef(new Map<string, number>());
|
||||||
|
|
||||||
const resetStreamingState = useCallback(() => {
|
const resetStreamingState = useCallback(() => {
|
||||||
if (streamTimerRef.current) {
|
if (streamTimerRef.current) {
|
||||||
@@ -92,16 +94,15 @@ function ChatInterface({
|
|||||||
const {
|
const {
|
||||||
chatMessages,
|
chatMessages,
|
||||||
addMessage,
|
addMessage,
|
||||||
isLoading,
|
sessionActivity,
|
||||||
setIsLoading,
|
isProcessing,
|
||||||
|
canAbortSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
isLoadingSessionMessages,
|
isLoadingSessionMessages,
|
||||||
isLoadingMoreMessages,
|
isLoadingMoreMessages,
|
||||||
hasMoreMessages,
|
hasMoreMessages,
|
||||||
totalMessages,
|
totalMessages,
|
||||||
canAbortSession,
|
|
||||||
setCanAbortSession,
|
|
||||||
isUserScrolledUp,
|
isUserScrolledUp,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
@@ -114,8 +115,6 @@ function ChatInterface({
|
|||||||
isLoadingAllMessages,
|
isLoadingAllMessages,
|
||||||
loadAllJustFinished,
|
loadAllJustFinished,
|
||||||
showLoadAllOverlay,
|
showLoadAllOverlay,
|
||||||
claudeStatus,
|
|
||||||
setClaudeStatus,
|
|
||||||
createDiff,
|
createDiff,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
@@ -130,11 +129,22 @@ function ChatInterface({
|
|||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
|
onSessionIdle,
|
||||||
resetStreamingState,
|
resetStreamingState,
|
||||||
pendingViewSessionRef,
|
statusCheckSentAtRef,
|
||||||
|
lastSeqRef,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Brand-new conversation: the composer allocated a stable session id via
|
||||||
|
// the session gateway before the first send. Record it locally and put it
|
||||||
|
// in the URL — this id never changes again, so there is no later handoff.
|
||||||
|
const handleSessionEstablished = useCallback<NonNullable<ChatInterfaceProps['onSessionEstablished']>>((sessionId, context) => {
|
||||||
|
setCurrentSessionId(sessionId);
|
||||||
|
onSessionEstablished?.(sessionId, context);
|
||||||
|
onNavigateToSession?.(sessionId);
|
||||||
|
}, [setCurrentSessionId, onSessionEstablished, onNavigateToSession]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
input,
|
input,
|
||||||
setInput,
|
setInput,
|
||||||
@@ -191,66 +201,67 @@ function ChatInterface({
|
|||||||
codexModel,
|
codexModel,
|
||||||
geminiModel,
|
geminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
isLoading,
|
isLoading: isProcessing,
|
||||||
canAbortSession,
|
canAbortSession,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
onSessionActive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
|
onSessionEstablished: handleSessionEstablished,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
onFileOpen,
|
onFileOpen,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
pendingViewSessionRef,
|
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
addMessage,
|
addMessage,
|
||||||
setIsLoading,
|
|
||||||
setCanAbortSession,
|
|
||||||
setClaudeStatus,
|
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
});
|
});
|
||||||
|
|
||||||
// On WebSocket reconnect, re-fetch the current session's messages from the server
|
// On WebSocket reconnect, re-fetch the current session's messages from the
|
||||||
// so missed streaming events are shown. Also reset isLoading.
|
// server so missed streaming events are shown, then re-subscribe — the
|
||||||
|
// `chat_subscribed` ack restores or clears the activity indicator, replays
|
||||||
|
// missed live events, and re-attaches a still-running stream to this socket.
|
||||||
const handleWebSocketReconnect = useCallback(async () => {
|
const handleWebSocketReconnect = useCallback(async () => {
|
||||||
if (!selectedProject || !selectedSession) return;
|
if (!selectedProject || !selectedSession) return;
|
||||||
const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
|
const providerVal =
|
||||||
|
selectedSession.__provider
|
||||||
|
|| (localStorage.getItem('selected-provider') as LLMProvider)
|
||||||
|
|| 'claude';
|
||||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||||
provider: (selectedSession.__provider || providerVal) as LLMProvider,
|
provider: providerVal as LLMProvider,
|
||||||
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
|
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
|
||||||
projectId: selectedProject.projectId,
|
projectId: selectedProject.projectId,
|
||||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||||
});
|
});
|
||||||
setIsLoading(false);
|
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
|
||||||
setCanAbortSession(false);
|
sendMessage({
|
||||||
}, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]);
|
type: 'chat.subscribe',
|
||||||
|
sessions: [{
|
||||||
|
sessionId: selectedSession.id,
|
||||||
|
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}, [selectedProject, selectedSession, sendMessage, sessionStore]);
|
||||||
|
|
||||||
useChatRealtimeHandlers({
|
useChatRealtimeHandlers({
|
||||||
latestMessage,
|
subscribe,
|
||||||
provider,
|
provider,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
|
||||||
setIsLoading,
|
|
||||||
setCanAbortSession,
|
|
||||||
setClaudeStatus,
|
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
onSessionInactive,
|
lastSeqRef,
|
||||||
onSessionActive,
|
statusCheckSentAtRef,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
onNavigateToSession,
|
|
||||||
onWebSocketReconnect: handleWebSocketReconnect,
|
onWebSocketReconnect: handleWebSocketReconnect,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading || !canAbortSession) {
|
if (!canAbortSession) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +278,7 @@ function ChatInterface({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
|
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
|
||||||
};
|
};
|
||||||
}, [canAbortSession, handleAbortSession, isLoading]);
|
}, [canAbortSession, handleAbortSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -362,10 +373,9 @@ function ChatInterface({
|
|||||||
pendingPermissionRequests={pendingPermissionRequests}
|
pendingPermissionRequests={pendingPermissionRequests}
|
||||||
handlePermissionDecision={handlePermissionDecision}
|
handlePermissionDecision={handlePermissionDecision}
|
||||||
handleGrantToolPermission={handleGrantToolPermission}
|
handleGrantToolPermission={handleGrantToolPermission}
|
||||||
claudeStatus={claudeStatus}
|
activity={sessionActivity}
|
||||||
isLoading={isLoading}
|
isLoading={isProcessing}
|
||||||
onAbortSession={handleAbortSession}
|
onAbortSession={handleAbortSession}
|
||||||
provider={provider}
|
|
||||||
permissionMode={permissionMode}
|
permissionMode={permissionMode}
|
||||||
onModeSwitch={cyclePermissionMode}
|
onModeSwitch={cyclePermissionMode}
|
||||||
tokenBudget={tokenBudget}
|
tokenBudget={tokenBudget}
|
||||||
|
|||||||
80
src/components/chat/view/subcomponents/ActivityIndicator.tsx
Normal file
80
src/components/chat/view/subcomponents/ActivityIndicator.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Shimmer } from '../../../../shared/view/ui';
|
||||||
|
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
||||||
|
|
||||||
|
type ActivityIndicatorProps = {
|
||||||
|
activity: SessionActivity | null;
|
||||||
|
onAbort?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_KEYS = [
|
||||||
|
'claudeStatus.actions.thinking',
|
||||||
|
'claudeStatus.actions.processing',
|
||||||
|
'claudeStatus.actions.analyzing',
|
||||||
|
'claudeStatus.actions.working',
|
||||||
|
'claudeStatus.actions.computing',
|
||||||
|
'claudeStatus.actions.reasoning',
|
||||||
|
];
|
||||||
|
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal response-in-progress indicator, in the spirit of the inline status
|
||||||
|
* lines in Claude Code / Codex / OpenCode: a shimmering activity label, the
|
||||||
|
* elapsed time, and an interrupt affordance. Rendered only while the viewed
|
||||||
|
* session has an entry in the processing map; it disappears the instant that
|
||||||
|
* entry is removed.
|
||||||
|
*/
|
||||||
|
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
const startedAt = activity?.startedAt ?? null;
|
||||||
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (startedAt === null) return;
|
||||||
|
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
|
||||||
|
update();
|
||||||
|
const timer = setInterval(update, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [startedAt]);
|
||||||
|
|
||||||
|
if (!activity) return null;
|
||||||
|
|
||||||
|
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
||||||
|
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
|
||||||
|
.replace(/\.+$/, '');
|
||||||
|
|
||||||
|
const minutes = Math.floor(elapsedSeconds / 60);
|
||||||
|
const seconds = elapsedSeconds % 60;
|
||||||
|
const elapsedLabel = minutes < 1
|
||||||
|
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
|
||||||
|
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-in fade-in mb-2 w-full duration-300">
|
||||||
|
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1">
|
||||||
|
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
|
||||||
|
<Shimmer className="text-xs font-medium">{`${label}…`}</Shimmer>
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
|
||||||
|
|
||||||
|
{activity.canInterrupt && onAbort && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAbort}
|
||||||
|
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
|
||||||
|
>
|
||||||
|
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
|
||||||
|
<rect x="5" y="5" width="14" height="14" rx="2" />
|
||||||
|
</svg>
|
||||||
|
<span>{t('claudeStatus.stop', { defaultValue: 'Stop' })}</span>
|
||||||
|
<kbd className="hidden rounded border border-border/60 px-1 text-[10px] text-muted-foreground/70 sm:inline-block">
|
||||||
|
esc
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ import type {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
|
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
|
||||||
|
|
||||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
||||||
|
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
|
||||||
import {
|
import {
|
||||||
PromptInput,
|
PromptInput,
|
||||||
PromptInputHeader,
|
PromptInputHeader,
|
||||||
@@ -24,7 +25,7 @@ import {
|
|||||||
} from '../../../../shared/view/ui';
|
} from '../../../../shared/view/ui';
|
||||||
|
|
||||||
import CommandMenu from './CommandMenu';
|
import CommandMenu from './CommandMenu';
|
||||||
import ClaudeStatus from './ClaudeStatus';
|
import ActivityIndicator from './ActivityIndicator';
|
||||||
import ImageAttachment from './ImageAttachment';
|
import ImageAttachment from './ImageAttachment';
|
||||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||||
import TokenUsageSummary from './TokenUsageSummary';
|
import TokenUsageSummary from './TokenUsageSummary';
|
||||||
@@ -51,10 +52,9 @@ interface ChatComposerProps {
|
|||||||
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
|
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
|
||||||
) => void;
|
) => void;
|
||||||
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
||||||
claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
|
activity: SessionActivity | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onAbortSession: () => void;
|
onAbortSession: () => void;
|
||||||
provider: Provider | string;
|
|
||||||
permissionMode: PermissionMode | string;
|
permissionMode: PermissionMode | string;
|
||||||
onModeSwitch: () => void;
|
onModeSwitch: () => void;
|
||||||
tokenBudget: Record<string, unknown> | null;
|
tokenBudget: Record<string, unknown> | null;
|
||||||
@@ -105,10 +105,9 @@ export default function ChatComposer({
|
|||||||
pendingPermissionRequests,
|
pendingPermissionRequests,
|
||||||
handlePermissionDecision,
|
handlePermissionDecision,
|
||||||
handleGrantToolPermission,
|
handleGrantToolPermission,
|
||||||
claudeStatus,
|
activity,
|
||||||
isLoading,
|
isLoading,
|
||||||
onAbortSession,
|
onAbortSession,
|
||||||
provider,
|
|
||||||
permissionMode,
|
permissionMode,
|
||||||
onModeSwitch,
|
onModeSwitch,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
@@ -173,12 +172,7 @@ export default function ChatComposer({
|
|||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
|
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
|
||||||
{!hasPendingPermissions && (
|
{!hasPendingPermissions && (
|
||||||
<ClaudeStatus
|
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
|
||||||
status={claudeStatus}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onAbort={onAbortSession}
|
|
||||||
provider={provider}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pendingPermissionRequests.length > 0 && (
|
{pendingPermissionRequests.length > 0 && (
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { cn } from '../../../../lib/utils';
|
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
|
||||||
|
|
||||||
type ClaudeStatusProps = {
|
|
||||||
status: {
|
|
||||||
text?: string;
|
|
||||||
tokens?: number;
|
|
||||||
can_interrupt?: boolean;
|
|
||||||
} | null;
|
|
||||||
onAbort?: () => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
provider?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ACTION_KEYS = [
|
|
||||||
'claudeStatus.actions.thinking',
|
|
||||||
'claudeStatus.actions.processing',
|
|
||||||
'claudeStatus.actions.analyzing',
|
|
||||||
'claudeStatus.actions.working',
|
|
||||||
'claudeStatus.actions.computing',
|
|
||||||
'claudeStatus.actions.reasoning',
|
|
||||||
];
|
|
||||||
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
|
||||||
|
|
||||||
const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
|
||||||
claude: 'messageTypes.claude',
|
|
||||||
codex: 'messageTypes.codex',
|
|
||||||
cursor: 'messageTypes.cursor',
|
|
||||||
gemini: 'messageTypes.gemini',
|
|
||||||
opencode: 'messageTypes.opencode',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatElapsedTime(totalSeconds: number) {
|
|
||||||
const mins = Math.floor(totalSeconds / 60);
|
|
||||||
const secs = totalSeconds % 60;
|
|
||||||
return mins < 1 ? `${secs}s` : `${mins}m ${secs}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ClaudeStatus({
|
|
||||||
status,
|
|
||||||
onAbort,
|
|
||||||
isLoading,
|
|
||||||
provider = 'claude',
|
|
||||||
}: ClaudeStatusProps) {
|
|
||||||
const { t } = useTranslation('chat');
|
|
||||||
const [elapsedTime, setElapsedTime] = useState(0);
|
|
||||||
const [dots, setDots] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoading) {
|
|
||||||
setElapsedTime(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const startTime = Date.now();
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
|
|
||||||
}, 1000);
|
|
||||||
const dotTimer = setInterval(() => {
|
|
||||||
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(timer);
|
|
||||||
clearInterval(dotTimer);
|
|
||||||
};
|
|
||||||
}, [isLoading]);
|
|
||||||
|
|
||||||
if (!isLoading && !status) return null;
|
|
||||||
|
|
||||||
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
|
||||||
const statusText = (status?.text || actionWords[Math.floor(elapsedTime / 3) % actionWords.length]).replace(/[.]+$/, '');
|
|
||||||
|
|
||||||
const providerLabel = t(PROVIDER_LABEL_KEYS[provider] || 'claudeStatus.providers.assistant', { defaultValue: 'Assistant' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-2 mb-3 w-full duration-500">
|
|
||||||
<div className="mx-auto flex max-w-4xl items-center justify-between gap-3 overflow-hidden rounded-full border border-border/50 bg-slate-100 px-3 py-1.5 shadow-sm backdrop-blur-md dark:bg-slate-900">
|
|
||||||
|
|
||||||
{/* Left Side: Identity & Status */}
|
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
|
||||||
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/20 ring-1 ring-primary/10">
|
|
||||||
<SessionProviderLogo provider={provider} className="h-3.5 w-3.5" />
|
|
||||||
{isLoading && (
|
|
||||||
<span className="absolute inset-0 animate-pulse rounded-full ring-2 ring-emerald-500/20" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col sm:flex-row sm:items-center sm:gap-2">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70">
|
|
||||||
{providerLabel}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className={cn("h-1.5 w-1.5 rounded-full", isLoading ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
|
||||||
<p className="truncate text-xs font-medium text-foreground">
|
|
||||||
{statusText}<span className="inline-block w-4 text-primary">{isLoading ? dots : ''}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Side: Metrics & Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isLoading && status?.can_interrupt !== false && onAbort && (
|
|
||||||
<>
|
|
||||||
<div className="hidden items-center rounded-md bg-muted/50 px-2 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground sm:flex">
|
|
||||||
{formatElapsedTime(elapsedTime)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onAbort}
|
|
||||||
className="group flex items-center gap-1.5 rounded-full bg-destructive/10 px-2.5 py-1 text-[10px] font-bold text-destructive transition-all hover:bg-destructive hover:text-destructive-foreground"
|
|
||||||
>
|
|
||||||
<svg className="h-3 w-3 fill-current" viewBox="0 0 24 24">
|
|
||||||
<path d="M6 6h12v12H6z" />
|
|
||||||
</svg>
|
|
||||||
<span className="hidden sm:inline">STOP</span>
|
|
||||||
<kbd className="hidden rounded bg-black/10 px-1 text-[9px] group-hover:bg-white/20 sm:block">
|
|
||||||
ESC
|
|
||||||
</kbd>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
import type { AppTab, Project, ProjectSession } from '../../../types/app';
|
import type { AppTab, Project, ProjectSession } from '../../../types/app';
|
||||||
import type { SessionNavigationOptions } from '../../chat/types/types';
|
import type {
|
||||||
|
MarkSessionIdle,
|
||||||
export type SessionLifecycleHandler = (sessionId?: string | null) => void;
|
MarkSessionProcessing,
|
||||||
|
SessionActivityMap,
|
||||||
|
} from '../../../hooks/useSessionProtection';
|
||||||
|
import type { SessionEstablishedContext, SessionNavigationOptions } from '../../chat/types/types';
|
||||||
|
|
||||||
export type TaskMasterTask = {
|
export type TaskMasterTask = {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@@ -41,17 +44,15 @@ export type MainContentProps = {
|
|||||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||||
ws: WebSocket | null;
|
ws: WebSocket | null;
|
||||||
sendMessage: (message: unknown) => void;
|
sendMessage: (message: unknown) => void;
|
||||||
latestMessage: unknown;
|
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onInputFocusChange: (focused: boolean) => void;
|
onInputFocusChange: (focused: boolean) => void;
|
||||||
onSessionActive: SessionLifecycleHandler;
|
onSessionProcessing: MarkSessionProcessing;
|
||||||
onSessionInactive: SessionLifecycleHandler;
|
onSessionIdle: MarkSessionIdle;
|
||||||
onSessionProcessing: SessionLifecycleHandler;
|
processingSessions: SessionActivityMap;
|
||||||
onSessionNotProcessing: SessionLifecycleHandler;
|
|
||||||
processingSessions: Set<string>;
|
|
||||||
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||||
|
onSessionEstablished: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||||
onShowSettings: () => void;
|
onShowSettings: () => void;
|
||||||
externalMessageUpdate: number;
|
externalMessageUpdate: number;
|
||||||
newSessionTrigger: number;
|
newSessionTrigger: number;
|
||||||
|
|||||||
@@ -37,17 +37,15 @@ function MainContent({
|
|||||||
setActiveTab,
|
setActiveTab,
|
||||||
ws,
|
ws,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
latestMessage,
|
|
||||||
isMobile,
|
isMobile,
|
||||||
onMenuClick,
|
onMenuClick,
|
||||||
isLoading,
|
isLoading,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
onSessionActive,
|
|
||||||
onSessionInactive,
|
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionIdle,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
|
onSessionEstablished,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
@@ -128,15 +126,13 @@ function MainContent({
|
|||||||
selectedSession={selectedSession}
|
selectedSession={selectedSession}
|
||||||
ws={ws}
|
ws={ws}
|
||||||
sendMessage={sendMessage}
|
sendMessage={sendMessage}
|
||||||
latestMessage={latestMessage}
|
|
||||||
onFileOpen={handleFileOpen}
|
onFileOpen={handleFileOpen}
|
||||||
onInputFocusChange={onInputFocusChange}
|
onInputFocusChange={onInputFocusChange}
|
||||||
onSessionActive={onSessionActive}
|
|
||||||
onSessionInactive={onSessionInactive}
|
|
||||||
onSessionProcessing={onSessionProcessing}
|
onSessionProcessing={onSessionProcessing}
|
||||||
onSessionNotProcessing={onSessionNotProcessing}
|
onSessionIdle={onSessionIdle}
|
||||||
processingSessions={processingSessions}
|
processingSessions={processingSessions}
|
||||||
onNavigateToSession={onNavigateToSession}
|
onNavigateToSession={onNavigateToSession}
|
||||||
|
onSessionEstablished={onSessionEstablished}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
autoExpandTools={autoExpandTools}
|
autoExpandTools={autoExpandTools}
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ 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,
|
||||||
@@ -30,10 +27,6 @@ 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;
|
||||||
@@ -86,40 +79,8 @@ 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) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TFunction } from 'i18next';
|
|||||||
import { api } from '../../../utils/api';
|
import { api } from '../../../utils/api';
|
||||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||||
|
import type { SessionActivityMap } from '../../../hooks/useSessionProtection';
|
||||||
import type {
|
import type {
|
||||||
ArchivedProjectListItem,
|
ArchivedProjectListItem,
|
||||||
ArchivedSessionListItem,
|
ArchivedSessionListItem,
|
||||||
@@ -81,6 +82,7 @@ type UseSidebarControllerArgs = {
|
|||||||
projects: Project[];
|
projects: Project[];
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
|
activeSessions: SessionActivityMap;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
@@ -100,6 +102,7 @@ export function useSidebarController({
|
|||||||
projects,
|
projects,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession: _selectedSession,
|
selectedSession: _selectedSession,
|
||||||
|
activeSessions,
|
||||||
isLoading,
|
isLoading,
|
||||||
isMobile,
|
isMobile,
|
||||||
t,
|
t,
|
||||||
@@ -146,6 +149,8 @@ export function useSidebarController({
|
|||||||
const onRefreshRef = useRef(onRefresh);
|
const onRefreshRef = useRef(onRefresh);
|
||||||
|
|
||||||
const isSidebarCollapsed = !isMobile && !sidebarVisible;
|
const isSidebarCollapsed = !isMobile && !sidebarVisible;
|
||||||
|
const activeSessionIds = useMemo(() => new Set(activeSessions.keys()), [activeSessions]);
|
||||||
|
const runningSessionsCount = activeSessionIds.size;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
@@ -582,9 +587,48 @@ export function useSidebarController({
|
|||||||
[projectSortOrder, projectsWithResolvedStarState],
|
[projectSortOrder, projectsWithResolvedStarState],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const runningProjects = useMemo(() => {
|
||||||
|
if (activeSessionIds.size === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedProjects.reduce<Project[]>((acc, project) => {
|
||||||
|
const sessions = (project.sessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||||
|
const cursorSessions = (project.cursorSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||||
|
const codexSessions = (project.codexSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||||
|
const geminiSessions = (project.geminiSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||||
|
const opencodeSessions = (project.opencodeSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||||
|
const runningCount =
|
||||||
|
sessions.length
|
||||||
|
+ cursorSessions.length
|
||||||
|
+ codexSessions.length
|
||||||
|
+ geminiSessions.length
|
||||||
|
+ opencodeSessions.length;
|
||||||
|
|
||||||
|
if (runningCount === 0) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.push({
|
||||||
|
...project,
|
||||||
|
sessions,
|
||||||
|
cursorSessions,
|
||||||
|
codexSessions,
|
||||||
|
geminiSessions,
|
||||||
|
opencodeSessions,
|
||||||
|
sessionMeta: {
|
||||||
|
...project.sessionMeta,
|
||||||
|
total: runningCount,
|
||||||
|
hasMore: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}, [activeSessionIds, sortedProjects]);
|
||||||
|
|
||||||
const filteredProjects = useMemo(
|
const filteredProjects = useMemo(
|
||||||
() => filterProjects(sortedProjects, debouncedSearchQuery),
|
() => filterProjects(searchMode === 'running' ? runningProjects : sortedProjects, debouncedSearchQuery),
|
||||||
[debouncedSearchQuery, sortedProjects],
|
[debouncedSearchQuery, runningProjects, searchMode, sortedProjects],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredArchivedSessions = useMemo(() => {
|
const filteredArchivedSessions = useMemo(() => {
|
||||||
@@ -914,6 +958,7 @@ export function useSidebarController({
|
|||||||
sessionDeleteConfirmation,
|
sessionDeleteConfirmation,
|
||||||
showVersionModal,
|
showVersionModal,
|
||||||
filteredProjects,
|
filteredProjects,
|
||||||
|
runningSessionsCount,
|
||||||
archivedProjects: filteredArchivedProjects,
|
archivedProjects: filteredArchivedProjects,
|
||||||
archivedSessions: filteredArchivedSessions,
|
archivedSessions: filteredArchivedSessions,
|
||||||
archivedSessionsCount: archivedProjects.length + archivedSessions.length,
|
archivedSessionsCount: archivedProjects.length + archivedSessions.length,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../types/app';
|
import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||||
|
import type { SessionActivityMap } from '../../../hooks/useSessionProtection';
|
||||||
|
|
||||||
export type ProjectSortOrder = 'name' | 'date';
|
export type ProjectSortOrder = 'name' | 'date';
|
||||||
export type SidebarSearchMode = 'projects' | 'conversations' | 'archived';
|
export type SidebarSearchMode = 'projects' | 'conversations' | 'running' | 'archived';
|
||||||
export type ArchivedProjectListItem = Project & { isArchived: true };
|
export type ArchivedProjectListItem = Project & { isArchived: true };
|
||||||
|
|
||||||
export type SessionWithProvider = ProjectSession & {
|
export type SessionWithProvider = ProjectSession & {
|
||||||
@@ -40,6 +41,7 @@ export type SidebarProps = {
|
|||||||
projects: Project[];
|
projects: Project[];
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
|
activeSessions: SessionActivityMap;
|
||||||
onProjectSelect: (project: Project) => void;
|
onProjectSelect: (project: Project) => void;
|
||||||
onSessionSelect: (session: ProjectSession) => void;
|
onSessionSelect: (session: ProjectSession) => void;
|
||||||
onNewSession: (project: Project) => void;
|
onNewSession: (project: Project) => void;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function Sidebar({
|
|||||||
projects,
|
projects,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
|
activeSessions,
|
||||||
onProjectSelect,
|
onProjectSelect,
|
||||||
onSessionSelect,
|
onSessionSelect,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
@@ -70,6 +71,7 @@ function Sidebar({
|
|||||||
isSearching,
|
isSearching,
|
||||||
searchProgress,
|
searchProgress,
|
||||||
clearConversationResults,
|
clearConversationResults,
|
||||||
|
runningSessionsCount,
|
||||||
deletingProjects,
|
deletingProjects,
|
||||||
deleteConfirmation,
|
deleteConfirmation,
|
||||||
sessionDeleteConfirmation,
|
sessionDeleteConfirmation,
|
||||||
@@ -113,6 +115,7 @@ function Sidebar({
|
|||||||
projects,
|
projects,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
|
activeSessions,
|
||||||
isLoading,
|
isLoading,
|
||||||
isMobile,
|
isMobile,
|
||||||
t,
|
t,
|
||||||
@@ -159,6 +162,8 @@ function Sidebar({
|
|||||||
mcpServerStatus,
|
mcpServerStatus,
|
||||||
getProjectSessions,
|
getProjectSessions,
|
||||||
loadingMoreProjects,
|
loadingMoreProjects,
|
||||||
|
activeSessions,
|
||||||
|
forceExpanded: searchMode === 'running',
|
||||||
isProjectStarred,
|
isProjectStarred,
|
||||||
onEditingNameChange: setEditingName,
|
onEditingNameChange: setEditingName,
|
||||||
onToggleProject: toggleProject,
|
onToggleProject: toggleProject,
|
||||||
@@ -229,6 +234,7 @@ function Sidebar({
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
|
runningSessionsCount={runningSessionsCount}
|
||||||
archivedProjects={archivedProjects}
|
archivedProjects={archivedProjects}
|
||||||
archivedSessions={archivedSessions}
|
archivedSessions={archivedSessions}
|
||||||
archivedSessionsCount={archivedSessionsCount}
|
archivedSessionsCount={archivedSessionsCount}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react';
|
import { Activity, Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
import { ScrollArea } from '../../../../shared/view/ui';
|
import { ScrollArea } from '../../../../shared/view/ui';
|
||||||
import type { Project } from '../../../../types/app';
|
import type { Project } from '../../../../types/app';
|
||||||
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
||||||
import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';
|
import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';
|
||||||
import type { ArchivedProjectListItem, ArchivedSessionListItem, SidebarSearchMode } from '../../types/types';
|
import type { ArchivedProjectListItem, ArchivedSessionListItem, SidebarSearchMode } from '../../types/types';
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||||
|
import { getAllSessions } from '../../utils/utils';
|
||||||
|
|
||||||
import SidebarFooter from './SidebarFooter';
|
import SidebarFooter from './SidebarFooter';
|
||||||
import SidebarHeader from './SidebarHeader';
|
import SidebarHeader from './SidebarHeader';
|
||||||
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
|
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
|
||||||
import { getAllSessions } from '../../utils/utils';
|
|
||||||
|
|
||||||
function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) {
|
function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) {
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
@@ -114,6 +116,7 @@ type SidebarContentProps = {
|
|||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
|
runningSessionsCount: number;
|
||||||
archivedProjects: ArchivedProjectListItem[];
|
archivedProjects: ArchivedProjectListItem[];
|
||||||
archivedSessions: ArchivedSessionListItem[];
|
archivedSessions: ArchivedSessionListItem[];
|
||||||
archivedSessionsCount: number;
|
archivedSessionsCount: number;
|
||||||
@@ -152,6 +155,7 @@ export default function SidebarContent({
|
|||||||
isMobile,
|
isMobile,
|
||||||
isLoading,
|
isLoading,
|
||||||
projects,
|
projects,
|
||||||
|
runningSessionsCount,
|
||||||
archivedProjects,
|
archivedProjects,
|
||||||
archivedSessions,
|
archivedSessions,
|
||||||
archivedSessionsCount,
|
archivedSessionsCount,
|
||||||
@@ -196,6 +200,7 @@ export default function SidebarContent({
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
projectsCount={projects.length}
|
projectsCount={projects.length}
|
||||||
|
runningSessionsCount={runningSessionsCount}
|
||||||
archivedSessionsCount={archivedSessionsCount}
|
archivedSessionsCount={archivedSessionsCount}
|
||||||
isArchivedSessionsLoading={isArchivedSessionsLoading}
|
isArchivedSessionsLoading={isArchivedSessionsLoading}
|
||||||
searchFilter={searchFilter}
|
searchFilter={searchFilter}
|
||||||
@@ -307,6 +312,39 @@ export default function SidebarContent({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
|
) : searchMode === 'running' ? (
|
||||||
|
projectListProps.filteredProjects.length === 0 ? (
|
||||||
|
<div className="px-4 py-12 text-center md:py-8">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-border/70 bg-muted/50 md:mb-3">
|
||||||
|
<Activity className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 text-base font-medium text-foreground md:mb-1">
|
||||||
|
{t('running.emptyTitle', 'No sessions running')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{runningSessionsCount > 0
|
||||||
|
? t('running.noMatchingSessions', 'No running sessions match this search.')
|
||||||
|
: t('running.emptyDescription', 'Active work will appear here while a provider is processing.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="mx-2 flex items-center justify-between rounded-lg border border-border/60 bg-card/50 px-3 py-2 shadow-sm">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||||
|
<Activity className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs font-medium text-foreground">
|
||||||
|
{t('running.title', 'Running now')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
|
||||||
|
{runningSessionsCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<SidebarProjectList {...projectListProps} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : searchMode === 'archived' ? (
|
) : searchMode === 'archived' ? (
|
||||||
isArchivedSessionsLoading ? (
|
isArchivedSessionsLoading ? (
|
||||||
<div className="px-4 py-12 text-center md:py-8">
|
<div className="px-4 py-12 text-center md:py-8">
|
||||||
@@ -358,7 +396,7 @@ export default function SidebarContent({
|
|||||||
<span className="truncate text-sm font-medium text-foreground">
|
<span className="truncate text-sm font-medium text-foreground">
|
||||||
{project.displayName}
|
{project.displayName}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-center text-muted-foreground">
|
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-center text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-muted-foreground">
|
||||||
{t('archived.projectArchived', 'Project archived')}
|
{t('archived.projectArchived', 'Project archived')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -448,7 +486,7 @@ export default function SidebarContent({
|
|||||||
{group.projectDisplayName}
|
{group.projectDisplayName}
|
||||||
</span>
|
</span>
|
||||||
{group.isProjectArchived && (
|
{group.isProjectArchived && (
|
||||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-center text-muted-foreground">
|
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-center text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-muted-foreground">
|
||||||
{t('archived.projectArchived', 'Project archived')}
|
{t('archived.projectArchived', 'Project archived')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
|
import { Activity, Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
import { Button, Input, Tooltip } from '../../../../shared/view/ui';
|
import { Button, Input, Tooltip } from '../../../../shared/view/ui';
|
||||||
import { IS_PLATFORM } from '../../../../constants/config';
|
import { IS_PLATFORM } from '../../../../constants/config';
|
||||||
import { cn } from '../../../../lib/utils';
|
import { cn } from '../../../../lib/utils';
|
||||||
import type { SidebarSearchMode } from '../../types/types';
|
import type { SidebarSearchMode } from '../../types/types';
|
||||||
|
|
||||||
import GitHubStarBadge from './GitHubStarBadge';
|
import GitHubStarBadge from './GitHubStarBadge';
|
||||||
|
|
||||||
const MOD_KEY =
|
const MOD_KEY =
|
||||||
@@ -14,6 +16,7 @@ type SidebarHeaderProps = {
|
|||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
projectsCount: number;
|
projectsCount: number;
|
||||||
|
runningSessionsCount: number;
|
||||||
archivedSessionsCount: number;
|
archivedSessionsCount: number;
|
||||||
isArchivedSessionsLoading: boolean;
|
isArchivedSessionsLoading: boolean;
|
||||||
searchFilter: string;
|
searchFilter: string;
|
||||||
@@ -33,6 +36,7 @@ export default function SidebarHeader({
|
|||||||
isMobile,
|
isMobile,
|
||||||
isLoading,
|
isLoading,
|
||||||
projectsCount,
|
projectsCount,
|
||||||
|
runningSessionsCount,
|
||||||
archivedSessionsCount,
|
archivedSessionsCount,
|
||||||
isArchivedSessionsLoading,
|
isArchivedSessionsLoading,
|
||||||
searchFilter,
|
searchFilter,
|
||||||
@@ -46,12 +50,15 @@ export default function SidebarHeader({
|
|||||||
onCollapseSidebar,
|
onCollapseSidebar,
|
||||||
t,
|
t,
|
||||||
}: SidebarHeaderProps) {
|
}: SidebarHeaderProps) {
|
||||||
const showSearchTools = (projectsCount > 0 || archivedSessionsCount > 0 || isArchivedSessionsLoading) && !isLoading;
|
const showSearchTools = (projectsCount > 0 || runningSessionsCount > 0 || archivedSessionsCount > 0 || isArchivedSessionsLoading) && !isLoading;
|
||||||
const searchPlaceholder = searchMode === 'conversations'
|
const searchPlaceholder = searchMode === 'conversations'
|
||||||
? t('search.conversationsPlaceholder')
|
? t('search.conversationsPlaceholder')
|
||||||
: searchMode === 'archived'
|
: searchMode === 'archived'
|
||||||
? t('search.archivedPlaceholder', 'Search archived sessions...')
|
? t('search.archivedPlaceholder', 'Search archived sessions...')
|
||||||
: t('projects.searchPlaceholder');
|
: searchMode === 'running'
|
||||||
|
? t('search.runningPlaceholder', 'Search running sessions...')
|
||||||
|
: t('projects.searchPlaceholder');
|
||||||
|
const runningBadgeText = runningSessionsCount > 99 ? '99+' : String(runningSessionsCount);
|
||||||
|
|
||||||
const LogoBlock = () => (
|
const LogoBlock = () => (
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
@@ -153,6 +160,29 @@ export default function SidebarHeader({
|
|||||||
<MessageSquare className="h-3 w-3" />
|
<MessageSquare className="h-3 w-3" />
|
||||||
{t('search.modeConversations')}
|
{t('search.modeConversations')}
|
||||||
</button>
|
</button>
|
||||||
|
<Tooltip content={t('search.runningTooltip', 'Running sessions')} position="top">
|
||||||
|
<button
|
||||||
|
onClick={() => onSearchModeChange('running')}
|
||||||
|
aria-pressed={searchMode === 'running'}
|
||||||
|
aria-label={t('search.runningTooltip', 'Running sessions')}
|
||||||
|
title={t('search.runningTooltip', 'Running sessions')}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||||
|
searchMode === 'running'
|
||||||
|
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="relative flex h-3 w-3 items-center justify-center">
|
||||||
|
<Activity className={cn("h-3 w-3", runningSessionsCount > 0 && "text-emerald-500")} />
|
||||||
|
{runningSessionsCount > 0 && (
|
||||||
|
<span className="absolute -right-2.5 -top-2 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-emerald-500 px-0.5 text-[8px] font-semibold leading-none text-white shadow-sm ring-1 ring-background">
|
||||||
|
{runningBadgeText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip content={t('search.archiveOnlyTooltip', 'Archive only')} position="top">
|
<Tooltip content={t('search.archiveOnlyTooltip', 'Archive only')} position="top">
|
||||||
<button
|
<button
|
||||||
onClick={() => onSearchModeChange('archived')}
|
onClick={() => onSearchModeChange('archived')}
|
||||||
@@ -270,6 +300,30 @@ export default function SidebarHeader({
|
|||||||
<MessageSquare className="h-3 w-3" />
|
<MessageSquare className="h-3 w-3" />
|
||||||
{t('search.modeConversations')}
|
{t('search.modeConversations')}
|
||||||
</button>
|
</button>
|
||||||
|
<Tooltip content={t('search.runningTooltip', 'Running sessions')} position="top">
|
||||||
|
<button
|
||||||
|
onClick={() => onSearchModeChange('running')}
|
||||||
|
aria-pressed={searchMode === 'running'}
|
||||||
|
aria-label={t('search.runningTooltip', 'Running sessions')}
|
||||||
|
title={t('search.runningTooltip', 'Running sessions')}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||||
|
searchMode === 'running'
|
||||||
|
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="relative flex h-3 w-3 items-center justify-center">
|
||||||
|
<Activity className={cn("h-3 w-3", runningSessionsCount > 0 && "text-emerald-500")} />
|
||||||
|
{runningSessionsCount > 0 && (
|
||||||
|
<span className="absolute -right-2.5 -top-2 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-emerald-500 px-0.5 text-[8px] font-semibold leading-none text-white shadow-sm ring-1 ring-background">
|
||||||
|
{runningBadgeText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">{t('search.modeRunning', 'Running')}</span>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip content={t('search.archiveOnlyTooltip', 'Archive only')} position="top">
|
<Tooltip content={t('search.archiveOnlyTooltip', 'Archive only')} position="top">
|
||||||
<button
|
<button
|
||||||
onClick={() => onSearchModeChange('archived')}
|
onClick={() => onSearchModeChange('archived')}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TFunction } from 'i18next';
|
|||||||
import { Button } from '../../../../shared/view/ui';
|
import { Button } from '../../../../shared/view/ui';
|
||||||
import { cn } from '../../../../lib/utils';
|
import { cn } from '../../../../lib/utils';
|
||||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||||
|
import type { SessionActivityMap } from '../../../../hooks/useSessionProtection';
|
||||||
import type { MCPServerStatus, SessionWithProvider } from '../../types/types';
|
import type { MCPServerStatus, SessionWithProvider } from '../../types/types';
|
||||||
import { getTaskIndicatorStatus } from '../../utils/utils';
|
import { getTaskIndicatorStatus } from '../../utils/utils';
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ type SidebarProjectItemProps = {
|
|||||||
provider: LLMProvider,
|
provider: LLMProvider,
|
||||||
) => void;
|
) => void;
|
||||||
onLoadMoreSessions: (projectId: string) => void;
|
onLoadMoreSessions: (projectId: string) => void;
|
||||||
|
activeSessions: SessionActivityMap;
|
||||||
onNewSession: (project: Project) => void;
|
onNewSession: (project: Project) => void;
|
||||||
onEditingSessionNameChange: (value: string) => void;
|
onEditingSessionNameChange: (value: string) => void;
|
||||||
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||||
@@ -84,6 +86,7 @@ export default function SidebarProjectItem({
|
|||||||
onSessionSelect,
|
onSessionSelect,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
onLoadMoreSessions,
|
onLoadMoreSessions,
|
||||||
|
activeSessions,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onEditingSessionNameChange,
|
onEditingSessionNameChange,
|
||||||
onStartEditingSession,
|
onStartEditingSession,
|
||||||
@@ -395,6 +398,7 @@ export default function SidebarProjectItem({
|
|||||||
initialSessionsLoaded={initialSessionsLoaded}
|
initialSessionsLoaded={initialSessionsLoaded}
|
||||||
hasMoreSessions={Boolean(project.sessionMeta?.hasMore)}
|
hasMoreSessions={Boolean(project.sessionMeta?.hasMore)}
|
||||||
isLoadingMoreSessions={isLoadingMoreSessions}
|
isLoadingMoreSessions={isLoadingMoreSessions}
|
||||||
|
activeSessions={activeSessions}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
editingSession={editingSession}
|
editingSession={editingSession}
|
||||||
editingSessionName={editingSessionName}
|
editingSessionName={editingSessionName}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||||
|
import type { SessionActivityMap } from '../../../../hooks/useSessionProtection';
|
||||||
import type { MCPServerStatus, SessionWithProvider } from '../../types/types';
|
import type { MCPServerStatus, SessionWithProvider } from '../../types/types';
|
||||||
|
|
||||||
import SidebarProjectItem from './SidebarProjectItem';
|
import SidebarProjectItem from './SidebarProjectItem';
|
||||||
@@ -27,6 +28,8 @@ export type SidebarProjectListProps = {
|
|||||||
getProjectSessions: (project: Project) => SessionWithProvider[];
|
getProjectSessions: (project: Project) => SessionWithProvider[];
|
||||||
onLoadMoreSessions: (projectId: string) => void;
|
onLoadMoreSessions: (projectId: string) => void;
|
||||||
loadingMoreProjects: Set<string>;
|
loadingMoreProjects: Set<string>;
|
||||||
|
activeSessions: SessionActivityMap;
|
||||||
|
forceExpanded?: boolean;
|
||||||
isProjectStarred: (projectName: string) => boolean;
|
isProjectStarred: (projectName: string) => boolean;
|
||||||
onEditingNameChange: (value: string) => void;
|
onEditingNameChange: (value: string) => void;
|
||||||
onToggleProject: (projectName: string) => void;
|
onToggleProject: (projectName: string) => void;
|
||||||
@@ -71,6 +74,8 @@ export default function SidebarProjectList({
|
|||||||
getProjectSessions,
|
getProjectSessions,
|
||||||
onLoadMoreSessions,
|
onLoadMoreSessions,
|
||||||
loadingMoreProjects,
|
loadingMoreProjects,
|
||||||
|
activeSessions,
|
||||||
|
forceExpanded = false,
|
||||||
isProjectStarred,
|
isProjectStarred,
|
||||||
onEditingNameChange,
|
onEditingNameChange,
|
||||||
onToggleProject,
|
onToggleProject,
|
||||||
@@ -122,7 +127,7 @@ export default function SidebarProjectList({
|
|||||||
project={project}
|
project={project}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
selectedSession={selectedSession}
|
selectedSession={selectedSession}
|
||||||
isExpanded={expandedProjects.has(project.projectId)}
|
isExpanded={forceExpanded || expandedProjects.has(project.projectId)}
|
||||||
isDeleting={deletingProjects.has(project.projectId)}
|
isDeleting={deletingProjects.has(project.projectId)}
|
||||||
isStarred={isProjectStarred(project.projectId)}
|
isStarred={isProjectStarred(project.projectId)}
|
||||||
editingProject={editingProject}
|
editingProject={editingProject}
|
||||||
@@ -146,6 +151,7 @@ export default function SidebarProjectList({
|
|||||||
onSessionSelect={onSessionSelect}
|
onSessionSelect={onSessionSelect}
|
||||||
onDeleteSession={onDeleteSession}
|
onDeleteSession={onDeleteSession}
|
||||||
onLoadMoreSessions={onLoadMoreSessions}
|
onLoadMoreSessions={onLoadMoreSessions}
|
||||||
|
activeSessions={activeSessions}
|
||||||
onNewSession={onNewSession}
|
onNewSession={onNewSession}
|
||||||
onEditingSessionNameChange={onEditingSessionNameChange}
|
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||||
onStartEditingSession={onStartEditingSession}
|
onStartEditingSession={onStartEditingSession}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Plus } from 'lucide-react';
|
|||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
import { Button } from '../../../../shared/view/ui';
|
import { Button } from '../../../../shared/view/ui';
|
||||||
|
import type { SessionActivityMap } from '../../../../hooks/useSessionProtection';
|
||||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||||
import type { SessionWithProvider } from '../../types/types';
|
import type { SessionWithProvider } from '../../types/types';
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ type SidebarProjectSessionsProps = {
|
|||||||
initialSessionsLoaded: boolean;
|
initialSessionsLoaded: boolean;
|
||||||
hasMoreSessions: boolean;
|
hasMoreSessions: boolean;
|
||||||
isLoadingMoreSessions: boolean;
|
isLoadingMoreSessions: boolean;
|
||||||
|
activeSessions: SessionActivityMap;
|
||||||
currentTime: Date;
|
currentTime: Date;
|
||||||
editingSession: string | null;
|
editingSession: string | null;
|
||||||
editingSessionName: string;
|
editingSessionName: string;
|
||||||
@@ -61,6 +63,7 @@ export default function SidebarProjectSessions({
|
|||||||
initialSessionsLoaded,
|
initialSessionsLoaded,
|
||||||
hasMoreSessions,
|
hasMoreSessions,
|
||||||
isLoadingMoreSessions,
|
isLoadingMoreSessions,
|
||||||
|
activeSessions,
|
||||||
currentTime,
|
currentTime,
|
||||||
editingSession,
|
editingSession,
|
||||||
editingSessionName,
|
editingSessionName,
|
||||||
@@ -120,6 +123,7 @@ export default function SidebarProjectSessions({
|
|||||||
project={project}
|
project={project}
|
||||||
session={session}
|
session={session}
|
||||||
selectedSession={selectedSession}
|
selectedSession={selectedSession}
|
||||||
|
isProcessing={activeSessions.has(session.id)}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
editingSession={editingSession}
|
editingSession={editingSession}
|
||||||
editingSessionName={editingSessionName}
|
editingSessionName={editingSessionName}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Check, Edit2, Trash2, X } from 'lucide-react';
|
import { Check, Edit2, Loader2, Trash2, X } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
import { Badge, Button, Tooltip } from '../../../../shared/view/ui';
|
import { Badge, Button, Tooltip } from '../../../../shared/view/ui';
|
||||||
@@ -13,6 +13,7 @@ type SidebarSessionItemProps = {
|
|||||||
project: Project;
|
project: Project;
|
||||||
session: SessionWithProvider;
|
session: SessionWithProvider;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
|
isProcessing: boolean;
|
||||||
currentTime: Date;
|
currentTime: Date;
|
||||||
editingSession: string | null;
|
editingSession: string | null;
|
||||||
editingSessionName: string;
|
editingSessionName: string;
|
||||||
@@ -63,6 +64,7 @@ export default function SidebarSessionItem({
|
|||||||
project,
|
project,
|
||||||
session,
|
session,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
|
isProcessing,
|
||||||
currentTime,
|
currentTime,
|
||||||
editingSession,
|
editingSession,
|
||||||
editingSessionName,
|
editingSessionName,
|
||||||
@@ -117,7 +119,7 @@ export default function SidebarSessionItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
{sessionView.isActive && (
|
{!isProcessing && sessionView.isActive && (
|
||||||
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
|
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
|
||||||
<Tooltip content={t('tooltips.activeSessionIndicator')} position="right">
|
<Tooltip content={t('tooltips.activeSessionIndicator')} position="right">
|
||||||
<div
|
<div
|
||||||
@@ -134,7 +136,9 @@ export default function SidebarSessionItem({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'p-2 mx-3 my-0.5 rounded-md bg-card border active:scale-[0.98] transition-all duration-150 relative',
|
'p-2 mx-3 my-0.5 rounded-md bg-card border active:scale-[0.98] transition-all duration-150 relative',
|
||||||
isSelected ? 'bg-primary/5 border-primary/20' : '',
|
isSelected ? 'bg-primary/5 border-primary/20' : '',
|
||||||
!isSelected && sessionView.isActive
|
!isSelected && isProcessing
|
||||||
|
? 'border-border/60 bg-muted/20'
|
||||||
|
: !isSelected && sessionView.isActive
|
||||||
? 'border-green-500/30 bg-green-50/5 dark:bg-green-900/5'
|
? 'border-green-500/30 bg-green-50/5 dark:bg-green-900/5'
|
||||||
: 'border-border/30',
|
: 'border-border/30',
|
||||||
)}
|
)}
|
||||||
@@ -153,7 +157,13 @@ export default function SidebarSessionItem({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||||
{compactSessionAge && (
|
{isProcessing ? (
|
||||||
|
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
|
||||||
|
<span className="ml-auto flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground transition-opacity duration-200 group-hover:opacity-0">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : compactSessionAge && (
|
||||||
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground">{compactSessionAge}</span>
|
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground">{compactSessionAge}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -187,15 +197,16 @@ export default function SidebarSessionItem({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200',
|
'w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200',
|
||||||
isSelected && 'bg-accent text-accent-foreground',
|
isSelected && 'bg-accent text-accent-foreground',
|
||||||
|
!isSelected && isProcessing && 'bg-muted/20 hover:bg-accent/50',
|
||||||
)}
|
)}
|
||||||
onClick={() => onSessionSelect(session, project.projectId)}
|
onClick={() => onSessionSelect(session, project.projectId)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full min-w-0 items-start gap-2">
|
<div className="flex w-full min-w-0 items-start gap-2">
|
||||||
<SessionProviderLogo provider={session.__provider} className="mt-0.5 h-3 w-3 flex-shrink-0" />
|
<SessionProviderLogo provider={session.__provider} className="mt-0.5 h-3 w-3 flex-shrink-0" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className={cn('flex items-center gap-2', isProcessing && 'pr-7')}>
|
||||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||||
{compactSessionAge && (
|
{!isProcessing && compactSessionAge && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200',
|
'ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200',
|
||||||
@@ -213,6 +224,19 @@ export default function SidebarSessionItem({
|
|||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{isProcessing && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label={t('tooltips.processingSessionIndicator', 'Processing session')}
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute right-2 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-opacity duration-200',
|
||||||
|
isEditing ? 'opacity-0' : 'group-hover:opacity-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={editingContainerRef}
|
ref={editingContainerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -2,10 +2,42 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
|||||||
import { useAuth } from '../components/auth/context/AuthContext';
|
import { useAuth } from '../components/auth/context/AuthContext';
|
||||||
import { IS_PLATFORM } from '../constants/config';
|
import { IS_PLATFORM } from '../constants/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One frame received from the chat websocket. The server guarantees every
|
||||||
|
* frame carries a `kind` (provider message kinds plus gateway kinds such as
|
||||||
|
* `chat_subscribed`, `session_upserted`, `loading_progress`,
|
||||||
|
* `protocol_error`). The synthetic `websocket_reconnected` kind is injected
|
||||||
|
* client-side when the socket re-opens after a drop.
|
||||||
|
*/
|
||||||
|
export type ServerEvent = {
|
||||||
|
kind?: string;
|
||||||
|
type?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
seq?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ServerEventListener = (event: ServerEvent) => void;
|
||||||
|
|
||||||
type WebSocketContextType = {
|
type WebSocketContextType = {
|
||||||
ws: WebSocket | null;
|
ws: WebSocket | null;
|
||||||
sendMessage: (message: any) => void;
|
sendMessage: (message: unknown) => void;
|
||||||
latestMessage: any | null;
|
/**
|
||||||
|
* Subscribes to every websocket frame. Returns an unsubscribe function.
|
||||||
|
*
|
||||||
|
* This is the primary consumption API: events are dispatched synchronously
|
||||||
|
* to every listener, so rapid back-to-back frames can never be coalesced or
|
||||||
|
* dropped the way a single "latest message" state slot could.
|
||||||
|
*/
|
||||||
|
subscribe: (listener: ServerEventListener) => () => void;
|
||||||
|
/**
|
||||||
|
* Legacy state-based access to the most recent frame.
|
||||||
|
*
|
||||||
|
* Kept only for low-frequency consumers (TaskMaster broadcasts). High-rate
|
||||||
|
* chat streams must use `subscribe` — React may batch state updates, which
|
||||||
|
* makes `latestMessage` lossy under load.
|
||||||
|
*/
|
||||||
|
latestMessage: ServerEvent | null;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,11 +62,28 @@ const useWebSocketProviderState = (): WebSocketContextType => {
|
|||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const unmountedRef = useRef(false); // Track if component is unmounted
|
const unmountedRef = useRef(false); // Track if component is unmounted
|
||||||
const hasConnectedRef = useRef(false); // Track if we've ever connected (to detect reconnects)
|
const hasConnectedRef = useRef(false); // Track if we've ever connected (to detect reconnects)
|
||||||
const [latestMessage, setLatestMessage] = useState<any>(null);
|
/**
|
||||||
|
* Listener registry for the subscribe API. A ref (not state) because the
|
||||||
|
* set must be readable synchronously inside `onmessage` and never trigger
|
||||||
|
* re-renders of the provider tree.
|
||||||
|
*/
|
||||||
|
const listenersRef = useRef(new Set<ServerEventListener>());
|
||||||
|
const [latestMessage, setLatestMessage] = useState<ServerEvent | null>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
const dispatch = useCallback((event: ServerEvent) => {
|
||||||
|
for (const listener of listenersRef.current) {
|
||||||
|
try {
|
||||||
|
listener(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WebSocket listener error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLatestMessage(event);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// The cleanup below sets unmountedRef = true. Without this reset, every
|
// The cleanup below sets unmountedRef = true. Without this reset, every
|
||||||
// re-run of the effect (e.g. on token refresh) would short-circuit connect()
|
// re-run of the effect (e.g. on token refresh) would short-circuit connect()
|
||||||
@@ -60,7 +109,7 @@ const useWebSocketProviderState = (): WebSocketContextType => {
|
|||||||
const wsUrl = buildWebSocketUrl(token);
|
const wsUrl = buildWebSocketUrl(token);
|
||||||
|
|
||||||
if (!wsUrl) return console.warn('No authentication token found for WebSocket connection');
|
if (!wsUrl) return console.warn('No authentication token found for WebSocket connection');
|
||||||
|
|
||||||
const websocket = new WebSocket(wsUrl);
|
const websocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
websocket.onopen = () => {
|
websocket.onopen = () => {
|
||||||
@@ -68,15 +117,15 @@ const useWebSocketProviderState = (): WebSocketContextType => {
|
|||||||
wsRef.current = websocket;
|
wsRef.current = websocket;
|
||||||
if (hasConnectedRef.current) {
|
if (hasConnectedRef.current) {
|
||||||
// This is a reconnect — signal so components can catch up on missed messages
|
// This is a reconnect — signal so components can catch up on missed messages
|
||||||
setLatestMessage({ type: 'websocket-reconnected', timestamp: Date.now() });
|
dispatch({ kind: 'websocket_reconnected', timestamp: Date.now() });
|
||||||
}
|
}
|
||||||
hasConnectedRef.current = true;
|
hasConnectedRef.current = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onmessage = (event) => {
|
websocket.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data) as ServerEvent;
|
||||||
setLatestMessage(data);
|
dispatch(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing WebSocket message:', error);
|
console.error('Error parsing WebSocket message:', error);
|
||||||
}
|
}
|
||||||
@@ -85,7 +134,7 @@ const useWebSocketProviderState = (): WebSocketContextType => {
|
|||||||
websocket.onclose = () => {
|
websocket.onclose = () => {
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
|
|
||||||
// Attempt to reconnect after 3 seconds
|
// Attempt to reconnect after 3 seconds
|
||||||
reconnectTimeoutRef.current = setTimeout(() => {
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
if (unmountedRef.current) return; // Prevent reconnection if unmounted
|
if (unmountedRef.current) return; // Prevent reconnection if unmounted
|
||||||
@@ -100,9 +149,9 @@ const useWebSocketProviderState = (): WebSocketContextType => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating WebSocket connection:', error);
|
console.error('Error creating WebSocket connection:', error);
|
||||||
}
|
}
|
||||||
}, [token]); // everytime token changes, we reconnect
|
}, [token, dispatch]); // everytime token changes, we reconnect
|
||||||
|
|
||||||
const sendMessage = useCallback((message: any) => {
|
const sendMessage = useCallback((message: unknown) => {
|
||||||
const socket = wsRef.current;
|
const socket = wsRef.current;
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
socket.send(JSON.stringify(message));
|
socket.send(JSON.stringify(message));
|
||||||
@@ -111,20 +160,28 @@ const useWebSocketProviderState = (): WebSocketContextType => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const subscribe = useCallback((listener: ServerEventListener) => {
|
||||||
|
listenersRef.current.add(listener);
|
||||||
|
return () => {
|
||||||
|
listenersRef.current.delete(listener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const value: WebSocketContextType = useMemo(() =>
|
const value: WebSocketContextType = useMemo(() =>
|
||||||
({
|
({
|
||||||
ws: wsRef.current,
|
ws: wsRef.current,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
subscribe,
|
||||||
latestMessage,
|
latestMessage,
|
||||||
isConnected
|
isConnected
|
||||||
}), [sendMessage, latestMessage, isConnected]);
|
}), [sendMessage, subscribe, latestMessage, isConnected]);
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WebSocketProvider = ({ children }: { children: React.ReactNode }) => {
|
export const WebSocketProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
const webSocketData = useWebSocketProviderState();
|
const webSocketData = useWebSocketProviderState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WebSocketContext.Provider value={webSocketData}>
|
<WebSocketContext.Provider value={webSocketData}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,28 +2,55 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import type { NavigateFunction } from 'react-router-dom';
|
import type { NavigateFunction } from 'react-router-dom';
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
|
import type { ServerEvent } from '../contexts/WebSocketContext';
|
||||||
import type {
|
import type {
|
||||||
AppSocketMessage,
|
|
||||||
AppTab,
|
AppTab,
|
||||||
LLMProvider,
|
LLMProvider,
|
||||||
LoadingProgress,
|
LoadingProgress,
|
||||||
Project,
|
Project,
|
||||||
ProjectSession,
|
ProjectSession,
|
||||||
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;
|
/** Subscription to the unified websocket event stream. */
|
||||||
|
subscribe: (listener: (event: ServerEvent) => void) => () => void;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
activeSessions: Set<string>;
|
activeSessions: SessionActivityMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape of the per-session sidebar delta broadcast by the backend file
|
||||||
|
* watcher (`kind: session_upserted`). It carries everything needed to upsert
|
||||||
|
* one session row in place — no full project-list snapshot is ever pushed.
|
||||||
|
*/
|
||||||
|
type SessionUpsertedEvent = ServerEvent & {
|
||||||
|
sessionId: string;
|
||||||
|
provider: LLMProvider;
|
||||||
|
session: ProjectSession;
|
||||||
|
project: {
|
||||||
|
projectId: string;
|
||||||
|
path: string;
|
||||||
|
fullPath: string;
|
||||||
|
displayName: string;
|
||||||
|
isStarred: boolean;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FetchProjectsOptions = {
|
type FetchProjectsOptions = {
|
||||||
showLoadingState?: boolean;
|
showLoadingState?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RegisterOptimisticSessionArgs = {
|
||||||
|
sessionId: string;
|
||||||
|
provider: LLMProvider;
|
||||||
|
project: Project;
|
||||||
|
summary?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const serialize = (value: unknown) => JSON.stringify(value ?? null);
|
const serialize = (value: unknown) => JSON.stringify(value ?? null);
|
||||||
|
|
||||||
const projectsHaveChanges = (
|
const projectsHaveChanges = (
|
||||||
@@ -185,42 +212,74 @@ const mergeProjectSessionPage = (
|
|||||||
return mergedProject;
|
return mergedProject;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isUpdateAdditive = (
|
/**
|
||||||
currentProjects: Project[],
|
* Resolves which provider bucket on a `Project` holds sessions for a provider.
|
||||||
updatedProjects: Project[],
|
* The legacy payload keeps Claude sessions in `sessions` and the other
|
||||||
selectedProject: Project | null,
|
* providers in their own arrays.
|
||||||
selectedSession: ProjectSession | null,
|
*/
|
||||||
): boolean => {
|
const providerBucketKey = (
|
||||||
if (!selectedProject || !selectedSession) {
|
provider: LLMProvider,
|
||||||
return true;
|
): 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' => {
|
||||||
}
|
if (provider === 'cursor') return 'cursorSessions';
|
||||||
|
if (provider === 'codex') return 'codexSessions';
|
||||||
const currentSelectedProject = currentProjects.find((project) => project.projectId === selectedProject.projectId);
|
if (provider === 'gemini') return 'geminiSessions';
|
||||||
const updatedSelectedProject = updatedProjects.find((project) => project.projectId === selectedProject.projectId);
|
if (provider === 'opencode') return 'opencodeSessions';
|
||||||
|
return 'sessions';
|
||||||
if (!currentSelectedProject || !updatedSelectedProject) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSelectedSession = getProjectSessions(currentSelectedProject).find(
|
|
||||||
(session) => session.id === selectedSession.id,
|
|
||||||
);
|
|
||||||
const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find(
|
|
||||||
(session) => session.id === selectedSession.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!currentSelectedSession || !updatedSelectedSession) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
currentSelectedSession.id === updatedSelectedSession.id &&
|
|
||||||
currentSelectedSession.title === updatedSelectedSession.title &&
|
|
||||||
currentSelectedSession.created_at === updatedSelectedSession.created_at &&
|
|
||||||
currentSelectedSession.updated_at === updatedSelectedSession.updated_at
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upserts one session into the matching provider bucket of a project.
|
||||||
|
*
|
||||||
|
* Existing rows are updated in place (summary/lastActivity changes from the
|
||||||
|
* watcher); new rows are prepended since the watcher only fires for sessions
|
||||||
|
* with fresh activity. `sessionMeta.total` grows only on insert.
|
||||||
|
*/
|
||||||
|
const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent): Project => {
|
||||||
|
const bucketKey = providerBucketKey(event.provider);
|
||||||
|
const bucket = project[bucketKey] ?? [];
|
||||||
|
const existingIndex = bucket.findIndex((session) => session.id === event.sessionId);
|
||||||
|
|
||||||
|
let nextBucket: ProjectSession[];
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const existing = bucket[existingIndex];
|
||||||
|
const updated = { ...existing, ...event.session };
|
||||||
|
if (serialize(existing) === serialize(updated)) {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
nextBucket = [...bucket];
|
||||||
|
nextBucket[existingIndex] = updated;
|
||||||
|
} else {
|
||||||
|
nextBucket = [event.session, ...bucket];
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: Project = { ...project, [bucketKey]: nextBucket };
|
||||||
|
if (existingIndex < 0) {
|
||||||
|
const total = Number(project.sessionMeta?.total ?? 0) + 1;
|
||||||
|
next.sessionMeta = {
|
||||||
|
...project.sessionMeta,
|
||||||
|
total,
|
||||||
|
hasMore: countLoadedProjectSessions(next) < total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectFromRegistration = (project: Project): Project => ({
|
||||||
|
projectId: project.projectId,
|
||||||
|
path: project.path || project.fullPath,
|
||||||
|
fullPath: project.fullPath || project.path || '',
|
||||||
|
displayName: project.displayName,
|
||||||
|
isStarred: project.isStarred,
|
||||||
|
sessions: project.sessions ?? [],
|
||||||
|
cursorSessions: project.cursorSessions ?? [],
|
||||||
|
codexSessions: project.codexSessions ?? [],
|
||||||
|
geminiSessions: project.geminiSessions ?? [],
|
||||||
|
opencodeSessions: project.opencodeSessions ?? [],
|
||||||
|
sessionMeta: project.sessionMeta ?? { hasMore: false, total: countLoadedProjectSessions(project) },
|
||||||
|
taskmaster: project.taskmaster,
|
||||||
|
});
|
||||||
|
|
||||||
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
|
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
|
||||||
|
|
||||||
const isValidTab = (tab: string): tab is AppTab => {
|
const isValidTab = (tab: string): tab is AppTab => {
|
||||||
@@ -242,7 +301,7 @@ const readPersistedTab = (): AppTab => {
|
|||||||
export function useProjectsState({
|
export function useProjectsState({
|
||||||
sessionId,
|
sessionId,
|
||||||
navigate,
|
navigate,
|
||||||
latestMessage,
|
subscribe,
|
||||||
isMobile,
|
isMobile,
|
||||||
activeSessions,
|
activeSessions,
|
||||||
}: UseProjectsStateArgs) {
|
}: UseProjectsStateArgs) {
|
||||||
@@ -289,7 +348,18 @@ export function useProjectsState({
|
|||||||
const [newSessionTrigger, setNewSessionTrigger] = useState(0);
|
const [newSessionTrigger, setNewSessionTrigger] = useState(0);
|
||||||
|
|
||||||
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastHandledMessageRef = useRef<AppSocketMessage | null>(null);
|
/**
|
||||||
|
* Ref mirrors for state the websocket subscription handler needs.
|
||||||
|
*
|
||||||
|
* The subscription is registered once (per `subscribe` identity) and events
|
||||||
|
* are dispatched synchronously outside React's render cycle, so the handler
|
||||||
|
* must read the latest values through refs instead of stale closures —
|
||||||
|
* re-subscribing on every state change would risk missing events.
|
||||||
|
*/
|
||||||
|
const selectedSessionRef = useRef(selectedSession);
|
||||||
|
selectedSessionRef.current = selectedSession;
|
||||||
|
const activeSessionsRef = useRef(activeSessions);
|
||||||
|
activeSessionsRef.current = activeSessions;
|
||||||
|
|
||||||
const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => {
|
const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => {
|
||||||
try {
|
try {
|
||||||
@@ -325,6 +395,75 @@ export function useProjectsState({
|
|||||||
await fetchProjects({ showLoadingState: false });
|
await fetchProjects({ showLoadingState: false });
|
||||||
}, [fetchProjects]);
|
}, [fetchProjects]);
|
||||||
|
|
||||||
|
const registerOptimisticSession = useCallback(({
|
||||||
|
sessionId: newSessionId,
|
||||||
|
provider,
|
||||||
|
project,
|
||||||
|
summary,
|
||||||
|
}: RegisterOptimisticSessionArgs) => {
|
||||||
|
if (!newSessionId || !project?.projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const optimisticSession: ProjectSession = {
|
||||||
|
id: newSessionId,
|
||||||
|
summary: summary ?? '',
|
||||||
|
messageCount: 0,
|
||||||
|
createdAt: now,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
lastActivity: now,
|
||||||
|
__provider: provider,
|
||||||
|
__projectId: project.projectId,
|
||||||
|
};
|
||||||
|
const upsert: SessionUpsertedEvent = {
|
||||||
|
kind: 'session_upserted',
|
||||||
|
sessionId: newSessionId,
|
||||||
|
provider,
|
||||||
|
session: optimisticSession,
|
||||||
|
project: {
|
||||||
|
projectId: project.projectId,
|
||||||
|
path: project.path || project.fullPath,
|
||||||
|
fullPath: project.fullPath || project.path || '',
|
||||||
|
displayName: project.displayName,
|
||||||
|
isStarred: Boolean(project.isStarred),
|
||||||
|
},
|
||||||
|
timestamp: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
setProjects((previousProjects) => {
|
||||||
|
const existingProject = previousProjects.find((candidate) => candidate.projectId === project.projectId);
|
||||||
|
if (!existingProject) {
|
||||||
|
return [upsertSessionIntoProject(projectFromRegistration(project), upsert), ...previousProjects];
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProject = upsertSessionIntoProject(existingProject, upsert);
|
||||||
|
if (updatedProject === existingProject) {
|
||||||
|
return previousProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
return previousProjects.map((candidate) =>
|
||||||
|
candidate.projectId === existingProject.projectId ? updatedProject : candidate,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedProject((previousProject) => {
|
||||||
|
if (!previousProject || previousProject.projectId !== project.projectId) {
|
||||||
|
return previousProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProject = upsertSessionIntoProject(previousProject, upsert);
|
||||||
|
return updatedProject === previousProject ? previousProject : updatedProject;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedSession((previousSession) => (
|
||||||
|
previousSession?.id === newSessionId
|
||||||
|
? { ...previousSession, ...optimisticSession }
|
||||||
|
: optimisticSession
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Hydrates TaskMaster details for the given `projectId`. The project
|
// Hydrates TaskMaster details for the given `projectId`. The project
|
||||||
// identifier comes directly from the DB-driven /api/projects response.
|
// identifier comes directly from the DB-driven /api/projects response.
|
||||||
const hydrateProjectTaskMaster = useCallback(async (projectId: string) => {
|
const hydrateProjectTaskMaster = useCallback(async (projectId: string) => {
|
||||||
@@ -391,98 +530,109 @@ export function useProjectsState({
|
|||||||
}
|
}
|
||||||
}, [isLoadingProjects, projects, selectedProject, sessionId]);
|
}, [isLoadingProjects, projects, selectedProject, sessionId]);
|
||||||
|
|
||||||
|
// Realtime sidebar updates. The backend pushes per-session deltas
|
||||||
|
// (`session_upserted`) instead of full project snapshots, so each event is
|
||||||
|
// a keyed upsert that can never clobber unrelated client state — no
|
||||||
|
// "suppress updates while a run is active" protection is needed anymore.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!latestMessage) {
|
const handleEvent = (event: ServerEvent) => {
|
||||||
return;
|
if (event.kind === 'loading_progress') {
|
||||||
}
|
if (loadingProgressTimeoutRef.current) {
|
||||||
|
clearTimeout(loadingProgressTimeoutRef.current);
|
||||||
// `latestMessage` is event-like data. This effect also depends on local state
|
|
||||||
// (`projects`, `selectedProject`, `selectedSession`) to compute derived updates.
|
|
||||||
// Without this guard, handling one websocket message can update that local
|
|
||||||
// state, retrigger the effect, and re-handle the same websocket message.
|
|
||||||
if (lastHandledMessageRef.current === latestMessage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastHandledMessageRef.current = latestMessage;
|
|
||||||
|
|
||||||
if (latestMessage.type === 'loading_progress') {
|
|
||||||
if (loadingProgressTimeoutRef.current) {
|
|
||||||
clearTimeout(loadingProgressTimeoutRef.current);
|
|
||||||
loadingProgressTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingProgress(latestMessage as LoadingProgress);
|
|
||||||
|
|
||||||
if (latestMessage.phase === 'complete') {
|
|
||||||
loadingProgressTimeoutRef.current = setTimeout(() => {
|
|
||||||
setLoadingProgress(null);
|
|
||||||
loadingProgressTimeoutRef.current = null;
|
loadingProgressTimeoutRef.current = null;
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (latestMessage.type !== 'projects_updated') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectsMessage = latestMessage as ProjectsUpdatedMessage;
|
|
||||||
|
|
||||||
if (projectsMessage.updatedSessionId && selectedSession && selectedProject) {
|
|
||||||
if (projectsMessage.updatedSessionId === selectedSession.id) {
|
|
||||||
const isSessionActive = activeSessions.has(selectedSession.id);
|
|
||||||
|
|
||||||
if (!isSessionActive) {
|
|
||||||
setExternalMessageUpdate((prev) => prev + 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLoadingProgress(event as unknown as LoadingProgress);
|
||||||
|
|
||||||
|
if (event.phase === 'complete') {
|
||||||
|
loadingProgressTimeoutRef.current = setTimeout(() => {
|
||||||
|
setLoadingProgress(null);
|
||||||
|
loadingProgressTimeoutRef.current = null;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const hasActiveSession = Boolean(selectedSession && activeSessions.has(selectedSession.id));
|
if (event.kind !== 'session_upserted') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const updatedProjectsWithTaskMaster = mergeTaskMasterCache(projectsMessage.projects, projects);
|
const upsert = event as SessionUpsertedEvent;
|
||||||
const updatedProjects = mergeExpandedSessionPages(projects, updatedProjectsWithTaskMaster);
|
if (!upsert.sessionId || !upsert.session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
// The transcript of the currently viewed session changed on disk while
|
||||||
hasActiveSession &&
|
// no run is active here (e.g. edited from another client or the CLI):
|
||||||
!isUpdateAdditive(projects, updatedProjects, selectedProject, selectedSession)
|
// signal the chat view to reload its messages.
|
||||||
) {
|
const currentSelectedSession = selectedSessionRef.current;
|
||||||
return;
|
if (
|
||||||
}
|
currentSelectedSession
|
||||||
|
&& upsert.sessionId === currentSelectedSession.id
|
||||||
|
&& !activeSessionsRef.current.has(upsert.sessionId)
|
||||||
|
) {
|
||||||
|
setExternalMessageUpdate((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
|
||||||
setProjects((previousProjects) =>
|
setProjects((previousProjects) => {
|
||||||
projectsHaveChanges(previousProjects, updatedProjects, true) ? updatedProjects : previousProjects,
|
const targetProjectId = upsert.project?.projectId;
|
||||||
);
|
const existingProject = previousProjects.find((project) =>
|
||||||
|
targetProjectId ? project.projectId === targetProjectId : getProjectSessions(project).some((session) => session.id === upsert.sessionId),
|
||||||
|
);
|
||||||
|
|
||||||
if (!selectedProject) {
|
if (!existingProject) {
|
||||||
return;
|
// First session of a project this client has never seen: create the
|
||||||
}
|
// project entry from the event payload.
|
||||||
|
if (!upsert.project) {
|
||||||
|
return previousProjects;
|
||||||
|
}
|
||||||
|
|
||||||
const updatedSelectedProject = updatedProjects.find(
|
const newProject: Project = {
|
||||||
(project) => project.projectId === selectedProject.projectId,
|
projectId: upsert.project.projectId,
|
||||||
);
|
path: upsert.project.path,
|
||||||
|
fullPath: upsert.project.fullPath,
|
||||||
|
displayName: upsert.project.displayName,
|
||||||
|
isStarred: upsert.project.isStarred,
|
||||||
|
sessions: [],
|
||||||
|
cursorSessions: [],
|
||||||
|
codexSessions: [],
|
||||||
|
geminiSessions: [],
|
||||||
|
opencodeSessions: [],
|
||||||
|
sessionMeta: { hasMore: false, total: 0 },
|
||||||
|
} as Project;
|
||||||
|
|
||||||
if (!updatedSelectedProject) {
|
return [...previousProjects, upsertSessionIntoProject(newProject, upsert)];
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (serialize(updatedSelectedProject) !== serialize(selectedProject)) {
|
const updatedProject = upsertSessionIntoProject(existingProject, upsert);
|
||||||
setSelectedProject(updatedSelectedProject);
|
if (updatedProject === existingProject) {
|
||||||
}
|
return previousProjects;
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedSession) {
|
return previousProjects.map((project) =>
|
||||||
return;
|
project.projectId === existingProject.projectId ? updatedProject : project,
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find(
|
// Keep the selected project reference in sync with the upsert.
|
||||||
(session) => session.id === selectedSession.id,
|
setSelectedProject((previousProject) => {
|
||||||
);
|
if (!previousProject) {
|
||||||
|
return previousProject;
|
||||||
|
}
|
||||||
|
const matches = upsert.project
|
||||||
|
? previousProject.projectId === upsert.project.projectId
|
||||||
|
: getProjectSessions(previousProject).some((session) => session.id === upsert.sessionId);
|
||||||
|
if (!matches) {
|
||||||
|
return previousProject;
|
||||||
|
}
|
||||||
|
const updated = upsertSessionIntoProject(previousProject, upsert);
|
||||||
|
return updated === previousProject ? previousProject : updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (!updatedSelectedSession) {
|
return subscribe(handleEvent);
|
||||||
setSelectedSession(null);
|
}, [subscribe]);
|
||||||
}
|
|
||||||
}, [latestMessage, selectedProject, selectedSession, activeSessions, projects]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -576,10 +726,12 @@ export function useProjectsState({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session id is in the URL but not yet present on any project payload (common
|
// Session id is in the URL but not yet present on any project payload
|
||||||
// right after `session_created` + navigate, before the next projects refresh).
|
// (normal for a brand-new conversation: the composer allocates the id and
|
||||||
// Without a `selectedSession`, chat state clears `currentSessionId` and the
|
// navigates before the sidebar learns about the session via
|
||||||
// UI stops reading the session store even though messages stream under this id.
|
// `session_upserted`). Without a `selectedSession`, chat state clears
|
||||||
|
// `currentSessionId` and the UI stops reading the session store even
|
||||||
|
// though messages stream under this id — so synthesize a placeholder.
|
||||||
if (selectedSession?.id === sessionId) {
|
if (selectedSession?.id === sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -635,11 +787,6 @@ export function useProjectsState({
|
|||||||
setActiveTab('chat');
|
setActiveTab('chat');
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = localStorage.getItem('selected-provider') || 'claude';
|
|
||||||
if (provider === 'cursor') {
|
|
||||||
sessionStorage.setItem('cursorSessionId', session.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
// Sessions are tagged with the owning project's DB `projectId` when
|
// Sessions are tagged with the owning project's DB `projectId` when
|
||||||
// picked from the sidebar (see useSidebarController); compare against
|
// picked from the sidebar (see useSidebarController); compare against
|
||||||
@@ -840,6 +987,7 @@ export function useProjectsState({
|
|||||||
projects,
|
projects,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
|
activeSessions,
|
||||||
onProjectSelect: handleProjectSelect,
|
onProjectSelect: handleProjectSelect,
|
||||||
onSessionSelect: handleSessionSelect,
|
onSessionSelect: handleSessionSelect,
|
||||||
onNewSession: handleNewSession,
|
onNewSession: handleNewSession,
|
||||||
@@ -866,6 +1014,7 @@ export function useProjectsState({
|
|||||||
isLoadingProjects,
|
isLoadingProjects,
|
||||||
isMobile,
|
isMobile,
|
||||||
loadingProgress,
|
loadingProgress,
|
||||||
|
activeSessions,
|
||||||
projects,
|
projects,
|
||||||
settingsInitialTab,
|
settingsInitialTab,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
@@ -894,6 +1043,7 @@ export function useProjectsState({
|
|||||||
openSettings,
|
openSettings,
|
||||||
fetchProjects,
|
fetchProjects,
|
||||||
refreshProjectsSilently,
|
refreshProjectsSilently,
|
||||||
|
registerOptimisticSession,
|
||||||
sidebarSharedProps,
|
sidebarSharedProps,
|
||||||
handleProjectSelect,
|
handleProjectSelect,
|
||||||
handleSessionSelect,
|
handleSessionSelect,
|
||||||
|
|||||||
@@ -1,55 +1,172 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
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 SessionActivitySnapshot = {
|
||||||
|
sessionId: string;
|
||||||
|
statusText?: string | null;
|
||||||
|
canInterrupt?: boolean;
|
||||||
|
startedAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarkSessionProcessing = (
|
||||||
|
sessionId?: string | null,
|
||||||
|
activity?: { statusText?: string | null; canInterrupt?: boolean },
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export type MarkSessionIdle = (
|
||||||
|
sessionId?: string | null,
|
||||||
|
opts?: { ifStartedBefore?: number },
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export type SyncProcessingSessions = (
|
||||||
|
sessions: readonly SessionActivitySnapshot[],
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
const LOCAL_ACTIVITY_GRACE_MS = 10_000;
|
||||||
|
|
||||||
|
const sessionActivityMapsMatch = (
|
||||||
|
left: ReadonlyMap<string, SessionActivity>,
|
||||||
|
right: ReadonlyMap<string, SessionActivity>,
|
||||||
|
): boolean => {
|
||||||
|
if (left.size !== right.size) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sessionId, leftActivity] of left) {
|
||||||
|
const rightActivity = right.get(sessionId);
|
||||||
|
if (
|
||||||
|
!rightActivity
|
||||||
|
|| leftActivity.statusText !== rightActivity.statusText
|
||||||
|
|| leftActivity.canInterrupt !== rightActivity.canInterrupt
|
||||||
|
|| leftActivity.startedAt !== rightActivity.startedAt
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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`, abort, an authoritative idle subscribe ack) delete the entry
|
||||||
|
* atomically. Session ids are always concrete (allocated before the first
|
||||||
|
* send), so entries are keyed by real session ids only.
|
||||||
|
*/
|
||||||
export function useSessionProtection() {
|
export function useSessionProtection() {
|
||||||
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
|
const [processingSessions, setProcessingSessions] = useState<Map<string, SessionActivity>>(
|
||||||
const [processingSessions, setProcessingSessions] = useState<Set<string>>(new Set());
|
new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
const markSessionAsActive = useCallback((sessionId?: string | null) => {
|
const markSessionProcessing = useCallback<MarkSessionProcessing>((sessionId, activity) => {
|
||||||
if (!sessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveSessions((prev) => new Set([...prev, sessionId]));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const markSessionAsInactive = useCallback((sessionId?: string | null) => {
|
|
||||||
if (!sessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveSessions((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(sessionId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const markSessionAsProcessing = useCallback((sessionId?: string | null) => {
|
|
||||||
if (!sessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProcessingSessions((prev) => new Set([...prev, sessionId]));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const markSessionAsNotProcessing = useCallback((sessionId?: string | null) => {
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProcessingSessions((prev) => {
|
setProcessingSessions((prev) => {
|
||||||
const next = new Set(prev);
|
const existing = prev.get(sessionId);
|
||||||
next.delete(sessionId);
|
const next: SessionActivity = {
|
||||||
return next;
|
statusText:
|
||||||
|
activity?.statusText !== undefined ? activity.statusText : existing?.statusText ?? null,
|
||||||
|
canInterrupt: activity?.canInterrupt ?? existing?.canInterrupt ?? true,
|
||||||
|
startedAt: existing?.startedAt ?? Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
existing
|
||||||
|
&& existing.statusText === next.statusText
|
||||||
|
&& existing.canInterrupt === next.canInterrupt
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = new Map(prev);
|
||||||
|
updated.set(sessionId, next);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markSessionIdle = useCallback<MarkSessionIdle>((sessionId, opts) => {
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessingSessions((prev) => {
|
||||||
|
const existing = prev.get(sessionId);
|
||||||
|
if (!existing) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against stale `chat_subscribed` idle acks: if a new request
|
||||||
|
// started after the subscribe was sent, the idle ack 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;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncProcessingSessions = useCallback<SyncProcessingSessions>((sessions) => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
setProcessingSessions((prev) => {
|
||||||
|
const incoming = new Map<string, SessionActivitySnapshot>();
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (!session.sessionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
incoming.set(session.sessionId, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = new Map<string, SessionActivity>();
|
||||||
|
|
||||||
|
for (const [sessionId, snapshot] of incoming) {
|
||||||
|
const existing = prev.get(sessionId);
|
||||||
|
const snapshotStartedAt =
|
||||||
|
typeof snapshot.startedAt === 'number' && Number.isFinite(snapshot.startedAt) && snapshot.startedAt > 0
|
||||||
|
? snapshot.startedAt
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
updated.set(sessionId, {
|
||||||
|
statusText:
|
||||||
|
snapshot.statusText !== undefined ? snapshot.statusText : existing?.statusText ?? null,
|
||||||
|
canInterrupt: snapshot.canInterrupt ?? existing?.canInterrupt ?? true,
|
||||||
|
startedAt: snapshotStartedAt ?? existing?.startedAt ?? now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sessionId, activity] of prev) {
|
||||||
|
if (!incoming.has(sessionId) && now - activity.startedAt < LOCAL_ACTIVITY_GRACE_MS) {
|
||||||
|
updated.set(sessionId, activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionActivityMapsMatch(prev, updated) ? prev : updated;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeSessions,
|
|
||||||
processingSessions,
|
processingSessions,
|
||||||
markSessionAsActive,
|
markSessionProcessing,
|
||||||
markSessionAsInactive,
|
markSessionIdle,
|
||||||
markSessionAsProcessing,
|
syncProcessingSessions,
|
||||||
markSessionAsNotProcessing,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,6 +224,7 @@
|
|||||||
"label": "{{time}} elapsed",
|
"label": "{{time}} elapsed",
|
||||||
"startingNow": "Starting now"
|
"startingNow": "Starting now"
|
||||||
},
|
},
|
||||||
|
"stop": "Stop",
|
||||||
"controls": {
|
"controls": {
|
||||||
"stopGeneration": "Stop Generation",
|
"stopGeneration": "Stop Generation",
|
||||||
"pressEscToStop": "Press Esc anytime to stop"
|
"pressEscToStop": "Press Esc anytime to stop"
|
||||||
|
|||||||
@@ -514,30 +514,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export interface NormalizedMessage {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
provider: LLMProvider;
|
provider: LLMProvider;
|
||||||
kind: MessageKind;
|
kind: MessageKind;
|
||||||
|
/**
|
||||||
|
* Per-run monotonic sequence number assigned by the backend to live
|
||||||
|
* websocket events. Used to compute `lastSeq` for `chat.subscribe` replay;
|
||||||
|
* REST history messages do not carry it.
|
||||||
|
*/
|
||||||
|
seq?: number;
|
||||||
|
|
||||||
// kind-specific fields (flat for simplicity)
|
// kind-specific fields (flat for simplicity)
|
||||||
role?: 'user' | 'assistant';
|
role?: 'user' | 'assistant';
|
||||||
@@ -122,12 +128,44 @@ function createEmptySlot(): SessionSlot {
|
|||||||
* assistant echo (same trimmed text), so finalized stream rows do not stack
|
* assistant echo (same trimmed text), so finalized stream rows do not stack
|
||||||
* on top of the persisted copy before realtime is cleared.
|
* on top of the persisted copy before realtime is cleared.
|
||||||
*/
|
*/
|
||||||
|
const LOCAL_USER_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
const LOCAL_USER_DEDUPE_CLOCK_SKEW_MS = 10_000;
|
||||||
|
|
||||||
function userTextFingerprint(m: NormalizedMessage): string | null {
|
function userTextFingerprint(m: NormalizedMessage): string | null {
|
||||||
if (m.kind !== 'text' || m.role !== 'user') return null;
|
if (m.kind !== 'text' || m.role !== 'user') return null;
|
||||||
const t = (m.content || '').trim();
|
const t = (m.content || '').trim();
|
||||||
return t.length > 0 ? t : null;
|
return t.length > 0 ? t : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readMessageTime(m: NormalizedMessage): number | null {
|
||||||
|
const time = Date.parse(m.timestamp);
|
||||||
|
return Number.isFinite(time) ? time : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasServerEchoForLocalUser(
|
||||||
|
localMessage: NormalizedMessage,
|
||||||
|
serverMessages: NormalizedMessage[],
|
||||||
|
): boolean {
|
||||||
|
const localText = userTextFingerprint(localMessage);
|
||||||
|
const localTime = readMessageTime(localMessage);
|
||||||
|
if (!localText || localTime === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverMessages.some((serverMessage) => {
|
||||||
|
if (userTextFingerprint(serverMessage) !== localText) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverTime = readMessageTime(serverMessage);
|
||||||
|
return (
|
||||||
|
serverTime !== null
|
||||||
|
&& serverTime >= localTime - LOCAL_USER_DEDUPE_CLOCK_SKEW_MS
|
||||||
|
&& serverTime - localTime <= LOCAL_USER_DEDUPE_WINDOW_MS
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After `finalizeStreaming`, the client holds a synthetic assistant `text` row
|
* After `finalizeStreaming`, the client holds a synthetic assistant `text` row
|
||||||
* while the sessions API soon returns the same reply with a different id.
|
* while the sessions API soon returns the same reply with a different id.
|
||||||
@@ -169,16 +207,13 @@ function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[
|
|||||||
if (realtime.length === 0) return server;
|
if (realtime.length === 0) return server;
|
||||||
if (server.length === 0) return dedupeAdjacentAssistantEchoes(realtime);
|
if (server.length === 0) return dedupeAdjacentAssistantEchoes(realtime);
|
||||||
const serverIds = new Set(server.map(m => m.id));
|
const serverIds = new Set(server.map(m => m.id));
|
||||||
const serverUserTexts = new Set(
|
|
||||||
server.map(userTextFingerprint).filter((t): t is string => t !== null),
|
|
||||||
);
|
|
||||||
const extra = realtime.filter((m) => {
|
const extra = realtime.filter((m) => {
|
||||||
if (serverIds.has(m.id)) return false;
|
if (serverIds.has(m.id)) return false;
|
||||||
// Optimistic user rows use `local_*` ids; once the same text exists on the
|
// Optimistic user rows use `local_*` ids; once the same text exists on the
|
||||||
// server-backed copy, drop the realtime echo to avoid duplicate bubbles.
|
// server-backed copy from the same send window, drop the realtime echo to
|
||||||
|
// avoid duplicate bubbles without hiding repeated prompts from history.
|
||||||
if (m.id.startsWith('local_')) {
|
if (m.id.startsWith('local_')) {
|
||||||
const fp = userTextFingerprint(m);
|
if (hasServerEchoForLocalUser(m, server)) return false;
|
||||||
if (fp && serverUserTexts.has(fp)) return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -186,60 +221,6 @@ function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[
|
|||||||
return dedupeAdjacentAssistantEchoes([...server, ...extra]);
|
return dedupeAdjacentAssistantEchoes([...server, ...extra]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareMessagesByTimestamp(left: NormalizedMessage, right: NormalizedMessage): number {
|
|
||||||
const leftTime = Date.parse(left.timestamp);
|
|
||||||
const rightTime = Date.parse(right.timestamp);
|
|
||||||
|
|
||||||
if (Number.isNaN(leftTime) || Number.isNaN(rightTime) || leftTime === rightTime) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return leftTime - rightTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rewriteMessageSessionId(
|
|
||||||
msg: NormalizedMessage,
|
|
||||||
fromSessionId: string,
|
|
||||||
toSessionId: string,
|
|
||||||
): NormalizedMessage {
|
|
||||||
const streamingSourceId = `__streaming_${fromSessionId}`;
|
|
||||||
const nextId = msg.id === streamingSourceId ? `__streaming_${toSessionId}` : msg.id;
|
|
||||||
|
|
||||||
if (msg.sessionId === toSessionId && nextId === msg.id) {
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...msg,
|
|
||||||
id: nextId,
|
|
||||||
sessionId: toSessionId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeMessagesById(
|
|
||||||
existing: NormalizedMessage[],
|
|
||||||
incoming: NormalizedMessage[],
|
|
||||||
): NormalizedMessage[] {
|
|
||||||
if (existing.length === 0) return incoming;
|
|
||||||
if (incoming.length === 0) return existing;
|
|
||||||
|
|
||||||
const merged = [...existing, ...incoming];
|
|
||||||
const deduped: NormalizedMessage[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
for (const msg of merged) {
|
|
||||||
if (seen.has(msg.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
seen.add(msg.id);
|
|
||||||
deduped.push(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
deduped.sort(compareMessagesByTimestamp);
|
|
||||||
return deduped;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recompute slot.merged only when the input arrays have actually changed
|
* Recompute slot.merged only when the input arrays have actually changed
|
||||||
* (by reference). Returns true if merged was recomputed.
|
* (by reference). Returns true if merged was recomputed.
|
||||||
@@ -264,64 +245,39 @@ const MAX_REALTIME_MESSAGES = 500;
|
|||||||
|
|
||||||
export function useSessionStore() {
|
export function useSessionStore() {
|
||||||
const storeRef = useRef(new Map<string, SessionSlot>());
|
const storeRef = useRef(new Map<string, SessionSlot>());
|
||||||
const sessionAliasesRef = useRef(new Map<string, string>());
|
|
||||||
const activeSessionIdRef = useRef<string | null>(null);
|
const activeSessionIdRef = useRef<string | null>(null);
|
||||||
// Bump to force re-render — only when the active session's data changes
|
// Bump to force re-render — only when the active session's data changes.
|
||||||
|
// Session ids are stable for the whole conversation lifetime (the backend
|
||||||
|
// allocates them before the first send), so slots are keyed directly with
|
||||||
|
// no alias/redirect indirection.
|
||||||
const [, setTick] = useState(0);
|
const [, setTick] = useState(0);
|
||||||
const notify = useCallback((sessionId: string) => {
|
const notify = useCallback((sessionId: string) => {
|
||||||
const aliases = sessionAliasesRef.current;
|
if (sessionId === activeSessionIdRef.current) {
|
||||||
let resolvedSessionId = sessionId;
|
|
||||||
const visited = new Set<string>();
|
|
||||||
|
|
||||||
while (aliases.has(resolvedSessionId) && !visited.has(resolvedSessionId)) {
|
|
||||||
visited.add(resolvedSessionId);
|
|
||||||
resolvedSessionId = aliases.get(resolvedSessionId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolvedSessionId === activeSessionIdRef.current) {
|
|
||||||
setTick(n => n + 1);
|
setTick(n => n + 1);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const resolveSessionId = useCallback((sessionId: string | null | undefined): string | null => {
|
const setActiveSession = useCallback((sessionId: string | null) => {
|
||||||
if (!sessionId) {
|
activeSessionIdRef.current = sessionId;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const aliases = sessionAliasesRef.current;
|
|
||||||
let resolvedSessionId = sessionId;
|
|
||||||
const visited = new Set<string>();
|
|
||||||
|
|
||||||
while (aliases.has(resolvedSessionId) && !visited.has(resolvedSessionId)) {
|
|
||||||
visited.add(resolvedSessionId);
|
|
||||||
resolvedSessionId = aliases.get(resolvedSessionId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolvedSessionId;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setActiveSession = useCallback((sessionId: string | null) => {
|
|
||||||
activeSessionIdRef.current = resolveSessionId(sessionId);
|
|
||||||
}, [resolveSessionId]);
|
|
||||||
|
|
||||||
const getSlot = useCallback((sessionId: string): SessionSlot => {
|
const getSlot = useCallback((sessionId: string): SessionSlot => {
|
||||||
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
|
|
||||||
const store = storeRef.current;
|
const store = storeRef.current;
|
||||||
if (!store.has(resolvedSessionId)) {
|
if (!store.has(sessionId)) {
|
||||||
store.set(resolvedSessionId, createEmptySlot());
|
store.set(sessionId, createEmptySlot());
|
||||||
}
|
}
|
||||||
return store.get(resolvedSessionId)!;
|
return store.get(sessionId)!;
|
||||||
}, [resolveSessionId]);
|
}, []);
|
||||||
|
|
||||||
const has = useCallback((sessionId: string) => {
|
const has = useCallback((sessionId: string) => {
|
||||||
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
|
return storeRef.current.has(sessionId);
|
||||||
return storeRef.current.has(resolvedSessionId);
|
}, []);
|
||||||
}, [resolveSessionId]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch messages from the provider sessions endpoint and populate serverMessages.
|
* Fetch messages from the provider sessions endpoint and populate serverMessages.
|
||||||
*
|
*
|
||||||
* Provider and project metadata are resolved server-side from `sessionId`.
|
* Provider and project metadata are resolved server-side from `sessionId`.
|
||||||
|
* The endpoint returns the standard `{ success, data }` envelope.
|
||||||
*/
|
*/
|
||||||
const fetchFromServer = useCallback(async (
|
const fetchFromServer = useCallback(async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -333,10 +289,9 @@ export function useSessionStore() {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
|
const slot = getSlot(sessionId);
|
||||||
const slot = getSlot(resolvedSessionId);
|
|
||||||
slot.status = 'loading';
|
slot.status = 'loading';
|
||||||
notify(resolvedSessionId);
|
notify(sessionId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -346,14 +301,15 @@ export function useSessionStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`;
|
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`;
|
||||||
const response = await authenticatedFetch(url);
|
const response = await authenticatedFetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const body = await response.json();
|
||||||
|
const data = body?.data ?? body;
|
||||||
const messages: NormalizedMessage[] = data.messages || [];
|
const messages: NormalizedMessage[] = data.messages || [];
|
||||||
|
|
||||||
slot.serverMessages = messages;
|
slot.serverMessages = messages;
|
||||||
@@ -367,15 +323,15 @@ export function useSessionStore() {
|
|||||||
slot.tokenUsage = data.tokenUsage;
|
slot.tokenUsage = data.tokenUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
notify(resolvedSessionId);
|
notify(sessionId);
|
||||||
return slot;
|
return slot;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[SessionStore] fetch failed for ${resolvedSessionId}:`, error);
|
console.error(`[SessionStore] fetch failed for ${sessionId}:`, error);
|
||||||
slot.status = 'error';
|
slot.status = 'error';
|
||||||
notify(resolvedSessionId);
|
notify(sessionId);
|
||||||
return slot;
|
return slot;
|
||||||
}
|
}
|
||||||
}, [getSlot, notify, resolveSessionId]);
|
}, [getSlot, notify]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load older (paginated) messages and prepend to serverMessages.
|
* Load older (paginated) messages and prepend to serverMessages.
|
||||||
@@ -389,8 +345,7 @@ export function useSessionStore() {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
|
const slot = getSlot(sessionId);
|
||||||
const slot = getSlot(resolvedSessionId);
|
|
||||||
if (!slot.hasMore) return slot;
|
if (!slot.hasMore) return slot;
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -399,12 +354,13 @@ export function useSessionStore() {
|
|||||||
params.append('offset', String(slot.offset));
|
params.append('offset', String(slot.offset));
|
||||||
|
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`;
|
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(url);
|
const response = await authenticatedFetch(url);
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const data = await response.json();
|
const body = await response.json();
|
||||||
|
const data = body?.data ?? body;
|
||||||
const olderMessages: NormalizedMessage[] = data.messages || [];
|
const olderMessages: NormalizedMessage[] = data.messages || [];
|
||||||
|
|
||||||
// Prepend older messages (they're earlier in the conversation)
|
// Prepend older messages (they're earlier in the conversation)
|
||||||
@@ -412,45 +368,43 @@ export function useSessionStore() {
|
|||||||
slot.hasMore = Boolean(data.hasMore);
|
slot.hasMore = Boolean(data.hasMore);
|
||||||
slot.offset = slot.offset + olderMessages.length;
|
slot.offset = slot.offset + olderMessages.length;
|
||||||
recomputeMergedIfNeeded(slot);
|
recomputeMergedIfNeeded(slot);
|
||||||
notify(resolvedSessionId);
|
notify(sessionId);
|
||||||
return slot;
|
return slot;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[SessionStore] fetchMore failed for ${resolvedSessionId}:`, error);
|
console.error(`[SessionStore] fetchMore failed for ${sessionId}:`, error);
|
||||||
return slot;
|
return slot;
|
||||||
}
|
}
|
||||||
}, [getSlot, notify, resolveSessionId]);
|
}, [getSlot, notify]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append a realtime (WebSocket) message to the correct session slot.
|
* Append a realtime (WebSocket) message to the correct session slot.
|
||||||
* This works regardless of which session is actively viewed.
|
* This works regardless of which session is actively viewed.
|
||||||
*/
|
*/
|
||||||
const appendRealtime = useCallback((sessionId: string, msg: NormalizedMessage) => {
|
const appendRealtime = useCallback((sessionId: string, msg: NormalizedMessage) => {
|
||||||
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
|
const slot = getSlot(sessionId);
|
||||||
const slot = getSlot(resolvedSessionId);
|
|
||||||
const normalizedMessage =
|
const normalizedMessage =
|
||||||
msg.sessionId === resolvedSessionId
|
msg.sessionId === sessionId
|
||||||
? msg
|
? msg
|
||||||
: { ...msg, sessionId: resolvedSessionId };
|
: { ...msg, sessionId };
|
||||||
let updated = [...slot.realtimeMessages, normalizedMessage];
|
let updated = [...slot.realtimeMessages, normalizedMessage];
|
||||||
if (updated.length > MAX_REALTIME_MESSAGES) {
|
if (updated.length > MAX_REALTIME_MESSAGES) {
|
||||||
updated = updated.slice(-MAX_REALTIME_MESSAGES);
|
updated = updated.slice(-MAX_REALTIME_MESSAGES);
|
||||||
}
|
}
|
||||||
slot.realtimeMessages = updated;
|
slot.realtimeMessages = updated;
|
||||||
recomputeMergedIfNeeded(slot);
|
recomputeMergedIfNeeded(slot);
|
||||||
notify(resolvedSessionId);
|
notify(sessionId);
|
||||||
}, [getSlot, notify, resolveSessionId]);
|
}, [getSlot, notify]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append multiple realtime messages at once (batch).
|
* Append multiple realtime messages at once (batch).
|
||||||
*/
|
*/
|
||||||
const appendRealtimeBatch = useCallback((sessionId: string, msgs: NormalizedMessage[]) => {
|
const appendRealtimeBatch = useCallback((sessionId: string, msgs: NormalizedMessage[]) => {
|
||||||
if (msgs.length === 0) return;
|
if (msgs.length === 0) return;
|
||||||
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
|
const slot = getSlot(sessionId);
|
||||||
const slot = getSlot(resolvedSessionId);
|
|
||||||
const normalizedMessages = msgs.map((msg) =>
|
const normalizedMessages = msgs.map((msg) =>
|
||||||
msg.sessionId === resolvedSessionId
|
msg.sessionId === sessionId
|
||||||
? msg
|
? msg
|
||||||
: { ...msg, sessionId: resolvedSessionId },
|
: { ...msg, sessionId },
|
||||||
);
|
);
|
||||||
let updated = [...slot.realtimeMessages, ...normalizedMessages];
|
let updated = [...slot.realtimeMessages, ...normalizedMessages];
|
||||||
if (updated.length > MAX_REALTIME_MESSAGES) {
|
if (updated.length > MAX_REALTIME_MESSAGES) {
|
||||||
@@ -458,8 +412,8 @@ export function useSessionStore() {
|
|||||||
}
|
}
|
||||||
slot.realtimeMessages = updated;
|
slot.realtimeMessages = updated;
|
||||||
recomputeMergedIfNeeded(slot);
|
recomputeMergedIfNeeded(slot);
|
||||||
notify(resolvedSessionId);
|
notify(sessionId);
|
||||||
}, [getSlot, notify, resolveSessionId]);
|
}, [getSlot, notify]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-fetch serverMessages from the provider sessions endpoint.
|
* Re-fetch serverMessages from the provider sessions endpoint.
|
||||||
@@ -472,17 +426,14 @@ export function useSessionStore() {
|
|||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
|
const slot = getSlot(sessionId);
|
||||||
const slot = getSlot(resolvedSessionId);
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages`;
|
||||||
|
|
||||||
const qs = params.toString();
|
|
||||||
const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`;
|
|
||||||
const response = await authenticatedFetch(url);
|
const response = await authenticatedFetch(url);
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const data = await response.json();
|
const body = await response.json();
|
||||||
|
const data = body?.data ?? body;
|
||||||
|
|
||||||
slot.serverMessages = data.messages || [];
|
slot.serverMessages = data.messages || [];
|
||||||
slot.total = data.total ?? slot.serverMessages.length;
|
slot.total = data.total ?? slot.serverMessages.length;
|
||||||
@@ -491,43 +442,40 @@ export function useSessionStore() {
|
|||||||
// drop realtime messages that the server has caught up with to prevent unbounded growth.
|
// drop realtime messages that the server has caught up with to prevent unbounded growth.
|
||||||
slot.realtimeMessages = [];
|
slot.realtimeMessages = [];
|
||||||
recomputeMergedIfNeeded(slot);
|
recomputeMergedIfNeeded(slot);
|
||||||
notify(resolvedSessionId);
|
notify(sessionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[SessionStore] refresh failed for ${resolvedSessionId}:`, error);
|
console.error(`[SessionStore] refresh failed for ${sessionId}:`, error);
|
||||||
}
|
}
|
||||||
}, [getSlot, notify, resolveSessionId]);
|
}, [getSlot, notify]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update session status.
|
* Update session status.
|
||||||
*/
|
*/
|
||||||
const setStatus = useCallback((sessionId: string, status: SessionStatus) => {
|
const setStatus = useCallback((sessionId: string, status: SessionStatus) => {
|
||||||
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
|
const slot = getSlot(sessionId);
|
||||||
const slot = getSlot(resolvedSessionId);
|
|
||||||
slot.status = status;
|
slot.status = status;
|
||||||
notify(resolvedSessionId);
|
notify(sessionId);
|
||||||
}, [getSlot, notify, resolveSessionId]);
|
}, [getSlot, notify]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a session's data is stale (>30s old).
|
* Check if a session's data is stale (>30s old).
|
||||||
*/
|
*/
|
||||||
const isStale = useCallback((sessionId: string) => {
|
const isStale = useCallback((sessionId: string) => {
|
||||||
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
|
const slot = storeRef.current.get(sessionId);
|
||||||
const slot = storeRef.current.get(resolvedSessionId);
|
|
||||||
if (!slot) return true;
|
if (!slot) return true;
|
||||||
return Date.now() - slot.fetchedAt > STALE_THRESHOLD_MS;
|
return Date.now() - slot.fetchedAt > STALE_THRESHOLD_MS;
|
||||||
}, [resolveSessionId]);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update or create a streaming message (accumulated text so far).
|
* Update or create a streaming message (accumulated text so far).
|
||||||
* Uses a well-known ID so subsequent calls replace the same message.
|
* Uses a well-known ID so subsequent calls replace the same message.
|
||||||
*/
|
*/
|
||||||
const updateStreaming = useCallback((sessionId: string, accumulatedText: string, msgProvider: LLMProvider) => {
|
const updateStreaming = useCallback((sessionId: string, accumulatedText: string, msgProvider: LLMProvider) => {
|
||||||
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
|
const slot = getSlot(sessionId);
|
||||||
const slot = getSlot(resolvedSessionId);
|
const streamId = `__streaming_${sessionId}`;
|
||||||
const streamId = `__streaming_${resolvedSessionId}`;
|
|
||||||
const msg: NormalizedMessage = {
|
const msg: NormalizedMessage = {
|
||||||
id: streamId,
|
id: streamId,
|
||||||
sessionId: resolvedSessionId,
|
sessionId,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
provider: msgProvider,
|
provider: msgProvider,
|
||||||
kind: 'stream_delta',
|
kind: 'stream_delta',
|
||||||
@@ -541,18 +489,17 @@ export function useSessionStore() {
|
|||||||
slot.realtimeMessages = [...slot.realtimeMessages, msg];
|
slot.realtimeMessages = [...slot.realtimeMessages, msg];
|
||||||
}
|
}
|
||||||
recomputeMergedIfNeeded(slot);
|
recomputeMergedIfNeeded(slot);
|
||||||
notify(resolvedSessionId);
|
notify(sessionId);
|
||||||
}, [getSlot, notify, resolveSessionId]);
|
}, [getSlot, notify]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize streaming: convert the streaming message to a regular text message.
|
* Finalize streaming: convert the streaming message to a regular text message.
|
||||||
* The well-known streaming ID is replaced with a unique text message ID.
|
* The well-known streaming ID is replaced with a unique text message ID.
|
||||||
*/
|
*/
|
||||||
const finalizeStreaming = useCallback((sessionId: string) => {
|
const finalizeStreaming = useCallback((sessionId: string) => {
|
||||||
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
|
const slot = storeRef.current.get(sessionId);
|
||||||
const slot = storeRef.current.get(resolvedSessionId);
|
|
||||||
if (!slot) return;
|
if (!slot) return;
|
||||||
const streamId = `__streaming_${resolvedSessionId}`;
|
const streamId = `__streaming_${sessionId}`;
|
||||||
const idx = slot.realtimeMessages.findIndex(m => m.id === streamId);
|
const idx = slot.realtimeMessages.findIndex(m => m.id === streamId);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
const stream = slot.realtimeMessages[idx];
|
const stream = slot.realtimeMessages[idx];
|
||||||
@@ -564,104 +511,35 @@ export function useSessionStore() {
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
};
|
};
|
||||||
recomputeMergedIfNeeded(slot);
|
recomputeMergedIfNeeded(slot);
|
||||||
notify(resolvedSessionId);
|
notify(sessionId);
|
||||||
}
|
}
|
||||||
}, [notify, resolveSessionId]);
|
}, [notify]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear realtime messages for a session (e.g., after stream completes and server fetch catches up).
|
* Clear realtime messages for a session (e.g., after stream completes and server fetch catches up).
|
||||||
*/
|
*/
|
||||||
const clearRealtime = useCallback((sessionId: string) => {
|
const clearRealtime = useCallback((sessionId: string) => {
|
||||||
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
|
const slot = storeRef.current.get(sessionId);
|
||||||
const slot = storeRef.current.get(resolvedSessionId);
|
|
||||||
if (slot) {
|
if (slot) {
|
||||||
slot.realtimeMessages = [];
|
slot.realtimeMessages = [];
|
||||||
recomputeMergedIfNeeded(slot);
|
recomputeMergedIfNeeded(slot);
|
||||||
notify(resolvedSessionId);
|
notify(sessionId);
|
||||||
}
|
}
|
||||||
}, [notify, resolveSessionId]);
|
}, [notify]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get merged messages for a session (for rendering).
|
* Get merged messages for a session (for rendering).
|
||||||
*/
|
*/
|
||||||
const getMessages = useCallback((sessionId: string): NormalizedMessage[] => {
|
const getMessages = useCallback((sessionId: string): NormalizedMessage[] => {
|
||||||
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
|
return storeRef.current.get(sessionId)?.merged ?? [];
|
||||||
return storeRef.current.get(resolvedSessionId)?.merged ?? [];
|
}, []);
|
||||||
}, [resolveSessionId]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get session slot (for status, pagination info, etc.).
|
* Get session slot (for status, pagination info, etc.).
|
||||||
*/
|
*/
|
||||||
const getSessionSlot = useCallback((sessionId: string): SessionSlot | undefined => {
|
const getSessionSlot = useCallback((sessionId: string): SessionSlot | undefined => {
|
||||||
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
|
return storeRef.current.get(sessionId);
|
||||||
return storeRef.current.get(resolvedSessionId);
|
}, []);
|
||||||
}, [resolveSessionId]);
|
|
||||||
|
|
||||||
const replaceSessionId = useCallback((fromSessionId: string, toSessionId: string) => {
|
|
||||||
const resolvedFromSessionId = resolveSessionId(fromSessionId) ?? fromSessionId;
|
|
||||||
const resolvedToSessionId = resolveSessionId(toSessionId) ?? toSessionId;
|
|
||||||
|
|
||||||
if (resolvedFromSessionId === resolvedToSessionId) {
|
|
||||||
sessionAliasesRef.current.set(fromSessionId, resolvedToSessionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = storeRef.current;
|
|
||||||
const sourceSlot = store.get(resolvedFromSessionId);
|
|
||||||
const targetSlot = store.get(resolvedToSessionId) ?? createEmptySlot();
|
|
||||||
|
|
||||||
if (sourceSlot) {
|
|
||||||
const migratedServerMessages = sourceSlot.serverMessages.map((msg) =>
|
|
||||||
rewriteMessageSessionId(msg, resolvedFromSessionId, resolvedToSessionId),
|
|
||||||
);
|
|
||||||
const migratedRealtimeMessages = sourceSlot.realtimeMessages.map((msg) =>
|
|
||||||
rewriteMessageSessionId(msg, resolvedFromSessionId, resolvedToSessionId),
|
|
||||||
);
|
|
||||||
|
|
||||||
targetSlot.serverMessages = mergeMessagesById(targetSlot.serverMessages, migratedServerMessages);
|
|
||||||
targetSlot.realtimeMessages = mergeMessagesById(targetSlot.realtimeMessages, migratedRealtimeMessages);
|
|
||||||
if (targetSlot.realtimeMessages.length > MAX_REALTIME_MESSAGES) {
|
|
||||||
targetSlot.realtimeMessages = targetSlot.realtimeMessages.slice(-MAX_REALTIME_MESSAGES);
|
|
||||||
}
|
|
||||||
targetSlot.status =
|
|
||||||
sourceSlot.status === 'error'
|
|
||||||
? 'error'
|
|
||||||
: sourceSlot.status === 'streaming' || targetSlot.status === 'streaming'
|
|
||||||
? 'streaming'
|
|
||||||
: sourceSlot.status === 'loading' || targetSlot.status === 'loading'
|
|
||||||
? 'loading'
|
|
||||||
: targetSlot.status;
|
|
||||||
targetSlot.fetchedAt = Math.max(targetSlot.fetchedAt, sourceSlot.fetchedAt, Date.now());
|
|
||||||
targetSlot.total = Math.max(
|
|
||||||
targetSlot.total,
|
|
||||||
sourceSlot.total,
|
|
||||||
targetSlot.serverMessages.length,
|
|
||||||
targetSlot.realtimeMessages.length,
|
|
||||||
);
|
|
||||||
targetSlot.hasMore = targetSlot.hasMore || sourceSlot.hasMore;
|
|
||||||
targetSlot.offset = Math.max(targetSlot.offset, sourceSlot.offset);
|
|
||||||
targetSlot.tokenUsage = targetSlot.tokenUsage ?? sourceSlot.tokenUsage;
|
|
||||||
recomputeMergedIfNeeded(targetSlot);
|
|
||||||
|
|
||||||
store.set(resolvedToSessionId, targetSlot);
|
|
||||||
store.delete(resolvedFromSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionAliasesRef.current.set(resolvedFromSessionId, resolvedToSessionId);
|
|
||||||
sessionAliasesRef.current.set(fromSessionId, resolvedToSessionId);
|
|
||||||
|
|
||||||
for (const [aliasSessionId, targetSessionId] of sessionAliasesRef.current.entries()) {
|
|
||||||
if (targetSessionId === resolvedFromSessionId) {
|
|
||||||
sessionAliasesRef.current.set(aliasSessionId, resolvedToSessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSessionIdRef.current === resolvedFromSessionId) {
|
|
||||||
activeSessionIdRef.current = resolvedToSessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(resolvedToSessionId);
|
|
||||||
}, [notify, resolveSessionId]);
|
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
getSlot,
|
getSlot,
|
||||||
@@ -679,12 +557,11 @@ export function useSessionStore() {
|
|||||||
clearRealtime,
|
clearRealtime,
|
||||||
getMessages,
|
getMessages,
|
||||||
getSessionSlot,
|
getSessionSlot,
|
||||||
replaceSessionId,
|
|
||||||
}), [
|
}), [
|
||||||
getSlot, has, fetchFromServer, fetchMore,
|
getSlot, has, fetchFromServer, fetchMore,
|
||||||
appendRealtime, appendRealtimeBatch, refreshFromServer,
|
appendRealtime, appendRealtimeBatch, refreshFromServer,
|
||||||
setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming,
|
setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming,
|
||||||
clearRealtime, getMessages, getSessionSlot, replaceSessionId,
|
clearRealtime, getMessages, getSessionSlot,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,32 +70,10 @@ export interface Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadingProgress {
|
export interface LoadingProgress {
|
||||||
type?: 'loading_progress';
|
kind?: 'loading_progress';
|
||||||
phase?: string;
|
phase?: string;
|
||||||
current: number;
|
current: number;
|
||||||
total: number;
|
total: number;
|
||||||
currentProject?: string;
|
currentProject?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectsUpdatedMessage {
|
|
||||||
type: 'projects_updated';
|
|
||||||
projects: Project[];
|
|
||||||
updatedSessionId?: string;
|
|
||||||
updatedSessionIds?: string[];
|
|
||||||
watchProvider?: LLMProvider;
|
|
||||||
watchProviders?: LLMProvider[];
|
|
||||||
changeType?: 'add' | 'change';
|
|
||||||
changeTypes?: Array<'add' | 'change'>;
|
|
||||||
batched?: boolean;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoadingProgressMessage extends LoadingProgress {
|
|
||||||
type: 'loading_progress';
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AppSocketMessage =
|
|
||||||
| LoadingProgressMessage
|
|
||||||
| ProjectsUpdatedMessage
|
|
||||||
| { type?: string;[key: string]: unknown };
|
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export const api = {
|
|||||||
},
|
},
|
||||||
getArchivedSessions: () =>
|
getArchivedSessions: () =>
|
||||||
authenticatedFetch('/api/providers/sessions/archived'),
|
authenticatedFetch('/api/providers/sessions/archived'),
|
||||||
|
runningSessions: () =>
|
||||||
|
authenticatedFetch('/api/providers/sessions/running'),
|
||||||
restoreSession: (sessionId) =>
|
restoreSession: (sessionId) =>
|
||||||
authenticatedFetch(`/api/providers/sessions/${sessionId}/restore`, {
|
authenticatedFetch(`/api/providers/sessions/${sessionId}/restore`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user