Compare commits

..

26 Commits

Author SHA1 Message Date
Simos Mikelatos
c03ddb25fe Merge pull request #887 from siteboon/feat/unify-websocket-2
Refactor chat activity indicator and unify session lifecycle handling
2026-06-16 19:01:25 +02:00
Haileyesus
d7a38a567a chore: move tests to appropriate folder 2026-06-16 17:54:48 +03:00
Haileyesus
fec91d3deb Merge branch 'feat/unify-websocket-2' of https://github.com/siteboon/claudecodeui into feat/unify-websocket-2 2026-06-16 17:48:08 +03:00
Haileyesus
c6c153e7f2 chore: move tests to appropriate folder 2026-06-16 17:47:52 +03:00
Haile
4758ccf36e Merge branch 'main' into feat/unify-websocket-2 2026-06-16 17:39:10 +03:00
Haileyesus
e23e6af06a docs: update session activity guard comment 2026-06-16 17:27:54 +03:00
Haileyesus
56b2e14059 fix: recover pending permission requests 2026-06-16 17:20:40 +03:00
Haileyesus
39b0473e38 fix: keep running-session polling active
Keep the running-session poller active even when the local
processing set is empty so runs started from another tab or
client can still be discovered.
2026-06-16 16:52:12 +03:00
Aurélien
f319d2cf8d feat(i18n): add French (fr) locale (#878)
Complete French translation for all 7 locale files:
auth, chat, codeEditor, common, settings, sidebar, tasks.

Also fixes a bug in languages.js where the Turkish and Italian
entries shared the same object (missing closing brace), causing
Italian to be silently dropped from the supported languages list.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:02:50 +03:00
Haileyesus
9fb2d91b26 fix: resolve session provider on backend reads
Session history and token usage reads already have a stable app session id.
Passing provider and project hints from the frontend kept those reads coupled
with provider-specific state that the backend can resolve from the session row.

Resolve token usage provider server-side and narrow the session store read API
to session id plus pagination. This keeps provider-specific storage decisions
behind the backend boundary and makes reconnect, pagination, and load-all use
the same session-owned contract.
2026-06-15 14:04:50 +03:00
Haileyesus
9cb2afd67e fix: upgrade gemini logo 2026-06-15 13:47:28 +03:00
Haileyesus
d0adddbbda fix: normalize project session payloads
The sidebar had to understand cursorSessions, codexSessions,
and other provider buckets because /api/projects exposed
provider-shaped arrays.

That leaked backend adapter storage into project state and made
frontend behavior drift each time a provider needed another bucket
or exception.

Return one sessions list with provider metadata instead. Project
state, search, and running-session filtering now share one contract,
while provider-specific storage remains behind the backend boundary.
2026-06-15 13:43:18 +03:00
Haileyesus
2abb45636b fix: remove provider specific token usage calculator 2026-06-15 13:36:57 +03:00
Haileyesus
677d330981 fix: create one unified function for frontend session processing 2026-06-15 13:36:35 +03:00
Haileyesus
1b336e9aa9 fix(sidebar): align session status controls across layouts 2026-06-13 00:09:59 +03:00
Haileyesus
7bed675ad5 fix: changes provider logos to svg for fast load 2026-06-13 00:04:56 +03:00
Haileyesus
5b9adbbdee fix(opencode): bind watcher sessions to app rows early 2026-06-12 23:22:11 +03:00
Haileyesus
416a737d76 fix(opencode): pass workspace dir explicitly
The remote environment could start OpenCode runs under /opt/claudecodeui.

That happened even when the selected project path was correct.

The integration relied on child-process cwd alone.

OpenCode run resolves its workspace through the explicit --dir contract.

Pass --dir with the resolved working directory.

Assert in the CLI test that launch args include the workspace dir.
2026-06-12 22:45:22 +03:00
Haileyesus
3bbb42c233 fix(sessions): canonicalize sidebar ids and timestamps
The sidebar could keep a provider-native id after backend remapping.

That left a duplicate non-working session visible until refresh.

Fresh sessions could also appear hours old.

SQLite CURRENT_TIMESTAMP is UTC without a timezone suffix.

Browser parsing then treated those values like local time.

Broadcast a canonical session_upserted event when the provider id is mapped.

Collapse provider-id aliases onto the stable app session id in the client.

Normalize session-row timestamps to ISO UTC when reading from the repository.
2026-06-12 20:52:18 +03:00
Haileyesus
123ae31020 fix(chat): sort messages appropriately 2026-06-11 21:48:46 +03:00
Haileyesus
89f05247ed fix(shell): use correct session id 2026-06-11 21:04:31 +03:00
Simos Mikelatos
86f64797b0 Merge pull request #867 from siteboon/chore/add-github-issues-board-plugin 2026-06-11 13:29:44 +02:00
Haileyesus
21b0f14e7a chore: add github issues board plugin 2026-06-11 14:00:41 +03:00
Simos Mikelatos
f12af8a61b Merge pull request #864 from siteboon/chore/add-plugins
chore: add plugins
2026-06-11 09:51:07 +02:00
Haileyesus
f549bd99e7 docs: update available plugin readmes 2026-06-10 16:57:40 +03:00
Haileyesus
bc34085af9 chore: add more plugins list 2026-06-10 16:49:38 +03:00
52 changed files with 2620 additions and 558 deletions

1
.gitignore vendored
View File

@@ -134,6 +134,7 @@ tasks/
# Translations # Translations
!src/i18n/locales/en/tasks.json !src/i18n/locales/en/tasks.json
!src/i18n/locales/fr/tasks.json
!src/i18n/locales/ja/tasks.json !src/i18n/locales/ja/tasks.json
!src/i18n/locales/ru/tasks.json !src/i18n/locales/ru/tasks.json
!src/i18n/locales/de/tasks.json !src/i18n/locales/de/tasks.json

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -112,7 +112,17 @@ const wss = createWebSocketServer(server, {
getPendingApprovalsForSession, getPendingApprovalsForSession,
}, },
shell: { shell: {
getSessionById: (sessionId) => sessionManager.getSession(sessionId), resolveProviderSessionId: (sessionId, provider) => {
const dbSession = sessionsDb.getSessionById(sessionId);
const legacyGeminiSession =
provider === 'gemini' ? sessionManager.getSession(sessionId) : null;
if (dbSession) {
return dbSession.provider_session_id ?? legacyGeminiSession?.cliSessionId ?? null;
}
return legacyGeminiSession?.cliSessionId;
},
stripAnsiSequences, stripAnsiSequences,
normalizeDetectedUrl, normalizeDetectedUrl,
extractUrlsFromText, extractUrlsFromText,
@@ -1125,7 +1135,6 @@ app.post('/api/projects/:projectId/upload-images', authenticateToken, async (req
app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => { app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
try { try {
const { projectId, sessionId } = req.params; const { projectId, sessionId } = req.params;
const { provider = 'claude' } = req.query;
const homeDir = os.homedir(); const homeDir = os.homedir();
// Allow only safe characters in sessionId // Allow only safe characters in sessionId
@@ -1136,8 +1145,14 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
// Provider artifacts on disk (JSONL file names, OpenCode sqlite rows) // Provider artifacts on disk (JSONL file names, OpenCode sqlite rows)
// are keyed by the provider-native session id, while the caller sends // are keyed by the provider-native session id, while the caller sends
// the app-facing id. Resolve the mapping once for all branches below. // the app-facing id. Resolve provider and id mapping from the indexed
// session row so the frontend does not choose provider-specific paths.
const sessionRow = sessionsDb.getSessionById(safeSessionId); const sessionRow = sessionsDb.getSessionById(safeSessionId);
if (!sessionRow) {
return res.status(404).json({ error: 'Session not found', sessionId: safeSessionId });
}
const provider = sessionRow.provider || 'claude';
const providerNativeSessionId = sessionRow?.provider_session_id || safeSessionId; 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

View File

@@ -17,10 +17,19 @@ type SessionRow = {
const SESSION_ROW_COLUMNS = const SESSION_ROW_COLUMNS =
'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at'; 'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at';
const SQLITE_UTC_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
function normalizeTimestamp(value?: string): string | null { function normalizeTimestamp(value?: string): string | null {
if (!value) return null; if (!value) return null;
const parsed = new Date(value); // SQLite CURRENT_TIMESTAMP is stored as UTC without a timezone suffix.
// Normalize it here so every session reader returns canonical ISO strings
// and the sidebar never interprets fresh rows as local-time "hours old".
const normalizedValue = SQLITE_UTC_TIMESTAMP_REGEX.test(value)
? `${value.replace(' ', 'T')}Z`
: value;
const parsed = new Date(normalizedValue);
if (Number.isNaN(parsed.getTime())) { if (Number.isNaN(parsed.getTime())) {
return null; return null;
} }
@@ -28,6 +37,22 @@ function normalizeTimestamp(value?: string): string | null {
return parsed.toISOString(); return parsed.toISOString();
} }
function normalizeSessionRow<T extends SessionRow | null | undefined>(row: T): T {
if (!row) {
return row;
}
return {
...row,
created_at: normalizeTimestamp(row.created_at) ?? row.created_at,
updated_at: normalizeTimestamp(row.updated_at) ?? row.updated_at,
};
}
function normalizeSessionRows(rows: SessionRow[]): SessionRow[] {
return rows.map((row) => normalizeSessionRow(row) as SessionRow);
}
function normalizeProjectPathForProvider(provider: string, projectPath: string): string { function normalizeProjectPathForProvider(provider: string, projectPath: string): string {
void provider; void provider;
return normalizeProjectPath(projectPath); return normalizeProjectPath(projectPath);
@@ -207,7 +232,7 @@ export const sessionsDb = {
) )
.get(sessionId) as SessionRow | undefined; .get(sessionId) as SessionRow | undefined;
return row ?? null; return normalizeSessionRow(row) ?? null;
}, },
/** /**
@@ -229,18 +254,57 @@ export const sessionsDb = {
) )
.get(providerSessionId) as SessionRow | undefined; .get(providerSessionId) as SessionRow | undefined;
return row ?? null; return normalizeSessionRow(row) ?? null;
},
/**
* Finds the newest app-created session for a project that is still waiting
* for its provider-native id to be recorded.
*
* Primary intention: OpenCode can expose a new session in its shared
* `opencode.db` before the websocket runtime reports that same provider id
* back to our app. At that moment the sidebar already has an optimistic
* app-owned session row, but the watcher only knows the provider-native id.
*
* Without this lookup, the synchronizer would insert a second row keyed by
* the provider id, then `assignProviderSessionId()` would merge it a moment
* later. That eventually self-heals, but on slow networks the user can still
* briefly see two sidebar sessions for the same conversation.
*
* This helper lets the synchronizer claim the pending app row first, so the
* provider id is attached before any watcher-created row exists. The result
* is simpler than frontend dedupe and keeps the race resolved at the source.
*/
findLatestPendingAppSession(provider: string, projectPath: string): SessionRow | null {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
const row = db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
FROM sessions
WHERE provider = ?
AND project_path = ?
AND provider_session_id IS NULL
AND isArchived = 0
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC
LIMIT 1`
)
.get(provider, normalizedProjectPath) as SessionRow | undefined;
return normalizeSessionRow(row) ?? null;
}, },
getAllSessions(): SessionRow[] { getAllSessions(): SessionRow[] {
const db = getConnection(); const db = getConnection();
return db const rows = db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT ${SESSION_ROW_COLUMNS}
FROM sessions FROM sessions
WHERE isArchived = 0` WHERE isArchived = 0`
) )
.all() as SessionRow[]; .all() as SessionRow[];
return normalizeSessionRows(rows);
}, },
/** /**
@@ -249,7 +313,7 @@ export const sessionsDb = {
*/ */
getArchivedSessions(): SessionRow[] { getArchivedSessions(): SessionRow[] {
const db = getConnection(); const db = getConnection();
return db const rows = db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT ${SESSION_ROW_COLUMNS}
FROM sessions FROM sessions
@@ -257,12 +321,14 @@ export const sessionsDb = {
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC` ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
) )
.all() as SessionRow[]; .all() as SessionRow[];
return normalizeSessionRows(rows);
}, },
getSessionsByProjectPath(projectPath: string): SessionRow[] { getSessionsByProjectPath(projectPath: string): SessionRow[] {
const db = getConnection(); const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath); const normalizedProjectPath = normalizeProjectPath(projectPath);
return db const rows = db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT ${SESSION_ROW_COLUMNS}
FROM sessions FROM sessions
@@ -270,6 +336,8 @@ export const sessionsDb = {
AND isArchived = 0` AND isArchived = 0`
) )
.all(normalizedProjectPath) as SessionRow[]; .all(normalizedProjectPath) as SessionRow[];
return normalizeSessionRows(rows);
}, },
/** /**
@@ -279,19 +347,21 @@ export const sessionsDb = {
getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] { getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] {
const db = getConnection(); const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath); const normalizedProjectPath = normalizeProjectPath(projectPath);
return db const rows = db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT ${SESSION_ROW_COLUMNS}
FROM sessions FROM sessions
WHERE project_path = ?` WHERE project_path = ?`
) )
.all(normalizedProjectPath) as SessionRow[]; .all(normalizedProjectPath) as SessionRow[];
return normalizeSessionRows(rows);
}, },
getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] { getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] {
const db = getConnection(); const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath); const normalizedProjectPath = normalizeProjectPath(projectPath);
return db const rows = db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT ${SESSION_ROW_COLUMNS}
FROM sessions FROM sessions
@@ -301,6 +371,8 @@ export const sessionsDb = {
LIMIT ? OFFSET ?` LIMIT ? OFFSET ?`
) )
.all(normalizedProjectPath, limit, offset) as SessionRow[]; .all(normalizedProjectPath, limit, offset) as SessionRow[];
return normalizeSessionRows(rows);
}, },
countSessionsByProjectPath(projectPath: string): number { countSessionsByProjectPath(projectPath: string): number {

View File

@@ -70,3 +70,15 @@ test('createSession reactivates archived rows when the session becomes active ag
assert.equal(restoredSession?.isArchived, 0); assert.equal(restoredSession?.isArchived, 0);
}); });
}); });
test('repository reads normalize SQLite UTC timestamps to ISO strings', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('session-timezone', 'claude', '/workspace/demo-project');
const row = sessionsDb.getSessionById('session-timezone');
assert.ok(row?.created_at.endsWith('Z'));
assert.ok(row?.updated_at.endsWith('Z'));
assert.match(row?.created_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
assert.match(row?.updated_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
});
});

View File

@@ -30,10 +30,6 @@ type ProjectApiView = {
isArchived: boolean; isArchived: boolean;
isStarred: boolean; isStarred: boolean;
sessions: []; sessions: [];
cursorSessions: [];
codexSessions: [];
geminiSessions: [];
opencodeSessions: [];
sessionMeta: { sessionMeta: {
hasMore: false; hasMore: false;
total: 0; total: 0;
@@ -82,10 +78,6 @@ function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiVie
isArchived: Boolean(projectRow.isArchived), isArchived: Boolean(projectRow.isArchived),
isStarred: Boolean(projectRow.isStarred), isStarred: Boolean(projectRow.isStarred),
sessions: [], sessions: [],
cursorSessions: [],
codexSessions: [],
geminiSessions: [],
opencodeSessions: [],
sessionMeta: { sessionMeta: {
hasMore: false, hasMore: false,
total: 0, total: 0,

View File

@@ -9,13 +9,12 @@ import { AppError } from '@/shared/utils.js';
type SessionSummary = { type SessionSummary = {
id: string; id: string;
provider: string;
summary: string; summary: string;
messageCount: number; messageCount: number;
lastActivity: string; lastActivity: string;
}; };
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>;
type SessionRepositoryRow = { type SessionRepositoryRow = {
provider: string; provider: string;
session_id: string; session_id: string;
@@ -31,10 +30,6 @@ export type ProjectListItem = {
fullPath: string; fullPath: string;
isStarred: boolean; isStarred: boolean;
sessions: SessionSummary[]; sessions: SessionSummary[];
cursorSessions: SessionSummary[];
codexSessions: SessionSummary[];
geminiSessions: SessionSummary[];
opencodeSessions: SessionSummary[];
sessionMeta: { sessionMeta: {
hasMore: boolean; hasMore: boolean;
total: number; total: number;
@@ -64,7 +59,7 @@ type SessionPaginationOptions = {
}; };
type ProjectSessionsPageResult = { type ProjectSessionsPageResult = {
sessionsByProvider: SessionsByProvider; sessions: SessionSummary[];
total: number; total: number;
hasMore: boolean; hasMore: boolean;
}; };
@@ -72,10 +67,6 @@ type ProjectSessionsPageResult = {
export type ProjectSessionsPageApiView = { export type ProjectSessionsPageApiView = {
projectId: string; projectId: string;
sessions: SessionSummary[]; sessions: SessionSummary[];
cursorSessions: SessionSummary[];
codexSessions: SessionSummary[];
geminiSessions: SessionSummary[];
opencodeSessions: SessionSummary[];
sessionMeta: { sessionMeta: {
hasMore: boolean; hasMore: boolean;
total: number; total: number;
@@ -129,39 +120,18 @@ function normalizeSessionPagination(options: SessionPaginationOptions = {}): { l
function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary { function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary {
return { return {
id: row.session_id, id: row.session_id,
provider: row.provider,
summary: row.custom_name || '', summary: row.custom_name || '',
messageCount: 0, messageCount: 0,
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(), lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
}; };
} }
function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByProvider {
const byProvider: SessionsByProvider = {
claude: [],
cursor: [],
codex: [],
gemini: [],
opencode: [],
};
for (const row of rows) {
const provider = row.provider as keyof SessionsByProvider;
const bucket = byProvider[provider];
if (!bucket) {
continue;
}
bucket.push(mapSessionRowToSummary(row));
}
return byProvider;
}
function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult { function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult {
const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[]; const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[];
return { return {
sessionsByProvider: bucketSessionRowsByProvider(rows), sessions: rows.map(mapSessionRowToSummary),
total: rows.length, total: rows.length,
hasMore: false, hasMore: false,
}; };
@@ -183,7 +153,7 @@ function readProjectSessionsPageByPath(
const total = sessionsDb.countSessionsByProjectPath(projectPath); const total = sessionsDb.countSessionsByProjectPath(projectPath);
return { return {
sessionsByProvider: bucketSessionRowsByProvider(rows), sessions: rows.map(mapSessionRowToSummary),
total, total,
hasMore: pagination.offset + rows.length < total, hasMore: pagination.offset + rows.length < total,
}; };
@@ -205,7 +175,7 @@ function broadcastProgress(progress: ProgressUpdate) {
} }
/** /**
* Reads all projects from DB and returns provider-bucketed session summaries. * Reads all projects from DB and returns normalized session summaries.
*/ */
export async function getProjectsWithSessions( export async function getProjectsWithSessions(
options: GetProjectsWithSessionsOptions = {} options: GetProjectsWithSessionsOptions = {}
@@ -253,11 +223,7 @@ export async function getProjectsWithSessions(
displayName, displayName,
fullPath: projectPath, fullPath: projectPath,
isStarred: Boolean(row.isStarred), isStarred: Boolean(row.isStarred),
sessions: sessionsPage.sessionsByProvider.claude, sessions: sessionsPage.sessions,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: { sessionMeta: {
hasMore: sessionsPage.hasMore, hasMore: sessionsPage.hasMore,
total: sessionsPage.total, total: sessionsPage.total,
@@ -310,11 +276,7 @@ export async function getArchivedProjectsWithSessions(
fullPath: row.project_path, fullPath: row.project_path,
isStarred: Boolean(row.isStarred), isStarred: Boolean(row.isStarred),
isArchived: true, isArchived: true,
sessions: sessionsPage.sessionsByProvider.claude, sessions: sessionsPage.sessions,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: { sessionMeta: {
hasMore: sessionsPage.hasMore, hasMore: sessionsPage.hasMore,
total: sessionsPage.total, total: sessionsPage.total,
@@ -343,11 +305,7 @@ export async function getProjectSessionsPage(
const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options); const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options);
return { return {
projectId: projectRow.project_id, projectId: projectRow.project_id,
sessions: sessionsPage.sessionsByProvider.claude, sessions: sessionsPage.sessions,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: { sessionMeta: {
hasMore: sessionsPage.hasMore, hasMore: sessionsPage.hasMore,
total: sessionsPage.total, total: sessionsPage.total,

View File

@@ -112,6 +112,17 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
} }
const fallbackTitle = 'Untitled OpenCode Session'; const fallbackTitle = 'Untitled OpenCode Session';
const pendingAppSession = sessionsDb.getSessionByProviderSessionId(sessionId)
?? sessionsDb.getSessionById(sessionId)
?? sessionsDb.findLatestPendingAppSession(this.provider, projectPath);
if (pendingAppSession && !pendingAppSession.provider_session_id) {
// Slow networks can let the sqlite watcher index opencode.db before the
// runtime reports its provider id back through the websocket mapping.
// Bind that id to the fresh app row first so the watcher does not create
// a temporary provider-id sidebar entry for the same session.
sessionsDb.assignProviderSessionId(pendingAppSession.session_id, sessionId);
}
// App-created sessions are keyed by an app id, so disk-discovered provider // App-created sessions are keyed by an app id, so disk-discovered provider
// ids must be resolved through the provider-id mapping first. // ids must be resolved through the provider-id mapping first.
const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId) const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId)
@@ -123,7 +134,9 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
// OpenCode stores every session in one shared sqlite database, so jsonl_path // OpenCode stores every session in one shared sqlite database, so jsonl_path
// must stay null to avoid deleting opencode.db when one app session is removed. // must stay null to avoid deleting opencode.db when one app session is removed.
sessionsDb.createSession( // Return the canonical stored row id so watcher-triggered sidebar updates
// stay on the app session once provider_session_id has already been mapped.
return sessionsDb.createSession(
sessionId, sessionId,
this.provider, this.provider,
projectPath, projectPath,
@@ -132,8 +145,6 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
normalizeProviderTimestamp(row.time_updated ?? row.time_created), normalizeProviderTimestamp(row.time_updated ?? row.time_created),
null, null,
); );
return sessionId;
} }
private readFirstUserText(db: Database.Database, sessionId: string): string | undefined { private readFirstUserText(db: Database.Database, sessionId: string): string | undefined {

View File

@@ -272,6 +272,55 @@ test('OpenCode session synchronizer indexes sqlite sessions without deletable tr
} }
}); });
test('OpenCode session synchronizer returns the app session id once provider mapping exists', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-mapped-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await createOpenCodeDatabase(tempRoot, workspacePath);
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-session-1', 'opencode', workspacePath);
sessionsDb.assignProviderSessionId('app-session-1', 'open-session-1');
const synchronizer = new OpenCodeSessionSynchronizer();
return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => {
assert.equal(sessionId, 'app-session-1');
assert.equal(sessionsDb.getAllSessions().length, 1);
assert.equal(sessionsDb.getSessionById('app-session-1')?.provider_session_id, 'open-session-1');
});
});
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});
test('OpenCode session synchronizer adopts the pending app session before watcher sync creates a duplicate', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-race-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await createOpenCodeDatabase(tempRoot, workspacePath);
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-session-race', 'opencode', workspacePath);
const synchronizer = new OpenCodeSessionSynchronizer();
return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => {
assert.equal(sessionId, 'app-session-race');
assert.equal(sessionsDb.getAllSessions().length, 1);
assert.equal(sessionsDb.getSessionById('app-session-race')?.provider_session_id, 'open-session-1');
});
});
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});
test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => { test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
const provider = new OpenCodeSessionsProvider(); const provider = new OpenCodeSessionsProvider();
const normalized = provider.normalizeMessage({ const normalized = provider.normalizeMessage({

View File

@@ -1,5 +1,9 @@
import { sessionsDb } from '@/modules/database/index.js'; import path from 'node:path';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { generateDisplayName } from '@/modules/projects/index.js';
import { ChatSessionWriter } from '@/modules/websocket/services/chat-session-writer.service.js'; import { ChatSessionWriter } from '@/modules/websocket/services/chat-session-writer.service.js';
import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
import type { import type {
LLMProvider, LLMProvider,
NormalizedMessage, NormalizedMessage,
@@ -58,6 +62,48 @@ const MAX_BUFFERED_EVENTS_PER_RUN = 5000;
*/ */
const runs = new Map<string, ChatRun>(); const runs = new Map<string, ChatRun>();
async function broadcastCanonicalSessionUpsert(appSessionId: string): Promise<void> {
const row = sessionsDb.getSessionById(appSessionId);
if (!row || row.isArchived) {
return;
}
const projectPath = row.project_path;
const project = projectPath ? projectsDb.getProjectPath(projectPath) : null;
const displayName = project?.custom_project_name?.trim()
? project.custom_project_name
: await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath);
const payload = JSON.stringify({
kind: 'session_upserted',
sessionId: row.session_id,
providerSessionId: row.provider_session_id,
provider: row.provider,
session: {
id: row.session_id,
summary: row.custom_name || '',
messageCount: 0,
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
},
project: project
? {
projectId: project.project_id,
path: project.project_path,
fullPath: project.project_path,
displayName,
isStarred: Boolean(project.isStarred),
}
: null,
timestamp: new Date().toISOString(),
});
connectedClients.forEach((client) => {
if (client.readyState === WS_OPEN_STATE) {
client.send(payload);
}
});
}
function evictRunLater(appSessionId: string): void { function evictRunLater(appSessionId: string): void {
const timer = setTimeout(() => { const timer = setTimeout(() => {
const run = runs.get(appSessionId); const run = runs.get(appSessionId);
@@ -132,6 +178,14 @@ function recordProviderSessionId(run: ChatRun, providerSessionId: string): void
try { try {
sessionsDb.assignProviderSessionId(run.appSessionId, providerSessionId); sessionsDb.assignProviderSessionId(run.appSessionId, providerSessionId);
void broadcastCanonicalSessionUpsert(run.appSessionId).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
console.error('[ChatRunRegistry] Failed to broadcast canonical session mapping', {
appSessionId: run.appSessionId,
providerSessionId,
error: message,
});
});
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.error('[ChatRunRegistry] Failed to persist provider session id mapping', { console.error('[ChatRunRegistry] Failed to persist provider session id mapping', {

View File

@@ -5,7 +5,6 @@ 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 = {
@@ -36,7 +35,10 @@ const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768; const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
type ShellWebSocketDependencies = { type ShellWebSocketDependencies = {
getSessionById: (sessionId: string) => { cliSessionId?: string } | null | undefined; resolveProviderSessionId: (
sessionId: string,
provider: string,
) => string | null | undefined;
stripAnsiSequences: (content: string) => string; stripAnsiSequences: (content: string) => string;
normalizeDetectedUrl: (url: string) => string | null; normalizeDetectedUrl: (url: string) => string | null;
extractUrlsFromText: (content: string) => string[]; extractUrlsFromText: (content: string) => string[];
@@ -79,36 +81,32 @@ function parseShellMessage(rawMessage: RawData): ShellIncomingMessage | null {
const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9_.\-:]+$/; 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( function resolveResumeSessionId(
appSessionId: string, message: ShellIncomingMessage,
provider: string,
dependencies: ShellWebSocketDependencies dependencies: ShellWebSocketDependencies
): string | null { ): string {
try { const hasSession = readBoolean(message.hasSession);
const sessionRow = sessionsDb.getSessionById(appSessionId); const sessionId = readString(message.sessionId);
const providerSessionId = sessionRow?.provider_session_id; const provider = readString(message.provider, 'claude');
if (providerSessionId && SAFE_SESSION_ID_PATTERN.test(providerSessionId)) {
return providerSessionId;
}
if (provider === 'gemini') { if (!hasSession || !sessionId) {
const geminiSession = dependencies.getSessionById(appSessionId); return '';
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; let resumeSessionId: string | null | undefined;
try {
resumeSessionId = dependencies.resolveProviderSessionId(sessionId, provider);
} catch (error) {
console.error('Failed to resolve provider session ID:', error);
resumeSessionId = undefined;
}
const resolvedSessionId = resumeSessionId === undefined ? sessionId : resumeSessionId;
if (!resolvedSessionId || !SAFE_SESSION_ID_PATTERN.test(resolvedSessionId)) {
return '';
}
return resolvedSessionId;
} }
/** /**
@@ -119,9 +117,9 @@ function buildShellCommand(
dependencies: ShellWebSocketDependencies dependencies: ShellWebSocketDependencies
): string { ): string {
const hasSession = readBoolean(message.hasSession); const hasSession = readBoolean(message.hasSession);
const sessionId = readString(message.sessionId);
const initialCommand = readString(message.initialCommand); const initialCommand = readString(message.initialCommand);
const provider = readString(message.provider, 'claude'); const provider = readString(message.provider, 'claude');
const resumeSessionId = resolveResumeSessionId(message, dependencies);
const isPlainShell = const isPlainShell =
readBoolean(message.isPlainShell) || readBoolean(message.isPlainShell) ||
(!!initialCommand && !hasSession) || (!!initialCommand && !hasSession) ||
@@ -131,47 +129,44 @@ function buildShellCommand(
return initialCommand; return initialCommand;
} }
const resumeId =
hasSession && sessionId ? resolveResumeSessionId(sessionId, provider, dependencies) : null;
if (provider === 'cursor') { if (provider === 'cursor') {
if (resumeId) { if (resumeSessionId) {
return `cursor-agent --resume="${resumeId}"`; return `cursor-agent --resume="${resumeSessionId}"`;
} }
return 'cursor-agent'; return 'cursor-agent';
} }
if (provider === 'codex') { if (provider === 'codex') {
if (resumeId) { if (resumeSessionId) {
if (os.platform() === 'win32') { if (os.platform() === 'win32') {
return `codex resume "${resumeId}"; if ($LASTEXITCODE -ne 0) { codex }`; return `codex resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
} }
return `codex resume "${resumeId}" || codex`; return `codex resume "${resumeSessionId}" || codex`;
} }
return 'codex'; return 'codex';
} }
if (provider === 'gemini') { if (provider === 'gemini') {
const command = initialCommand || 'gemini'; const command = initialCommand || 'gemini';
if (resumeId) { if (resumeSessionId) {
return `${command} --resume "${resumeId}"`; return `${command} --resume "${resumeSessionId}"`;
} }
return command; return command;
} }
if (provider === 'opencode') { if (provider === 'opencode') {
if (resumeId) { if (resumeSessionId) {
return `opencode --session "${resumeId}"`; return `opencode --session "${resumeSessionId}"`;
} }
return initialCommand || 'opencode'; return initialCommand || 'opencode';
} }
const command = initialCommand || 'claude'; const command = initialCommand || 'claude';
if (resumeId) { if (resumeSessionId) {
if (os.platform() === 'win32') { if (os.platform() === 'win32') {
return `claude --resume "${resumeId}"; if ($LASTEXITCODE -ne 0) { claude }`; return `claude --resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
} }
return `claude --resume "${resumeId}" || claude`; return `claude --resume "${resumeSessionId}" || claude`;
} }
return command; return command;
} }
@@ -276,12 +271,14 @@ export function handleShellConnection(
return; return;
} }
if (sessionId && !SAFE_SESSION_ID_PATTERN.test(sessionId)) { const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
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;
} }
const shellCommand = buildShellCommand(data, dependencies); const shellCommand = buildShellCommand(data, dependencies);
const resumeSessionId = resolveResumeSessionId(data, dependencies);
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
const shellArgs = const shellArgs =
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand]; os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
@@ -427,8 +424,8 @@ export function handleShellConnection(
: provider === 'opencode' : provider === 'opencode'
? 'OpenCode' ? 'OpenCode'
: 'Claude'; : 'Claude';
welcomeMsg = hasSession welcomeMsg = hasSession && resumeSessionId
? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` ? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n`
: `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`; : `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
} }

View File

@@ -6,7 +6,7 @@ import test from 'node:test';
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js'; import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
import type { NormalizedMessage } from '@/shared/types.js'; import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js';
/** /**
* Minimal stand-in for a websocket connection: collects every JSON frame the * Minimal stand-in for a websocket connection: collects every JSON frame the
@@ -14,10 +14,10 @@ import type { NormalizedMessage } from '@/shared/types.js';
*/ */
class FakeConnection { class FakeConnection {
readyState = 1; // WS_OPEN_STATE readyState = 1; // WS_OPEN_STATE
frames: NormalizedMessage[] = []; frames: Array<Record<string, unknown>> = [];
send(data: string): void { send(data: string): void {
this.frames.push(JSON.parse(data) as NormalizedMessage); this.frames.push(JSON.parse(data) as Record<string, unknown>);
} }
} }
@@ -33,6 +33,7 @@ async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promis
try { try {
await runTest(); await runTest();
} finally { } finally {
connectedClients.clear();
chatRunRegistry.clearAll(); chatRunRegistry.clearAll();
closeConnection(); closeConnection();
if (previousDatabasePath === undefined) { if (previousDatabasePath === undefined) {
@@ -72,6 +73,7 @@ test('session_created is swallowed and persisted as the provider-id mapping', as
await withIsolatedDatabase(() => { await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo'); sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo');
const connection = new FakeConnection(); const connection = new FakeConnection();
connectedClients.add(connection as never);
const run = chatRunRegistry.startRun({ const run = chatRunRegistry.startRun({
appSessionId: 'app-run-2', appSessionId: 'app-run-2',
provider: 'cursor', provider: 'cursor',
@@ -88,9 +90,12 @@ test('session_created is swallowed and persisted as the provider-id mapping', as
newSessionId: 'cursor-native-7', newSessionId: 'cursor-native-7',
}); });
// Never forwarded to the client... // The provider-native event itself is never forwarded...
assert.equal(connection.frames.length, 0); const sessionUpserts = connection.frames.filter((frame) => frame.kind === 'session_upserted');
// ...but recorded in the registry and persisted in the database. assert.equal(sessionUpserts.length, 1);
assert.equal(sessionUpserts[0]?.sessionId, 'app-run-2');
assert.equal(sessionUpserts[0]?.providerSessionId, 'cursor-native-7');
// ...but the canonical mapping is recorded and persisted in the database.
assert.equal(run.providerSessionId, 'cursor-native-7'); assert.equal(run.providerSessionId, 'cursor-native-7');
assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7'); assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7');
}); });

View File

@@ -194,6 +194,10 @@ async function spawnOpenCode(command, options = {}, ws) {
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => { void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
const args = ['run', '--format', 'json']; const args = ['run', '--format', 'json'];
// OpenCode's `run` command owns workspace selection through `--dir`.
// Relying on the child-process cwd alone is not enough on Linux, where
// the CLI can still resolve the session under the server install dir.
args.push('--dir', workingDir);
if (sessionId) { if (sessionId) {
args.push('--session', sessionId); args.push('--session', sessionId);
} }

View File

@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
@@ -12,6 +12,11 @@ const findEnvKey = (name) =>
async function createFakeOpenCodeExecutable(binDir) { async function createFakeOpenCodeExecutable(binDir) {
const scriptPath = path.join(binDir, 'opencode.js'); const scriptPath = path.join(binDir, 'opencode.js');
await writeFile(scriptPath, ` await writeFile(scriptPath, `
const capturePath = process.env.OPENCODE_ARGS_CAPTURE;
if (capturePath) {
require('node:fs').writeFileSync(capturePath, JSON.stringify(process.argv.slice(2)));
}
const events = [ const events = [
{ type: 'text', sessionID: 'open-live-1', text: 'assistant response' }, { type: 'text', sessionID: 'open-live-1', text: 'assistant response' },
{ type: 'step_finish', sessionID: 'open-live-1' }, { type: 'step_finish', sessionID: 'open-live-1' },
@@ -35,10 +40,12 @@ for (const event of events) {
test('spawnOpenCode emits session_created before normalized live messages for new sessions', async () => { test('spawnOpenCode emits session_created before normalized live messages for new sessions', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-live-')); const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-live-'));
const argsCapturePath = path.join(tempRoot, 'opencode-args.json');
const pathKey = findEnvKey('PATH'); const pathKey = findEnvKey('PATH');
const pathExtKey = findEnvKey('PATHEXT'); const pathExtKey = findEnvKey('PATHEXT');
const previousPath = process.env[pathKey]; const previousPath = process.env[pathKey];
const previousPathExt = process.env[pathExtKey]; const previousPathExt = process.env[pathExtKey];
const previousArgsCapture = process.env.OPENCODE_ARGS_CAPTURE;
const messages = []; const messages = [];
const writer = { const writer = {
userId: null, userId: null,
@@ -54,6 +61,7 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
try { try {
await createFakeOpenCodeExecutable(tempRoot); await createFakeOpenCodeExecutable(tempRoot);
process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`; process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`;
process.env.OPENCODE_ARGS_CAPTURE = argsCapturePath;
if (process.platform === 'win32') { if (process.platform === 'win32') {
process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD') process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD')
? previousPathExt ? previousPathExt
@@ -77,6 +85,11 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
assert.equal(streamEnd?.sessionId, 'open-live-1'); assert.equal(streamEnd?.sessionId, 'open-live-1');
assert.equal(complete?.sessionId, 'open-live-1'); assert.equal(complete?.sessionId, 'open-live-1');
assert.equal(messages.some((message) => message.kind === 'error'), false); assert.equal(messages.some((message) => message.kind === 'error'), false);
const launchedArgs = JSON.parse(await readFile(argsCapturePath, 'utf8'));
assert.ok(Array.isArray(launchedArgs));
assert.deepEqual(launchedArgs.slice(0, 4), ['run', '--format', 'json', '--dir']);
assert.equal(launchedArgs[4], tempRoot);
} finally { } finally {
if (previousPath === undefined) { if (previousPath === undefined) {
delete process.env[pathKey]; delete process.env[pathKey];
@@ -90,6 +103,12 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
process.env[pathExtKey] = previousPathExt; process.env[pathExtKey] = previousPathExt;
} }
if (previousArgsCapture === undefined) {
delete process.env.OPENCODE_ARGS_CAPTURE;
} else {
process.env.OPENCODE_ARGS_CAPTURE = previousArgsCapture;
}
await rm(tempRoot, { recursive: true, force: true }); await rm(tempRoot, { recursive: true, force: true });
} }
}); });

View File

@@ -121,16 +121,12 @@ function AppContentInner() {
}, [refreshRunningSessions]); }, [refreshRunningSessions]);
useEffect(() => { useEffect(() => {
if (processingSessions.size === 0) {
return;
}
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
void refreshRunningSessions(); void refreshRunningSessions();
}, 5000); }, 5000);
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [processingSessions.size, refreshRunningSessions]); }, [refreshRunningSessions]);
usePaletteOpsRegister({ usePaletteOpsRegister({
openSettings, openSettings,

View File

@@ -202,7 +202,9 @@ export function useChatRealtimeHandlers({
// indicator derives from the processing map, so deleting the entry // indicator derives from the processing map, so deleting the entry
// hides it immediately and atomically. // hides it immediately and atomically.
onSessionIdle?.(sid); onSessionIdle?.(sid);
setPendingPermissionRequests([]); if (sid === activeViewSessionId) {
setPendingPermissionRequests([]);
}
if (msg.aborted) { if (msg.aborted) {
// Abort was requested — the complete event confirms it. No // Abort was requested — the complete event confirms it. No
@@ -232,17 +234,19 @@ export function useChatRealtimeHandlers({
case 'permission_request': { case 'permission_request': {
if (!msg.requestId) break; if (!msg.requestId) break;
setPendingPermissionRequests((prev) => { if (sid === activeViewSessionId) {
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev; setPendingPermissionRequests((prev) => {
return [...prev, { if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
requestId: msg.requestId as string, return [...prev, {
toolName: (msg.toolName as string) || 'UnknownTool', requestId: msg.requestId as string,
input: msg.input, toolName: (msg.toolName as string) || 'UnknownTool',
context: msg.context, input: msg.input,
sessionId: sid || null, context: msg.context,
receivedAt: new Date(), sessionId: sid || null,
}]; receivedAt: new Date(),
}); }];
});
}
if (sid) { if (sid) {
onSessionProcessing?.(sid); onSessionProcessing?.(sid);
} }
@@ -250,7 +254,7 @@ export function useChatRealtimeHandlers({
} }
case 'permission_cancelled': { case 'permission_cancelled': {
if (msg.requestId) { if (msg.requestId && sid === activeViewSessionId) {
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId)); setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
} }
break; break;

View File

@@ -5,7 +5,7 @@ import { authenticatedFetch } from '../../../utils/api';
import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection'; import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
import type { ChatMessage, Provider } from '../types/types'; import type { ChatMessage } from '../types/types';
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms'; import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
import { normalizedToChatMessages } from './useChatMessages'; import { normalizedToChatMessages } from './useChatMessages';
@@ -328,18 +328,12 @@ export function useChatSessionState({
if (allMessagesLoadedRef.current) return false; if (allMessagesLoadedRef.current) return false;
if (!hasMoreMessages || !selectedSession || !selectedProject) return false; if (!hasMoreMessages || !selectedSession || !selectedProject) return false;
const sessionProvider = selectedSession.__provider || 'claude';
isLoadingMoreRef.current = true; isLoadingMoreRef.current = true;
const previousScrollHeight = container.scrollHeight; const previousScrollHeight = container.scrollHeight;
const previousScrollTop = container.scrollTop; const previousScrollTop = container.scrollTop;
try { try {
const slot = await sessionStore.fetchMore(selectedSession.id, { const slot = await sessionStore.fetchMore(selectedSession.id, {
provider: sessionProvider as LLMProvider,
// DB-assigned projectId replaces the legacy folder-derived name.
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: MESSAGES_PER_PAGE, limit: MESSAGES_PER_PAGE,
}); });
if (!slot || slot.serverMessages.length === 0) return false; if (!slot || slot.serverMessages.length === 0) return false;
@@ -458,15 +452,31 @@ export function useChatSessionState({
return; return;
} }
const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude'; const selectedSessionId = selectedSession.id;
const sessionKey = `${selectedSession.id}:${selectedProject.projectId}:${provider}`; const sessionKey = `${selectedSessionId}:${selectedProject.projectId}`;
const subscribeToSelectedSession = () => {
if (!ws) {
return;
}
statusCheckSentAtRef.current.set(selectedSessionId, Date.now());
sendMessage({
type: 'chat.subscribe',
sessions: [{
sessionId: selectedSessionId,
lastSeq: lastSeqRef.current.get(selectedSessionId) ?? 0,
}],
});
};
// Skip if already loaded and fresh // Skip if already loaded and fresh
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) { if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSessionId) && !sessionStore.isStale(selectedSessionId)) {
subscribeToSelectedSession();
return; return;
} }
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSessionId;
if (sessionChanged) { if (sessionChanged) {
resetStreamingState(); resetStreamingState();
} }
@@ -489,32 +499,20 @@ export function useChatSessionState({
setTokenBudget(null); setTokenBudget(null);
} }
setCurrentSessionId(selectedSession.id); setCurrentSessionId(selectedSessionId);
// Subscribe to the session's live run (if any): the ack reconciles the // Subscribe to the session's live run (if any): the ack reconciles the
// processing indicator, re-attaches a mid-flight stream to this socket, // processing indicator, re-attaches a mid-flight stream to this socket,
// and replays any live events missed since `lastSeq`. Recording the send // and replays any live events missed since `lastSeq`. Recording the send
// time lets the ack handler discard idle acks that a newer request has // time lets the ack handler discard idle acks that a newer request has
// since outdated. // since outdated.
if (ws) { subscribeToSelectedSession();
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;
// Fetch from server → store updates → chatMessages re-derives automatically // Fetch from server → store updates → chatMessages re-derives automatically
setIsLoadingSessionMessages(true); setIsLoadingSessionMessages(true);
sessionStore.fetchFromServer(selectedSession.id, { sessionStore.fetchFromServer(selectedSessionId, {
provider: (selectedSession.__provider || provider) as LLMProvider,
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: MESSAGES_PER_PAGE, limit: MESSAGES_PER_PAGE,
offset: 0, offset: 0,
}).then(slot => { }).then(slot => {
@@ -544,15 +542,9 @@ export function useChatSessionState({
const reloadExternalMessages = async () => { const reloadExternalMessages = async () => {
try { try {
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
// Skip store refresh during active streaming // Skip store refresh during active streaming
if (!isProcessing) { if (!isProcessing) {
await sessionStore.refreshFromServer(selectedSession.id, { await sessionStore.refreshFromServer(selectedSession.id);
provider: (selectedSession.__provider || provider) as LLMProvider,
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
});
if (Boolean(autoScrollToBottom) && isNearBottom()) { if (Boolean(autoScrollToBottom) && isNearBottom()) {
setTimeout(() => scrollToBottom(), 200); setTimeout(() => scrollToBottom(), 200);
@@ -598,13 +590,9 @@ export function useChatSessionState({
const scrollToTarget = async () => { const scrollToTarget = async () => {
if (!allMessagesLoadedRef.current && selectedSession && selectedProject) { if (!allMessagesLoadedRef.current && selectedSession && selectedProject) {
const sessionProvider = selectedSession.__provider || 'claude';
try { try {
// Load all messages into the store for search navigation // Load all messages into the store for search navigation
const slot = await sessionStore.fetchFromServer(selectedSession.id, { const slot = await sessionStore.fetchFromServer(selectedSession.id, {
provider: sessionProvider as LLMProvider,
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: null, limit: null,
offset: 0, offset: 0,
}); });
@@ -678,17 +666,10 @@ export function useChatSessionState({
setTokenBudget(null); setTokenBudget(null);
return; return;
} }
const sessionProvider = selectedSession.__provider || 'claude';
if (sessionProvider !== 'claude' && sessionProvider !== 'codex' && sessionProvider !== 'gemini' && sessionProvider !== 'opencode') {
setTokenBudget(null);
return;
}
const fetchInitialTokenUsage = async () => { const fetchInitialTokenUsage = async () => {
try { try {
// Token usage endpoint is now keyed by the DB projectId. // The backend resolves the provider from the indexed session row.
const params = new URLSearchParams({ provider: sessionProvider }); const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`;
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage?${params.toString()}`;
const response = await authenticatedFetch(url); const response = await authenticatedFetch(url);
if (response.ok) { if (response.ok) {
setTokenBudget(await response.json()); setTokenBudget(await response.json());
@@ -700,7 +681,7 @@ export function useChatSessionState({
} }
}; };
fetchInitialTokenUsage(); fetchInitialTokenUsage();
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]); }, [selectedProject, selectedSession?.id]);
const visibleMessages = useMemo(() => { const visibleMessages = useMemo(() => {
if (chatMessages.length <= visibleMessageCount) return chatMessages; if (chatMessages.length <= visibleMessageCount) return chatMessages;
@@ -760,8 +741,6 @@ export function useChatSessionState({
const loadAllMessages = useCallback(async () => { const loadAllMessages = useCallback(async () => {
if (!selectedSession || !selectedProject) return; if (!selectedSession || !selectedProject) return;
if (isLoadingAllMessages) return; if (isLoadingAllMessages) return;
const sessionProvider = selectedSession.__provider || 'claude';
const requestSessionId = selectedSession.id; const requestSessionId = selectedSession.id;
allMessagesLoadedRef.current = true; allMessagesLoadedRef.current = true;
isLoadingMoreRef.current = true; isLoadingMoreRef.current = true;
@@ -774,9 +753,6 @@ export function useChatSessionState({
try { try {
const slot = await sessionStore.fetchFromServer(requestSessionId, { const slot = await sessionStore.fetchFromServer(requestSessionId, {
provider: sessionProvider as LLMProvider,
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: null, limit: null,
offset: 0, offset: 0,
}); });

View File

@@ -6,7 +6,6 @@ import { useWebSocket } from '../../../contexts/WebSocketContext';
import PermissionContext from '../../../contexts/PermissionContext'; import PermissionContext from '../../../contexts/PermissionContext';
import { QuickSettingsPanel } from '../../quick-settings-panel'; import { QuickSettingsPanel } from '../../quick-settings-panel';
import type { ChatInterfaceProps, Provider } from '../types/types'; import type { ChatInterfaceProps, Provider } from '../types/types';
import type { LLMProvider } from '../../../types/app';
import { useChatProviderState } from '../hooks/useChatProviderState'; import { useChatProviderState } from '../hooks/useChatProviderState';
import { useChatSessionState } from '../hooks/useChatSessionState'; import { useChatSessionState } from '../hooks/useChatSessionState';
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers'; import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
@@ -223,16 +222,7 @@ function ChatInterface({
// missed live events, and re-attaches a still-running stream to this socket. // 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 = await sessionStore.refreshFromServer(selectedSession.id);
selectedSession.__provider
|| (localStorage.getItem('selected-provider') as LLMProvider)
|| 'claude';
await sessionStore.refreshFromServer(selectedSession.id, {
provider: providerVal as LLMProvider,
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
});
statusCheckSentAtRef.current.set(selectedSession.id, Date.now()); statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
sendMessage({ sendMessage({
type: 'chat.subscribe', type: 'chat.subscribe',
@@ -325,6 +315,7 @@ function ChatInterface({
onWheel={handleScroll} onWheel={handleScroll}
onTouchMove={handleScroll} onTouchMove={handleScroll}
isLoadingSessionMessages={isLoadingSessionMessages} isLoadingSessionMessages={isLoadingSessionMessages}
isProcessing={isProcessing}
chatMessages={chatMessages} chatMessages={chatMessages}
selectedSession={selectedSession} selectedSession={selectedSession}
currentSessionId={currentSessionId} currentSessionId={currentSessionId}

View File

@@ -19,6 +19,8 @@ interface ChatMessagesPaneProps {
onWheel: () => void; onWheel: () => void;
onTouchMove: () => void; onTouchMove: () => void;
isLoadingSessionMessages: boolean; isLoadingSessionMessages: boolean;
/** True while the viewed session has an active provider run in flight. */
isProcessing?: boolean;
chatMessages: ChatMessage[]; chatMessages: ChatMessage[];
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
currentSessionId: string | null; currentSessionId: string | null;
@@ -68,6 +70,7 @@ export default function ChatMessagesPane({
onWheel, onWheel,
onTouchMove, onTouchMove,
isLoadingSessionMessages, isLoadingSessionMessages,
isProcessing = false,
chatMessages, chatMessages,
selectedSession, selectedSession,
currentSessionId, currentSessionId,
@@ -147,7 +150,7 @@ export default function ChatMessagesPane({
onTouchMove={onTouchMove} onTouchMove={onTouchMove}
className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4" className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
> >
{isLoadingSessionMessages && chatMessages.length === 0 ? ( {(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
<div className="mt-8 text-center text-gray-500 dark:text-gray-400"> <div className="mt-8 text-center text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-center space-x-2"> <div className="flex items-center justify-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-gray-400" /> <div className="h-4 w-4 animate-spin rounded-full border-b-2 border-gray-400" />

View File

@@ -11,10 +11,6 @@ export type SessionResult = {
interface SessionsResponse { interface SessionsResponse {
sessions?: ProjectSession[]; sessions?: ProjectSession[];
cursorSessions?: ProjectSession[];
codexSessions?: ProjectSession[];
geminiSessions?: ProjectSession[];
opencodeSessions?: ProjectSession[];
} }
export function useSessionsSource(projectId: string | undefined, enabled: boolean) { export function useSessionsSource(projectId: string | undefined, enabled: boolean) {
@@ -29,17 +25,10 @@ export function useSessionsSource(projectId: string | undefined, enabled: boolea
); );
}, },
parse: (data) => { parse: (data) => {
const all: ProjectSession[] = [ return (data.sessions ?? []).map<SessionResult>((s) => ({
...(data.sessions ?? []),
...(data.cursorSessions ?? []),
...(data.codexSessions ?? []),
...(data.geminiSessions ?? []),
...(data.opencodeSessions ?? []),
];
return all.map<SessionResult>((s) => ({
id: s.id, id: s.id,
label: (s.title || s.summary || s.name || s.id) as string, label: (s.title || s.summary || s.name || s.id) as string,
provider: s.__provider, provider: (s.__provider || s.provider) as LLMProvider | undefined,
})); }));
}, },
}); });

View File

@@ -1,14 +1,27 @@
import React from 'react';
type ClaudeLogoProps = { type ClaudeLogoProps = {
className?: string; className?: string;
}; };
const ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => { const ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => (
return ( <svg
<img src="/icons/claude-ai-icon.svg" alt="Claude" className={className} /> viewBox="0 0 512 509.64"
); role="img"
}; aria-label="Claude"
className={className}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="#D77655"
d="M115.612 0h280.775C459.974 0 512 52.026 512 115.612v278.415c0 63.587-52.026 115.612-115.613 115.612H115.612C52.026 509.639 0 457.614 0 394.027V115.612C0 52.026 52.026 0 115.612 0z"
/>
<path
fill="#FCF2EE"
fillRule="nonzero"
d="M142.27 316.619l73.655-41.326 1.238-3.589-1.238-1.996-3.589-.001-12.31-.759-42.084-1.138-36.498-1.516-35.361-1.896-8.897-1.895-8.34-10.995.859-5.484 7.482-5.03 10.717.935 23.683 1.617 35.537 2.452 25.782 1.517 38.193 3.968h6.064l.86-2.451-2.073-1.517-1.618-1.517-36.776-24.922-39.81-26.338-20.852-15.166-11.273-7.683-5.687-7.204-2.451-15.721 10.237-11.273 13.75.935 3.513.936 13.928 10.716 29.749 23.027 38.848 28.612 5.687 4.727 2.275-1.617.278-1.138-2.553-4.271-21.13-38.193-22.546-38.848-10.035-16.101-2.654-9.655c-.935-3.968-1.617-7.304-1.617-11.374l11.652-15.823 6.445-2.073 15.545 2.073 6.547 5.687 9.655 22.092 15.646 34.78 24.265 47.291 7.103 14.028 3.791 12.992 1.416 3.968 2.449-.001v-2.275l1.997-26.641 3.69-32.707 3.589-42.084 1.239-11.854 5.863-14.206 11.652-7.683 9.099 4.348 7.482 10.716-1.036 6.926-4.449 28.915-8.72 45.294-5.687 30.331h3.313l3.792-3.791 15.342-20.372 25.782-32.227 11.374-12.789 13.27-14.129 8.517-6.724 16.1-.001 11.854 17.617-5.307 18.199-16.581 21.029-13.75 17.819-19.716 26.54-12.309 21.231 1.138 1.694 2.932-.278 44.536-9.479 24.062-4.347 28.714-4.928 12.992 6.066 1.416 6.167-5.106 12.613-30.71 7.583-36.018 7.204-53.636 12.689-.657.48.758.935 24.164 2.275 10.337.556h25.301l47.114 3.514 12.309 8.139 7.381 9.959-1.238 7.583-18.957 9.655-25.579-6.066-59.702-14.205-20.474-5.106-2.83-.001v1.694l17.061 16.682 31.266 28.233 39.152 36.397 1.997 8.999-5.03 7.102-5.307-.758-34.401-25.883-13.27-11.651-30.053-25.302-1.996-.001v2.654l6.926 10.136 36.574 54.975 1.895 16.859-2.653 5.485-9.479 3.311-10.414-1.895-21.408-30.054-22.092-33.844-17.819-30.331-2.173 1.238-10.515 113.261-4.929 5.788-11.374 4.348-9.478-7.204-5.03-11.652 5.03-23.027 6.066-30.052 4.928-23.886 4.449-29.674 2.654-9.858-.177-.657-2.173.278-22.37 30.71-34.021 45.977-26.919 28.815-6.445 2.553-11.173-5.789 1.037-10.337 6.243-9.2 37.257-47.392 22.47-29.371 14.508-16.961-.101-2.451h-.859l-98.954 64.251-17.618 2.275-7.583-7.103.936-11.652 3.589-3.791 29.749-20.474-.101.102.024.101z"
/>
</svg>
);
export default ClaudeLogo; export default ClaudeLogo;

View File

@@ -1,20 +1,21 @@
import React from 'react';
import { useTheme } from '../../contexts/ThemeContext';
type CodexLogoProps = { type CodexLogoProps = {
className?: string; className?: string;
}; };
const CodexLogo = ({ className = 'w-5 h-5' }: CodexLogoProps) => { const CodexLogo = ({ className = 'w-5 h-5' }: CodexLogoProps) => (
const { isDarkMode } = useTheme(); <svg
viewBox="100 100 520 520"
return ( role="img"
<img aria-label="Codex"
src={isDarkMode ? "/icons/codex-white.svg" : "/icons/codex.svg"} className={className}
alt="Codex" fill="none"
className={className} xmlns="http://www.w3.org/2000/svg"
>
<path
d="M304.246 294.611V249.028C304.246 245.189 305.687 242.309 309.044 240.392L400.692 187.612C413.167 180.415 428.042 177.058 443.394 177.058C500.971 177.058 537.44 221.682 537.44 269.182C537.44 272.54 537.44 276.379 536.959 280.218L441.954 224.558C436.197 221.201 430.437 221.201 424.68 224.558L304.246 294.611ZM518.245 472.145V363.224C518.245 356.505 515.364 351.707 509.608 348.349L389.174 278.296L428.519 255.743C431.877 253.826 434.757 253.826 438.115 255.743L529.762 308.523C556.154 323.879 573.905 356.505 573.905 388.171C573.905 424.636 552.315 458.225 518.245 472.141V472.145ZM275.937 376.182L236.592 353.152C233.235 351.235 231.794 348.354 231.794 344.515V238.956C231.794 187.617 271.139 148.749 324.4 148.749C344.555 148.749 363.264 155.468 379.102 167.463L284.578 222.164C278.822 225.521 275.942 230.319 275.942 237.039V376.186L275.937 376.182ZM360.626 425.122L304.246 393.455V326.283L360.626 294.616L417.002 326.283V393.455L360.626 425.122ZM396.852 570.989C376.698 570.989 357.989 564.27 342.151 552.276L436.674 497.574C442.431 494.217 445.311 489.419 445.311 482.699V343.552L485.138 366.582C488.495 368.499 489.936 371.379 489.936 375.219V480.778C489.936 532.117 450.109 570.985 396.852 570.985V570.989ZM283.134 463.99L191.486 411.211C165.094 395.854 147.343 363.229 147.343 331.562C147.343 294.616 169.415 261.509 203.48 247.593V356.991C203.48 363.71 206.361 368.508 212.117 371.866L332.074 441.437L292.729 463.99C289.372 465.907 286.491 465.907 283.134 463.99ZM277.859 542.68C223.639 542.68 183.813 501.895 183.813 451.514C183.813 447.675 184.294 443.836 184.771 439.997L279.295 494.698C285.051 498.056 290.812 498.056 296.568 494.698L417.002 425.127V470.71C417.002 474.549 415.562 477.429 412.204 479.346L320.557 532.126C308.081 539.323 293.206 542.68 277.854 542.68H277.859ZM396.852 599.776C454.911 599.776 503.37 558.513 514.41 503.812C568.149 489.896 602.696 439.515 602.696 388.176C602.696 354.587 588.303 321.962 562.392 298.45C564.791 288.373 566.231 278.296 566.231 268.224C566.231 199.611 510.571 148.267 446.274 148.267C433.322 148.267 420.846 150.184 408.37 154.505C386.775 133.392 357.026 119.958 324.4 119.958C266.342 119.958 217.883 161.22 206.843 215.921C153.104 229.837 118.557 280.218 118.557 331.557C118.557 365.146 132.95 397.771 158.861 421.283C156.462 431.36 155.022 441.437 155.022 451.51C155.022 520.123 210.682 571.466 274.978 571.466C287.931 571.466 300.407 569.549 312.883 565.228C334.473 586.341 364.222 599.776 396.852 599.776Z"
fill="currentColor"
/> />
); </svg>
}; );
export default CodexLogo; export default CodexLogo;

View File

@@ -1,20 +1,26 @@
import React from 'react';
import { useTheme } from '../../contexts/ThemeContext';
type CursorLogoProps = { type CursorLogoProps = {
className?: string; className?: string;
}; };
const CursorLogo = ({ className = 'w-5 h-5' }: CursorLogoProps) => { const CursorLogo = ({ className = 'w-5 h-5' }: CursorLogoProps) => (
const { isDarkMode } = useTheme(); <svg
viewBox="0 0 24 24"
return ( role="img"
<img aria-label="Cursor"
src={isDarkMode ? "/icons/cursor-white.svg" : "/icons/cursor.svg"} className={className}
alt="Cursor" fill="none"
className={className} xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.925 24l10.425-6-10.425-6L1.5 18l10.425 6z"
fill="currentColor"
opacity=".39"
/> />
); <path d="M22.35 18V6L11.925 0v12l10.425 6z" fill="currentColor" opacity=".8" />
}; <path d="M11.925 0L1.5 6v12l10.425-6V0z" fill="currentColor" opacity=".6" />
<path d="M22.35 6L11.925 24V12L22.35 6z" fill="currentColor" opacity=".72" />
<path d="M22.35 6l-10.425 6L1.5 6h20.85z" fill="currentColor" opacity=".95" />
</svg>
);
export default CursorLogo; export default CursorLogo;

View File

@@ -1,6 +1,262 @@
const GeminiLogo = ({className = 'w-5 h-5'}) => { import { useId } from 'react';
type GeminiLogoProps = {
className?: string;
};
const GeminiLogo = ({ className = 'w-5 h-5' }: GeminiLogoProps) => {
const id = useId().replace(/:/g, '');
const maskId = `${id}-gemini-mask`;
const gradientId = `${id}-gemini-gradient`;
const filterIds = Array.from({ length: 11 }, (_, index) => `${id}-gemini-filter-${index}`);
return ( return (
<img src="/icons/gemini-ai-icon.svg" alt="Gemini" className={className} /> <svg
viewBox="0 0 65 65"
role="img"
aria-label="Gemini"
className={className}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id={maskId}
style={{ maskType: 'alpha' }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="65"
height="65"
>
<path
d="M32.447 0c.68 0 1.273.465 1.439 1.125a38.904 38.904 0 001.999 5.905c2.152 5 5.105 9.376 8.854 13.125 3.751 3.75 8.126 6.703 13.125 8.855a38.98 38.98 0 005.906 1.999c.66.166 1.124.758 1.124 1.438 0 .68-.464 1.273-1.125 1.439a38.902 38.902 0 00-5.905 1.999c-5 2.152-9.375 5.105-13.125 8.854-3.749 3.751-6.702 8.126-8.854 13.125a38.973 38.973 0 00-2 5.906 1.485 1.485 0 01-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a38.913 38.913 0 00-2-5.905c-2.151-5-5.103-9.375-8.854-13.125-3.75-3.749-8.125-6.702-13.125-8.854a38.973 38.973 0 00-5.905-2A1.485 1.485 0 010 32.448c0-.68.465-1.272 1.125-1.438a38.903 38.903 0 005.905-2c5-2.151 9.376-5.104 13.125-8.854 3.75-3.749 6.703-8.125 8.855-13.125a38.972 38.972 0 001.999-5.905A1.485 1.485 0 0132.447 0z"
fill="#000"
/>
<path
d="M32.447 0c.68 0 1.273.465 1.439 1.125a38.904 38.904 0 001.999 5.905c2.152 5 5.105 9.376 8.854 13.125 3.751 3.75 8.126 6.703 13.125 8.855a38.98 38.98 0 005.906 1.999c.66.166 1.124.758 1.124 1.438 0 .68-.464 1.273-1.125 1.439a38.902 38.902 0 00-5.905 1.999c-5 2.152-9.375 5.105-13.125 8.854-3.749 3.751-6.702 8.126-8.854 13.125a38.973 38.973 0 00-2 5.906 1.485 1.485 0 01-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a38.913 38.913 0 00-2-5.905c-2.151-5-5.103-9.375-8.854-13.125-3.75-3.749-8.125-6.702-13.125-8.854a38.973 38.973 0 00-5.905-2A1.485 1.485 0 010 32.448c0-.68.465-1.272 1.125-1.438a38.903 38.903 0 005.905-2c5-2.151 9.376-5.104 13.125-8.854 3.75-3.749 6.703-8.125 8.855-13.125a38.972 38.972 0 001.999-5.905A1.485 1.485 0 0132.447 0z"
fill={`url(#${gradientId})`}
/>
</mask>
<g mask={`url(#${maskId})`}>
<g filter={`url(#${filterIds[0]})`}>
<path
d="M-5.859 50.734c7.498 2.663 16.116-2.33 19.249-11.152 3.133-8.821-.406-18.131-7.904-20.794-7.498-2.663-16.116 2.33-19.25 11.151-3.132 8.822.407 18.132 7.905 20.795z"
fill="#FFE432"
/>
</g>
<g filter={`url(#${filterIds[1]})`}>
<path
d="M27.433 21.649c10.3 0 18.651-8.535 18.651-19.062 0-10.528-8.35-19.062-18.651-19.062S8.78-7.94 8.78 2.587c0 10.527 8.35 19.062 18.652 19.062z"
fill="#FC413D"
/>
</g>
<g filter={`url(#${filterIds[2]})`}>
<path
d="M20.184 82.608c10.753-.525 18.918-12.244 18.237-26.174-.68-13.93-9.95-24.797-20.703-24.271C6.965 32.689-1.2 44.407-.519 58.337c.681 13.93 9.95 24.797 20.703 24.271z"
fill="#00B95C"
/>
</g>
<g filter={`url(#${filterIds[3]})`}>
<path
d="M20.184 82.608c10.753-.525 18.918-12.244 18.237-26.174-.68-13.93-9.95-24.797-20.703-24.271C6.965 32.689-1.2 44.407-.519 58.337c.681 13.93 9.95 24.797 20.703 24.271z"
fill="#00B95C"
/>
</g>
<g filter={`url(#${filterIds[4]})`}>
<path
d="M30.954 74.181c9.014-5.485 11.427-17.976 5.389-27.9-6.038-9.925-18.241-13.524-27.256-8.04-9.015 5.486-11.428 17.977-5.39 27.902 6.04 9.924 18.242 13.523 27.257 8.038z"
fill="#00B95C"
/>
</g>
<g filter={`url(#${filterIds[5]})`}>
<path
d="M67.391 42.993c10.132 0 18.346-7.91 18.346-17.666 0-9.757-8.214-17.667-18.346-17.667s-18.346 7.91-18.346 17.667c0 9.757 8.214 17.666 18.346 17.666z"
fill="#3186FF"
/>
</g>
<g filter={`url(#${filterIds[6]})`}>
<path
d="M-13.065 40.944c9.33 7.094 22.959 4.869 30.442-4.972 7.483-9.84 5.987-23.569-3.343-30.663C4.704-1.786-8.924.439-16.408 10.28c-7.483 9.84-5.986 23.57 3.343 30.664z"
fill="#FBBC04"
/>
</g>
<g filter={`url(#${filterIds[7]})`}>
<path
d="M34.74 51.43c11.135 7.656 25.896 5.524 32.968-4.764 7.073-10.287 3.779-24.832-7.357-32.488C49.215 6.52 34.455 8.654 27.382 18.94c-7.072 10.288-3.779 24.833 7.357 32.49z"
fill="#3186FF"
/>
</g>
<g filter={`url(#${filterIds[8]})`}>
<path
d="M54.984-2.336c2.833 3.852-.808 11.34-8.131 16.727-7.324 5.387-15.557 6.631-18.39 2.78-2.833-3.853.807-11.342 8.13-16.728 7.324-5.387 15.558-6.631 18.39-2.78z"
fill="#749BFF"
/>
</g>
<g filter={`url(#${filterIds[9]})`}>
<path
d="M31.727 16.104C43.053 5.598 46.94-8.626 40.41-15.666c-6.53-7.04-21.006-4.232-32.332 6.274s-15.214 24.73-8.683 31.77c6.53 7.04 21.006 4.232 32.332-6.274z"
fill="#FC413D"
/>
</g>
<g filter={`url(#${filterIds[10]})`}>
<path
d="M8.51 53.838c6.732 4.818 14.46 5.55 17.262 1.636 2.802-3.915-.384-10.994-7.116-15.812-6.731-4.818-14.46-5.55-17.261-1.636-2.802 3.915.383 10.994 7.115 15.812z"
fill="#FFEE48"
/>
</g>
</g>
<defs>
<filter
id={filterIds[0]}
x="-19.824"
y="13.152"
width="39.274"
height="43.217"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="2.46" result="effect1_foregroundBlur_2001_67" />
</filter>
<filter
id={filterIds[1]}
x="-15.001"
y="-40.257"
width="84.868"
height="85.688"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="11.891" result="effect1_foregroundBlur_2001_67" />
</filter>
<filter
id={filterIds[2]}
x="-20.776"
y="11.927"
width="79.454"
height="90.916"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67" />
</filter>
<filter
id={filterIds[3]}
x="-20.776"
y="11.927"
width="79.454"
height="90.916"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67" />
</filter>
<filter
id={filterIds[4]}
x="-19.845"
y="15.459"
width="79.731"
height="81.505"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67" />
</filter>
<filter
id={filterIds[5]}
x="29.832"
y="-11.552"
width="75.117"
height="73.758"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="9.606" result="effect1_foregroundBlur_2001_67" />
</filter>
<filter
id={filterIds[6]}
x="-38.583"
y="-16.253"
width="78.135"
height="78.758"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="8.706" result="effect1_foregroundBlur_2001_67" />
</filter>
<filter
id={filterIds[7]}
x="8.107"
y="-5.966"
width="78.877"
height="77.539"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="7.775" result="effect1_foregroundBlur_2001_67" />
</filter>
<filter
id={filterIds[8]}
x="13.587"
y="-18.488"
width="56.272"
height="51.81"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="6.957" result="effect1_foregroundBlur_2001_67" />
</filter>
<filter
id={filterIds[9]}
x="-15.526"
y="-31.297"
width="70.856"
height="69.306"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="5.876" result="effect1_foregroundBlur_2001_67" />
</filter>
<filter
id={filterIds[10]}
x="-14.168"
y="20.964"
width="55.501"
height="51.571"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="7.273" result="effect1_foregroundBlur_2001_67" />
</filter>
<linearGradient id={gradientId} x1="18.447" x2="52.153" y1="43.42" y2="15.004" gradientUnits="userSpaceOnUse">
<stop stopColor="#4893FC" />
<stop offset=".27" stopColor="#4893FC" />
<stop offset=".777" stopColor="#969DFF" />
<stop offset="1" stopColor="#BD99FE" />
</linearGradient>
</defs>
</svg>
); );
}; };

View File

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

View File

@@ -594,16 +594,7 @@ export function useSidebarController({
return sortedProjects.reduce<Project[]>((acc, project) => { return sortedProjects.reduce<Project[]>((acc, project) => {
const sessions = (project.sessions ?? []).filter((session) => activeSessionIds.has(String(session.id))); const sessions = (project.sessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
const cursorSessions = (project.cursorSessions ?? []).filter((session) => activeSessionIds.has(String(session.id))); const runningCount = sessions.length;
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) { if (runningCount === 0) {
return acc; return acc;
@@ -612,10 +603,6 @@ export function useSidebarController({
acc.push({ acc.push({
...project, ...project,
sessions, sessions,
cursorSessions,
codexSessions,
geminiSessions,
opencodeSessions,
sessionMeta: { sessionMeta: {
...project.sessionMeta, ...project.sessionMeta,
total: runningCount, total: runningCount,

View File

@@ -61,10 +61,6 @@ export type SidebarProps = {
}; };
export type SessionViewModel = { export type SessionViewModel = {
isCursorSession: boolean;
isCodexSession: boolean;
isGeminiSession: boolean;
isOpenCodeSession: boolean;
isActive: boolean; isActive: boolean;
sessionName: string; sessionName: string;
sessionTime: string; sessionTime: string;

View File

@@ -1,6 +1,6 @@
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { Project } from '../../../types/app'; import type { LLMProvider, Project, ProjectSession } from '../../../types/app';
import type { ProjectSortOrder, SettingsProject, SessionViewModel, SessionWithProvider } from '../types/types'; import type { ProjectSortOrder, SettingsProject, SessionViewModel, SessionWithProvider } from '../types/types';
export const readProjectSortOrder = (): ProjectSortOrder => { export const readProjectSortOrder = (): ProjectSortOrder => {
@@ -61,6 +61,13 @@ const getUpdatedTimestamp = (session: SessionWithProvider): string => {
return String(session.lastActivity || ''); return String(session.lastActivity || '');
}; };
const getSessionProvider = (session: ProjectSession): LLMProvider => {
const provider = session.__provider ?? session.provider;
return typeof provider === 'string' && provider.trim()
? provider as LLMProvider
: 'claude';
};
export const getSessionDate = (session: SessionWithProvider): Date => { export const getSessionDate = (session: SessionWithProvider): Date => {
return new Date(getUpdatedTimestamp(session) || getCreatedTimestamp(session) || 0); return new Date(getUpdatedTimestamp(session) || getCreatedTimestamp(session) || 0);
}; };
@@ -82,10 +89,6 @@ export const createSessionViewModel = (
const diffInMinutes = Math.floor((currentTime.getTime() - sessionDate.getTime()) / (1000 * 60)); const diffInMinutes = Math.floor((currentTime.getTime() - sessionDate.getTime()) / (1000 * 60));
return { return {
isCursorSession: session.__provider === 'cursor',
isCodexSession: session.__provider === 'codex',
isGeminiSession: session.__provider === 'gemini',
isOpenCodeSession: session.__provider === 'opencode',
isActive: diffInMinutes < 10, isActive: diffInMinutes < 10,
sessionName: getSessionName(session, t), sessionName: getSessionName(session, t),
sessionTime: getSessionTime(session), sessionTime: getSessionTime(session),
@@ -94,32 +97,10 @@ export const createSessionViewModel = (
}; };
export const getAllSessions = (project: Project): SessionWithProvider[] => { export const getAllSessions = (project: Project): SessionWithProvider[] => {
const claudeSessions = [...(project.sessions || [])].map((session) => ({ return (project.sessions || []).map((session) => ({
...session, ...session,
__provider: 'claude' as const, __provider: getSessionProvider(session),
})); })).sort(
const cursorSessions = (project.cursorSessions || []).map((session) => ({
...session,
__provider: 'cursor' as const,
}));
const codexSessions = (project.codexSessions || []).map((session) => ({
...session,
__provider: 'codex' as const,
}));
const geminiSessions = (project.geminiSessions || []).map((session) => ({
...session,
__provider: 'gemini' as const,
}));
const opencodeSessions = (project.opencodeSessions || []).map((session) => ({
...session,
__provider: 'opencode' as const,
}));
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions, ...opencodeSessions].sort(
(a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(), (a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(),
); );
}; };

View File

@@ -82,6 +82,7 @@ export default function SidebarSessionItem({
const isEditing = editingSession === session.id; const isEditing = editingSession === session.id;
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime); const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
const editingContainerRef = useRef<HTMLDivElement>(null); const editingContainerRef = useRef<HTMLDivElement>(null);
const showRecentIndicator = !isProcessing && sessionView.isActive;
// The rename panel sits inside a group-hover opacity wrapper, so leaving the row // The rename panel sits inside a group-hover opacity wrapper, so leaving the row
// would visually hide it. While editing, dismiss only when the user clicks outside // would visually hide it. While editing, dismiss only when the user clicks outside
@@ -119,7 +120,7 @@ export default function SidebarSessionItem({
return ( return (
<div className="group relative"> <div className="group relative">
{!isProcessing && sessionView.isActive && ( {showRecentIndicator && (
<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
@@ -156,13 +157,15 @@ 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="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
{isProcessing ? ( {isProcessing ? (
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top"> <span className="ml-auto flex-shrink-0">
<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"> <Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
<Loader2 className="h-3 w-3 animate-spin" /> <span className="flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground">
</span> <Loader2 className="h-3 w-3 animate-spin" />
</Tooltip> </span>
</Tooltip>
</span>
) : compactSessionAge && ( ) : 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>
)} )}
@@ -176,7 +179,7 @@ export default function SidebarSessionItem({
</div> </div>
</div> </div>
{!sessionView.isCursorSession && ( {!isProcessing && (
<button <button
className="ml-1 flex h-5 w-5 items-center justify-center rounded-md bg-red-50 opacity-70 transition-transform active:scale-95 dark:bg-red-900/20" className="ml-1 flex h-5 w-5 items-center justify-center rounded-md bg-red-50 opacity-70 transition-transform active:scale-95 dark:bg-red-900/20"
onClick={(event) => { onClick={(event) => {
@@ -195,18 +198,42 @@ export default function SidebarSessionItem({
<Button <Button
variant="ghost" variant="ghost"
className={cn( className={cn(
'w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200', 'h-auto w-full justify-start rounded-md border bg-card p-2 text-left font-normal transition-all duration-150',
isSelected && 'bg-accent text-accent-foreground', isSelected ? 'border-primary/20 bg-primary/5' : 'border-border/30',
!isSelected && isProcessing && 'bg-muted/20 hover:bg-accent/50', !isSelected && isProcessing
? 'border-border/60 bg-muted/20 hover:bg-muted/25'
: !isSelected && sessionView.isActive
? 'border-green-500/30 bg-green-50/5 hover:bg-green-50/10 dark:bg-green-900/5 dark:hover:bg-green-900/10'
: '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-center gap-2">
<SessionProviderLogo provider={session.__provider} className="mt-0.5 h-3 w-3 flex-shrink-0" /> <div
className={cn(
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md',
isSelected ? 'bg-primary/10' : 'bg-muted/50',
)}
>
<SessionProviderLogo provider={session.__provider} className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className={cn('flex items-center gap-2', isProcessing && 'pr-7')}> <div className="flex items-center gap-2">
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div> <div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
{!isProcessing && compactSessionAge && ( {isProcessing ? (
<span
className={cn(
'ml-auto flex-shrink-0 transition-opacity duration-200',
isEditing ? 'opacity-0' : 'group-hover:opacity-0',
)}
>
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
<span className="flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
</span>
</Tooltip>
</span>
) : 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',
@@ -224,19 +251,6 @@ 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(
@@ -295,7 +309,7 @@ export default function SidebarSessionItem({
> >
<Edit2 className="h-3 w-3 text-gray-600 dark:text-gray-400" /> <Edit2 className="h-3 w-3 text-gray-600 dark:text-gray-400" />
</button> </button>
{!sessionView.isCursorSession && ( {!isProcessing && (
<button <button
className="flex h-6 w-6 items-center justify-center rounded bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40" className="flex h-6 w-6 items-center justify-center rounded bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40"
onClick={(event) => { onClick={(event) => {

View File

@@ -29,6 +29,7 @@ type UseProjectsStateArgs = {
*/ */
type SessionUpsertedEvent = ServerEvent & { type SessionUpsertedEvent = ServerEvent & {
sessionId: string; sessionId: string;
providerSessionId?: string | null;
provider: LLMProvider; provider: LLMProvider;
session: ProjectSession; session: ProjectSession;
project: { project: {
@@ -51,12 +52,36 @@ type RegisterOptimisticSessionArgs = {
summary?: string | null; summary?: string | null;
}; };
type ProjectSessionPage = Pick<Project, 'sessions' | 'sessionMeta'>;
const DEFAULT_PROVIDER: LLMProvider = 'claude';
const serialize = (value: unknown) => JSON.stringify(value ?? null); const serialize = (value: unknown) => JSON.stringify(value ?? null);
const readSelectedProvider = (): LLMProvider => {
try {
const storedProvider = localStorage.getItem('selected-provider');
return storedProvider ? storedProvider as LLMProvider : DEFAULT_PROVIDER;
} catch {
return DEFAULT_PROVIDER;
}
};
const getSessionProvider = (session: ProjectSession): LLMProvider => {
const provider = session.__provider ?? session.provider;
return typeof provider === 'string' && provider.trim()
? provider as LLMProvider
: DEFAULT_PROVIDER;
};
const normalizeSessionProvider = (session: ProjectSession): ProjectSession => ({
...session,
__provider: getSessionProvider(session),
});
const projectsHaveChanges = ( const projectsHaveChanges = (
prevProjects: Project[], prevProjects: Project[],
nextProjects: Project[], nextProjects: Project[],
includeExternalSessions: boolean,
): boolean => { ): boolean => {
if (prevProjects.length !== nextProjects.length) { if (prevProjects.length !== nextProjects.length) {
return true; return true;
@@ -68,28 +93,14 @@ const projectsHaveChanges = (
return true; return true;
} }
const baseChanged = return (
nextProject.projectId !== prevProject.projectId || nextProject.projectId !== prevProject.projectId ||
nextProject.displayName !== prevProject.displayName || nextProject.displayName !== prevProject.displayName ||
nextProject.fullPath !== prevProject.fullPath || nextProject.fullPath !== prevProject.fullPath ||
Boolean(nextProject.isStarred) !== Boolean(prevProject.isStarred) || Boolean(nextProject.isStarred) !== Boolean(prevProject.isStarred) ||
serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) || serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) ||
serialize(nextProject.sessions) !== serialize(prevProject.sessions) || serialize(nextProject.sessions) !== serialize(prevProject.sessions) ||
serialize(nextProject.taskmaster) !== serialize(prevProject.taskmaster); serialize(nextProject.taskmaster) !== serialize(prevProject.taskmaster)
if (baseChanged) {
return true;
}
if (!includeExternalSessions) {
return false;
}
return (
serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) ||
serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) ||
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions) ||
serialize(nextProject.opencodeSessions) !== serialize(prevProject.opencodeSessions)
); );
}); });
}; };
@@ -121,13 +132,7 @@ const mergeTaskMasterCache = (nextProjects: Project[], previousProjects: Project
}; };
const getProjectSessions = (project: Project): ProjectSession[] => { const getProjectSessions = (project: Project): ProjectSession[] => {
return [ return project.sessions ?? [];
...(project.sessions ?? []),
...(project.codexSessions ?? []),
...(project.cursorSessions ?? []),
...(project.geminiSessions ?? []),
...(project.opencodeSessions ?? []),
];
}; };
const countLoadedProjectSessions = (project: Project): number => getProjectSessions(project).length; const countLoadedProjectSessions = (project: Project): number => getProjectSessions(project).length;
@@ -171,10 +176,6 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
const mergedProject: Project = { const mergedProject: Project = {
...incomingProject, ...incomingProject,
sessions: mergeSessionProviderLists(incomingProject.sessions ?? [], previousProject.sessions ?? []), sessions: mergeSessionProviderLists(incomingProject.sessions ?? [], previousProject.sessions ?? []),
cursorSessions: mergeSessionProviderLists(incomingProject.cursorSessions ?? [], previousProject.cursorSessions ?? []),
codexSessions: mergeSessionProviderLists(incomingProject.codexSessions ?? [], previousProject.codexSessions ?? []),
geminiSessions: mergeSessionProviderLists(incomingProject.geminiSessions ?? [], previousProject.geminiSessions ?? []),
opencodeSessions: mergeSessionProviderLists(incomingProject.opencodeSessions ?? [], previousProject.opencodeSessions ?? []),
}; };
const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount); const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount);
@@ -190,15 +191,11 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
const mergeProjectSessionPage = ( const mergeProjectSessionPage = (
existingProject: Project, existingProject: Project,
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>, sessionsPage: ProjectSessionPage,
): Project => { ): Project => {
const mergedProject: Project = { const mergedProject: Project = {
...existingProject, ...existingProject,
sessions: mergeSessionProviderLists(existingProject.sessions ?? [], sessionsPage.sessions ?? []), sessions: mergeSessionProviderLists(existingProject.sessions ?? [], sessionsPage.sessions ?? []),
cursorSessions: mergeSessionProviderLists(existingProject.cursorSessions ?? [], sessionsPage.cursorSessions ?? []),
codexSessions: mergeSessionProviderLists(existingProject.codexSessions ?? [], sessionsPage.codexSessions ?? []),
geminiSessions: mergeSessionProviderLists(existingProject.geminiSessions ?? [], sessionsPage.geminiSessions ?? []),
opencodeSessions: mergeSessionProviderLists(existingProject.opencodeSessions ?? [], sessionsPage.opencodeSessions ?? []),
}; };
const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0); const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0);
@@ -212,48 +209,77 @@ const mergeProjectSessionPage = (
return mergedProject; return mergedProject;
}; };
/** const getSessionAliasIds = (event: SessionUpsertedEvent): Set<string> => {
* Resolves which provider bucket on a `Project` holds sessions for a provider. const ids = new Set<string>();
* The legacy payload keeps Claude sessions in `sessions` and the other const add = (value: unknown) => {
* providers in their own arrays. if (typeof value !== 'string') {
*/ return;
const providerBucketKey = ( }
provider: LLMProvider,
): 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' => { const trimmed = value.trim();
if (provider === 'cursor') return 'cursorSessions'; if (trimmed) {
if (provider === 'codex') return 'codexSessions'; ids.add(trimmed);
if (provider === 'gemini') return 'geminiSessions'; }
if (provider === 'opencode') return 'opencodeSessions'; };
return 'sessions';
add(event.sessionId);
add(event.providerSessionId);
add(event.session?.id);
return ids;
}; };
/** /**
* Upserts one session into the matching provider bucket of a project. * Upserts one session into a project's normalized session list.
* *
* Existing rows are updated in place (summary/lastActivity changes from the * Existing rows are updated in place (summary/lastActivity changes from the
* watcher); new rows are prepended since the watcher only fires for sessions * watcher); new rows are prepended since the watcher only fires for sessions
* with fresh activity. `sessionMeta.total` grows only on insert. * with fresh activity. `sessionMeta.total` grows only on insert.
*/ */
const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent): Project => { const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent): Project => {
const bucketKey = providerBucketKey(event.provider); const sessions = project.sessions ?? [];
const bucket = project[bucketKey] ?? []; const aliasIds = getSessionAliasIds(event);
const existingIndex = bucket.findIndex((session) => session.id === event.sessionId); const normalizedSession: ProjectSession = {
...event.session,
id: event.sessionId,
__provider: event.provider,
};
const existingIndex = sessions.findIndex((session) => aliasIds.has(String(session.id)));
let nextBucket: ProjectSession[]; let nextSessions: ProjectSession[];
let inserted = false;
if (existingIndex >= 0) { if (existingIndex >= 0) {
const existing = bucket[existingIndex]; let changed = false;
const updated = { ...existing, ...event.session }; nextSessions = [];
if (serialize(existing) === serialize(updated)) {
for (const [index, session] of sessions.entries()) {
if (index === existingIndex) {
const updated = { ...session, ...normalizedSession };
if (serialize(session) !== serialize(updated)) {
changed = true;
}
nextSessions.push(updated);
continue;
}
if (aliasIds.has(String(session.id))) {
changed = true;
continue;
}
nextSessions.push(session);
}
if (!changed) {
return project; return project;
} }
nextBucket = [...bucket];
nextBucket[existingIndex] = updated;
} else { } else {
nextBucket = [event.session, ...bucket]; nextSessions = [normalizedSession, ...sessions];
inserted = true;
} }
const next: Project = { ...project, [bucketKey]: nextBucket }; const next: Project = { ...project, sessions: nextSessions };
if (existingIndex < 0) { if (inserted) {
const total = Number(project.sessionMeta?.total ?? 0) + 1; const total = Number(project.sessionMeta?.total ?? 0) + 1;
next.sessionMeta = { next.sessionMeta = {
...project.sessionMeta, ...project.sessionMeta,
@@ -272,14 +298,32 @@ const projectFromRegistration = (project: Project): Project => ({
displayName: project.displayName, displayName: project.displayName,
isStarred: project.isStarred, isStarred: project.isStarred,
sessions: project.sessions ?? [], sessions: project.sessions ?? [],
cursorSessions: project.cursorSessions ?? [],
codexSessions: project.codexSessions ?? [],
geminiSessions: project.geminiSessions ?? [],
opencodeSessions: project.opencodeSessions ?? [],
sessionMeta: project.sessionMeta ?? { hasMore: false, total: countLoadedProjectSessions(project) }, sessionMeta: project.sessionMeta ?? { hasMore: false, total: countLoadedProjectSessions(project) },
taskmaster: project.taskmaster, taskmaster: project.taskmaster,
}); });
const removeSessionFromProject = (project: Project, sessionIdToDelete: string): Project => {
const sessions = project.sessions ?? [];
const nextSessions = sessions.filter((session) => session.id !== sessionIdToDelete);
if (nextSessions.length === sessions.length) {
return project;
}
const updatedProject: Project = {
...project,
sessions: nextSessions,
};
const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);
updatedProject.sessionMeta = {
...project.sessionMeta,
total: totalSessions,
hasMore: countLoadedProjectSessions(updatedProject) < totalSessions,
};
return updatedProject;
};
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 => {
@@ -377,7 +421,7 @@ export function useProjectsState({
return mergedProjects; return mergedProjects;
} }
return projectsHaveChanges(prevProjects, mergedProjects, true) return projectsHaveChanges(prevProjects, mergedProjects)
? mergedProjects ? mergedProjects
: prevProjects; : prevProjects;
}); });
@@ -595,10 +639,6 @@ export function useProjectsState({
displayName: upsert.project.displayName, displayName: upsert.project.displayName,
isStarred: upsert.project.isStarred, isStarred: upsert.project.isStarred,
sessions: [], sessions: [],
cursorSessions: [],
codexSessions: [],
geminiSessions: [],
opencodeSessions: [],
sessionMeta: { hasMore: false, total: 0 }, sessionMeta: { hasMore: false, total: 0 },
} as Project; } as Project;
@@ -629,10 +669,40 @@ export function useProjectsState({
const updated = upsertSessionIntoProject(previousProject, upsert); const updated = upsertSessionIntoProject(previousProject, upsert);
return updated === previousProject ? previousProject : updated; return updated === previousProject ? previousProject : updated;
}); });
const aliasedSelectedSessionId =
typeof upsert.providerSessionId === 'string' && upsert.providerSessionId !== upsert.sessionId
? upsert.providerSessionId
: null;
if (!aliasedSelectedSessionId) {
return;
}
const normalizedSelectedSession: ProjectSession = {
...upsert.session,
id: upsert.sessionId,
__provider: upsert.provider,
__projectId: upsert.project?.projectId ?? currentSelectedSession?.__projectId,
};
setSelectedSession((previousSession) => {
if (previousSession?.id !== aliasedSelectedSessionId) {
return previousSession;
}
return {
...previousSession,
...normalizedSelectedSession,
};
});
if (sessionId === aliasedSelectedSessionId) {
navigate(`/session/${upsert.sessionId}`);
}
}; };
return subscribe(handleEvent); return subscribe(handleEvent);
}, [subscribe]); }, [navigate, sessionId, subscribe]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -650,77 +720,18 @@ export function useProjectsState({
// Project membership is resolved through `projectId` after the migration. // Project membership is resolved through `projectId` after the migration.
for (const project of projects) { for (const project of projects) {
const claudeSession = project.sessions?.find((session) => session.id === sessionId); const match = project.sessions?.find((session) => session.id === sessionId);
if (claudeSession) { if (match) {
const normalizedSession = normalizeSessionProvider(match);
const shouldUpdateProject = selectedProject?.projectId !== project.projectId; const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession = const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'claude'; selectedSession?.id !== sessionId || selectedSession.__provider !== normalizedSession.__provider;
if (shouldUpdateProject) { if (shouldUpdateProject) {
setSelectedProject(project); setSelectedProject(project);
} }
if (shouldUpdateSession) { if (shouldUpdateSession) {
setSelectedSession({ ...claudeSession, __provider: 'claude' }); setSelectedSession(normalizedSession);
}
return;
}
const cursorSession = project.cursorSessions?.find((session) => session.id === sessionId);
if (cursorSession) {
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'cursor';
if (shouldUpdateProject) {
setSelectedProject(project);
}
if (shouldUpdateSession) {
setSelectedSession({ ...cursorSession, __provider: 'cursor' });
}
return;
}
const codexSession = project.codexSessions?.find((session) => session.id === sessionId);
if (codexSession) {
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'codex';
if (shouldUpdateProject) {
setSelectedProject(project);
}
if (shouldUpdateSession) {
setSelectedSession({ ...codexSession, __provider: 'codex' });
}
return;
}
const geminiSession = project.geminiSessions?.find((session) => session.id === sessionId);
if (geminiSession) {
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'gemini';
if (shouldUpdateProject) {
setSelectedProject(project);
}
if (shouldUpdateSession) {
setSelectedSession({ ...geminiSession, __provider: 'gemini' });
}
return;
}
const opencodeSession = project.opencodeSessions?.find((session) => session.id === sessionId);
if (opencodeSession) {
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'opencode';
if (shouldUpdateProject) {
setSelectedProject(project);
}
if (shouldUpdateSession) {
setSelectedSession({ ...opencodeSession, __provider: 'opencode' });
} }
return; return;
} }
@@ -740,27 +751,9 @@ export function useProjectsState({
return; return;
} }
let providerFromStorage: string | null = null;
try {
providerFromStorage = localStorage.getItem('selected-provider');
} catch {
providerFromStorage = null;
}
const normalizedProvider: LLMProvider =
providerFromStorage === 'cursor'
? 'cursor'
: providerFromStorage === 'codex'
? 'codex'
: providerFromStorage === 'gemini'
? 'gemini'
: providerFromStorage === 'opencode'
? 'opencode'
: 'claude';
setSelectedSession({ setSelectedSession({
id: sessionId, id: sessionId,
__provider: normalizedProvider, __provider: readSelectedProvider(),
__projectId: selectedProject.projectId, __projectId: selectedProject.projectId,
summary: '', summary: '',
}); });
@@ -828,43 +821,7 @@ export function useProjectsState({
} }
setProjects((prevProjects) => setProjects((prevProjects) =>
prevProjects.map((project) => { prevProjects.map((project) => removeSessionFromProject(project, sessionIdToDelete)),
const sessions = project.sessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
const cursorSessions = project.cursorSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
const codexSessions = project.codexSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
const geminiSessions = project.geminiSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
const opencodeSessions = project.opencodeSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
const removedFromProject = (
sessions.length !== (project.sessions?.length ?? 0)
|| cursorSessions.length !== (project.cursorSessions?.length ?? 0)
|| codexSessions.length !== (project.codexSessions?.length ?? 0)
|| geminiSessions.length !== (project.geminiSessions?.length ?? 0)
|| opencodeSessions.length !== (project.opencodeSessions?.length ?? 0)
);
if (!removedFromProject) {
return project;
}
const updatedProject: Project = {
...project,
sessions,
cursorSessions,
codexSessions,
geminiSessions,
opencodeSessions,
};
const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);
updatedProject.sessionMeta = {
...project.sessionMeta,
total: totalSessions,
hasMore: countLoadedProjectSessions(updatedProject) < totalSessions,
};
return updatedProject;
}),
); );
}, },
[navigate, selectedSession?.id], [navigate, selectedSession?.id],
@@ -878,7 +835,7 @@ export function useProjectsState({
const mergedProjects = mergeExpandedSessionPages(projects, projectsWithTaskMaster); const mergedProjects = mergeExpandedSessionPages(projects, projectsWithTaskMaster);
setProjects((prevProjects) => setProjects((prevProjects) =>
projectsHaveChanges(prevProjects, mergedProjects, true) ? mergedProjects : prevProjects, projectsHaveChanges(prevProjects, mergedProjects) ? mergedProjects : prevProjects,
); );
if (!selectedProject) { if (!selectedProject) {
@@ -947,7 +904,7 @@ export function useProjectsState({
throw new Error(message); throw new Error(message);
} }
const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>; const sessionsPage = (await response.json()) as ProjectSessionPage;
let mergedProjectForSelection: Project | null = null; let mergedProjectForSelection: Project | null = null;
setProjects((previousProjects) => setProjects((previousProjects) =>

View File

@@ -6,7 +6,7 @@ export interface SessionActivity {
canInterrupt: boolean; canInterrupt: boolean;
/** /**
* When this request was first marked as processing (client clock). Drives * When this request was first marked as processing (client clock). Drives
* the elapsed-time display and the stale `session-status` reply guard. * the elapsed-time display and the stale `chat_subscribed` idle-ack guard.
*/ */
startedAt: number; startedAt: number;
} }

View File

@@ -14,6 +14,11 @@ export const languages = [
label: 'English', label: 'English',
nativeName: 'English', nativeName: 'English',
}, },
{
value: 'fr',
label: 'French',
nativeName: 'Français',
},
{ {
value: 'ko', value: 'ko',
label: 'Korean', label: 'Korean',
@@ -48,6 +53,8 @@ export const languages = [
value: 'tr', value: 'tr',
label: 'Turkish', label: 'Turkish',
nativeName: 'Türkçe', nativeName: 'Türkçe',
},
{
value: 'it', value: 'it',
label: 'Italian', label: 'Italian',
nativeName: 'Italiano', nativeName: 'Italiano',

View File

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

View File

@@ -0,0 +1,37 @@
{
"login": {
"title": "Bon retour",
"description": "Connectez-vous à votre compte CloudCLI auto-hébergé",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"submit": "Se connecter",
"loading": "Connexion en cours...",
"errors": {
"invalidCredentials": "Nom d'utilisateur ou mot de passe incorrect",
"requiredFields": "Veuillez remplir tous les champs",
"networkError": "Erreur réseau. Veuillez réessayer."
},
"placeholders": {
"username": "Entrez votre nom d'utilisateur",
"password": "Entrez votre mot de passe"
}
},
"register": {
"title": "Créer un compte",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"confirmPassword": "Confirmer le mot de passe",
"submit": "Créer le compte",
"loading": "Création du compte...",
"errors": {
"passwordMismatch": "Les mots de passe ne correspondent pas",
"usernameTaken": "Ce nom d'utilisateur est déjà pris",
"weakPassword": "Le mot de passe est trop faible"
}
},
"logout": {
"title": "Se déconnecter",
"confirm": "Êtes-vous sûr de vouloir vous déconnecter ?",
"button": "Se déconnecter"
}
}

View File

@@ -0,0 +1,241 @@
{
"codeBlock": {
"copy": "Copier",
"copied": "Copié",
"copyCode": "Copier le code"
},
"copyMessage": {
"copy": "Copier le message",
"copied": "Message copié",
"selectFormat": "Sélectionner le format de copie",
"copyAsMarkdown": "Copier en markdown",
"copyAsText": "Copier en texte brut"
},
"messageTypes": {
"user": "U",
"error": "Erreur",
"tool": "Outil",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode"
},
"tools": {
"settings": "Paramètres de l'outil",
"error": "Erreur de l'outil",
"result": "Résultat de l'outil",
"viewParams": "Voir les paramètres d'entrée",
"viewRawParams": "Voir les paramètres bruts",
"viewDiff": "Voir les différences pour",
"creatingFile": "Création du fichier :",
"updatingTodo": "Mise à jour de la liste de tâches",
"read": "Lire",
"readFile": "Lire le fichier",
"updateTodo": "Mettre à jour la liste de tâches",
"readTodo": "Lire la liste de tâches",
"searchResults": "résultats"
},
"search": {
"found": "{{count}} {{type}} trouvé(s)",
"file": "fichier",
"files": "fichiers",
"pattern": "motif :",
"in": "dans :"
},
"fileOperations": {
"updated": "Fichier mis à jour avec succès",
"created": "Fichier créé avec succès",
"written": "Fichier écrit avec succès",
"diff": "Diff",
"newFile": "Nouveau fichier",
"viewContent": "Voir le contenu du fichier",
"viewFullOutput": "Voir la sortie complète ({{count}} caractères)",
"contentDisplayed": "Le contenu du fichier est affiché dans la vue diff ci-dessus"
},
"interactive": {
"title": "Invite interactive",
"waiting": "En attente de votre réponse dans le CLI",
"instruction": "Veuillez sélectionner une option dans votre terminal où Claude s'exécute.",
"selectedOption": "✓ Claude a sélectionné l'option {{number}}",
"instructionDetail": "Dans le CLI, vous sélectionneriez cette option de manière interactive avec les touches fléchées ou en tapant le numéro."
},
"thinking": {
"title": "Réflexion...",
"emoji": "💭 Réflexion..."
},
"json": {
"response": "Réponse JSON"
},
"permissions": {
"grant": "Autoriser {{tool}}",
"added": "Permission ajoutée",
"addTo": "Ajoute {{entry}} aux outils autorisés.",
"retry": "Permission enregistrée. Relancez la requête pour utiliser l'outil.",
"error": "Impossible de mettre à jour les permissions. Veuillez réessayer.",
"openSettings": "Ouvrir les paramètres"
},
"todo": {
"updated": "La liste de tâches a été mise à jour avec succès",
"current": "Liste de tâches actuelle"
},
"plan": {
"viewPlan": "📋 Voir le plan d'implémentation",
"title": "Plan d'implémentation"
},
"usageLimit": {
"resetAt": "Limite d'utilisation Claude atteinte. Votre limite sera réinitialisée à **{{time}} {{timezone}}** - {{date}}"
},
"codex": {
"permissionMode": "Mode de permission",
"modes": {
"default": "Mode par défaut",
"auto": "Mode automatique",
"acceptEdits": "Accepter les modifications",
"bypassPermissions": "Contourner les permissions",
"plan": "Mode planification"
},
"descriptions": {
"default": "Seules les commandes de confiance (ls, cat, grep, git status, etc.) s'exécutent automatiquement. Les autres commandes sont ignorées. Peut écrire dans l'espace de travail.",
"auto": "Un classifieur de modèle décide pour chaque appel d'outil d'approuver ou refuser. Mode mains libres, mais plus sûr que le contournement — des refus peuvent toujours se produire.",
"acceptEdits": "Toutes les commandes s'exécutent automatiquement dans l'espace de travail. Mode entièrement automatique avec exécution sandboxée.",
"bypassPermissions": "Accès système complet sans restrictions. Toutes les commandes s'exécutent automatiquement avec accès disque et réseau complet. À utiliser avec précaution.",
"plan": "Mode planification - aucune commande n'est exécutée"
},
"technicalDetails": "Détails techniques"
},
"gemini": {
"permissionMode": "Mode de permission Gemini",
"description": "Contrôlez comment Gemini CLI gère les approbations d'opérations.",
"modes": {
"default": {
"title": "Standard (demander l'approbation)",
"description": "Gemini demandera une approbation avant d'exécuter des commandes, d'écrire des fichiers et de récupérer des ressources web."
},
"autoEdit": {
"title": "Modification automatique (ignorer les approbations de fichiers)",
"description": "Gemini approuvera automatiquement les modifications de fichiers et les récupérations web, mais demandera toujours pour les commandes shell."
},
"yolo": {
"title": "YOLO (contourner toutes les permissions)",
"description": "Gemini exécutera toutes les opérations sans demander d'approbation. Utilisez avec prudence."
}
}
},
"input": {
"placeholder": "Tapez / pour les commandes, @ pour les fichiers, ou posez une question à {{provider}}...",
"placeholderDefault": "Tapez votre message...",
"disabled": "Saisie désactivée",
"attachFiles": "Joindre des fichiers",
"attachImages": "Joindre des images",
"send": "Envoyer",
"stop": "Arrêter",
"hintText": {
"ctrlEnter": "Ctrl+Entrée pour envoyer • Maj+Entrée pour nouvelle ligne • Tab pour changer de mode • / pour les commandes slash",
"enter": "Entrée pour envoyer • Maj+Entrée pour nouvelle ligne • Tab pour changer de mode • / pour les commandes slash"
},
"clickToChangeMode": "Cliquez pour changer le mode de permission (ou appuyez sur Tab dans la saisie)",
"showAllCommands": "Afficher toutes les commandes",
"clearInput": "Effacer la saisie",
"scrollToBottom": "Défiler vers le bas"
},
"providerSelection": {
"title": "Choisissez votre assistant IA",
"description": "Sélectionnez un fournisseur pour démarrer une nouvelle conversation",
"selectModel": "Sélectionner un modèle",
"providerInfo": {
"anthropic": "par Anthropic",
"openai": "par OpenAI",
"cursorEditor": "Éditeur de code IA",
"google": "par Google"
},
"readyPrompt": {
"claude": "Prêt à utiliser Claude avec {{model}}. Commencez à taper votre message ci-dessous.",
"cursor": "Prêt à utiliser Cursor avec {{model}}. Commencez à taper votre message ci-dessous.",
"codex": "Prêt à utiliser Codex avec {{model}}. Commencez à taper votre message ci-dessous.",
"gemini": "Prêt à utiliser Gemini avec {{model}}. Commencez à taper votre message ci-dessous.",
"opencode": "Prêt à utiliser OpenCode avec {{model}}. Commencez à taper votre message ci-dessous.",
"default": "Sélectionnez un fournisseur ci-dessus pour commencer"
},
"pressToSearch": "Appuyez sur <kbd>{{shortcut}}</kbd> pour rechercher sessions, fichiers et commits"
},
"session": {
"continue": {
"title": "Continuer votre conversation",
"description": "Posez des questions sur votre code, demandez des modifications ou obtenez de l'aide pour vos tâches de développement"
},
"loading": {
"olderMessages": "Chargement des messages précédents...",
"sessionMessages": "Chargement des messages de la session..."
},
"messages": {
"showingOf": "Affichage de {{shown}} sur {{total}} messages",
"scrollToLoad": "Faites défiler vers le haut pour charger plus",
"showingLast": "Affichage des {{count}} derniers messages ({{total}} au total)",
"loadEarlier": "Charger les messages précédents",
"loadAll": "Charger tous les messages",
"loadingAll": "Chargement de tous les messages...",
"allLoaded": "Tous les messages chargés",
"perfWarning": "Tous les messages chargés — le défilement peut être plus lent. Cliquez sur « Défiler vers le bas » pour rétablir les performances."
}
},
"shell": {
"selectProject": {
"title": "Sélectionner un projet",
"description": "Choisissez un projet pour ouvrir un shell interactif dans ce répertoire"
},
"status": {
"newSession": "Nouvelle session",
"initializing": "Initialisation...",
"restarting": "Redémarrage..."
},
"actions": {
"disconnect": "Déconnecter",
"disconnectTitle": "Se déconnecter du shell",
"restart": "Redémarrer",
"restartTitle": "Redémarrer le shell",
"connect": "Continuer dans le shell",
"connectTitle": "Se connecter au shell"
},
"loading": "Chargement du terminal...",
"connecting": "Connexion au shell...",
"startSession": "Démarrer une nouvelle session Claude",
"resumeSession": "Reprendre la session : {{displayName}}...",
"runCommand": "Exécuter {{command}} dans {{projectName}}",
"startCli": "Démarrage du CLI Claude dans {{projectName}}",
"defaultCommand": "commande"
},
"claudeStatus": {
"actions": {
"thinking": "Réflexion",
"processing": "Traitement",
"analyzing": "Analyse",
"working": "Travail",
"computing": "Calcul",
"reasoning": "Raisonnement"
},
"state": {
"live": "En direct",
"paused": "En pause"
},
"elapsed": {
"seconds": "{{count}}s",
"minutesSeconds": "{{minutes}}m {{seconds}}s",
"label": "{{time}} écoulé",
"startingNow": "Démarrage"
},
"controls": {
"stopGeneration": "Arrêter la génération",
"pressEscToStop": "Appuyez sur Échap à tout moment pour arrêter"
},
"providers": {
"assistant": "Assistant"
}
},
"projectSelection": {
"startChatWithProvider": "Sélectionnez un projet pour commencer à chatter avec {{provider}}"
},
"tasks": {
"nextTaskPrompt": "Commencer la prochaine tâche"
}
}

View File

@@ -0,0 +1,36 @@
{
"toolbar": {
"changes": "modifications",
"previousChange": "Modification précédente",
"nextChange": "Modification suivante",
"hideDiff": "Masquer la mise en évidence des différences",
"showDiff": "Afficher la mise en évidence des différences",
"settings": "Paramètres de l'éditeur",
"collapse": "Réduire l'éditeur",
"expand": "Étendre l'éditeur en pleine largeur"
},
"loading": "Chargement de {{fileName}}...",
"header": {
"showingChanges": "Affichage des modifications"
},
"actions": {
"download": "Télécharger le fichier",
"save": "Enregistrer",
"saving": "Enregistrement...",
"saved": "Enregistré !",
"exitFullscreen": "Quitter le plein écran",
"fullscreen": "Plein écran",
"close": "Fermer",
"previewMarkdown": "Aperçu markdown",
"editMarkdown": "Modifier le markdown"
},
"footer": {
"lines": "Lignes :",
"characters": "Caractères :",
"shortcuts": "Ctrl+S pour enregistrer • Échap pour fermer"
},
"binaryFile": {
"title": "Fichier binaire",
"message": "Le fichier \"{{fileName}}\" ne peut pas être affiché dans l'éditeur de texte car c'est un fichier binaire."
}
}

View File

@@ -0,0 +1,267 @@
{
"buttons": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"create": "Créer",
"edit": "Modifier",
"close": "Fermer",
"confirm": "Confirmer",
"submit": "Soumettre",
"retry": "Réessayer",
"refresh": "Actualiser",
"search": "Rechercher",
"clear": "Effacer",
"copy": "Copier",
"download": "Télécharger",
"upload": "Envoyer",
"browse": "Parcourir"
},
"tabs": {
"chat": "Chat",
"shell": "Terminal",
"files": "Fichiers",
"git": "Contrôle de source",
"tasks": "Tâches"
},
"status": {
"loading": "Chargement...",
"success": "Succès",
"error": "Erreur",
"failed": "Échec",
"pending": "En attente",
"completed": "Terminé",
"inProgress": "En cours"
},
"messages": {
"savedSuccessfully": "Enregistré avec succès",
"deletedSuccessfully": "Supprimé avec succès",
"updatedSuccessfully": "Mis à jour avec succès",
"operationFailed": "Opération échouée",
"networkError": "Erreur réseau. Vérifiez votre connexion.",
"unauthorized": "Non autorisé. Veuillez vous connecter.",
"notFound": "Introuvable",
"invalidInput": "Entrée invalide",
"requiredField": "Ce champ est obligatoire",
"unknownError": "Une erreur inconnue s'est produite"
},
"navigation": {
"settings": "Paramètres",
"home": "Accueil",
"back": "Retour",
"next": "Suivant",
"previous": "Précédent",
"logout": "Déconnexion"
},
"common": {
"language": "Langue",
"theme": "Thème",
"darkMode": "Mode sombre",
"lightMode": "Mode clair",
"name": "Nom",
"description": "Description",
"enabled": "Activé",
"disabled": "Désactivé",
"optional": "Optionnel",
"version": "Version",
"select": "Sélectionner",
"selectAll": "Tout sélectionner",
"deselectAll": "Tout désélectionner"
},
"time": {
"justNow": "À l'instant",
"minutesAgo": "Il y a {{count}} min",
"hoursAgo": "Il y a {{count}} h",
"daysAgo": "Il y a {{count}} j",
"yesterday": "Hier"
},
"fileOperations": {
"newFile": "Nouveau fichier",
"newFolder": "Nouveau dossier",
"rename": "Renommer",
"move": "Déplacer",
"copyPath": "Copier le chemin",
"openInEditor": "Ouvrir dans l'éditeur"
},
"mainContent": {
"loading": "Chargement de CloudCLI",
"settingUpWorkspace": "Préparation de votre espace de travail...",
"chooseProject": "Choisissez votre projet",
"selectProjectDescription": "Sélectionnez un projet dans la barre latérale pour commencer à coder avec Claude. Chaque projet contient vos sessions de chat et l'historique des fichiers.",
"tip": "Astuce",
"createProjectMobile": "Appuyez sur le bouton menu ci-dessus pour accéder aux projets",
"createProjectDesktop": "Créez un nouveau projet en cliquant sur l'icône de dossier dans la barre latérale",
"newSession": "Nouvelle session",
"untitledSession": "Session sans titre",
"projectFiles": "Fichiers du projet"
},
"fileTree": {
"loading": "Chargement des fichiers...",
"files": "Fichiers",
"simpleView": "Vue simple",
"compactView": "Vue compacte",
"detailedView": "Vue détaillée",
"searchPlaceholder": "Rechercher fichiers et dossiers...",
"clearSearch": "Effacer la recherche",
"name": "Nom",
"size": "Taille",
"modified": "Modifié",
"permissions": "Permissions",
"noFilesFound": "Aucun fichier trouvé",
"checkProjectPath": "Vérifiez si le chemin du projet est accessible",
"noMatchesFound": "Aucun résultat",
"tryDifferentSearch": "Essayez un autre terme ou effacez la recherche",
"justNow": "à l'instant",
"minAgo": "il y a {{count}} min",
"hoursAgo": "il y a {{count}} h",
"daysAgo": "il y a {{count}} j",
"newFile": "Nouveau fichier (Cmd+N)",
"newFolder": "Nouveau dossier (Cmd+Maj+N)",
"refresh": "Actualiser",
"collapseAll": "Tout réduire",
"context": {
"rename": "Renommer",
"delete": "Supprimer",
"copyPath": "Copier le chemin",
"download": "Télécharger",
"newFile": "Nouveau fichier",
"newFolder": "Nouveau dossier",
"refresh": "Actualiser",
"menuLabel": "Menu contextuel du fichier",
"loading": "Chargement..."
}
},
"projectWizard": {
"title": "Créer un nouveau projet",
"steps": {
"type": "Type",
"configure": "Configurer",
"confirm": "Confirmer"
},
"step1": {
"question": "Avez-vous déjà un espace de travail, ou souhaitez-vous en créer un nouveau ?",
"existing": {
"title": "Espace de travail existant",
"description": "J'ai déjà un espace de travail sur mon serveur et je veux juste l'ajouter à la liste des projets"
},
"new": {
"title": "Nouvel espace de travail",
"description": "Créer un nouvel espace de travail, éventuellement cloné depuis un dépôt GitHub"
}
},
"step2": {
"existingPath": "Chemin de l'espace de travail",
"newPath": "Chemin de l'espace de travail",
"existingPlaceholder": "/chemin/vers/espace-de-travail",
"newPlaceholder": "/chemin/vers/nouvel-espace",
"existingHelp": "Chemin complet vers votre répertoire d'espace de travail existant",
"newHelp": "Chemin complet vers votre répertoire d'espace de travail",
"githubUrl": "URL GitHub (optionnel)",
"githubPlaceholder": "https://github.com/utilisateur/depot",
"githubHelp": "Optionnel : fournissez une URL GitHub pour cloner un dépôt",
"githubAuth": "Authentification GitHub (optionnel)",
"githubAuthHelp": "Uniquement requis pour les dépôts privés. Les dépôts publics peuvent être clonés sans authentification.",
"loadingTokens": "Chargement des tokens enregistrés...",
"storedToken": "Token enregistré",
"newToken": "Nouveau token",
"nonePublic": "Aucun (Public)",
"selectToken": "Sélectionner un token",
"selectTokenPlaceholder": "-- Sélectionner un token --",
"tokenPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"tokenHelp": "Ce token sera utilisé uniquement pour cette opération",
"publicRepoInfo": "Les dépôts publics ne nécessitent pas d'authentification. Vous pouvez ignorer le token pour cloner un dépôt public.",
"noTokensHelp": "Aucun token enregistré. Vous pouvez en ajouter dans Paramètres → Clés API.",
"optionalTokenPublic": "Token GitHub (optionnel pour les dépôts publics)",
"tokenPublicPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (laisser vide pour les dépôts publics)"
},
"step3": {
"reviewConfig": "Vérifiez votre configuration",
"existingWorkspace": "Espace de travail existant",
"newWorkspace": "Nouvel espace de travail",
"path": "Chemin :",
"cloneFrom": "Cloner depuis :",
"authentication": "Authentification :",
"usingStoredToken": "Utilisation du token enregistré :",
"usingProvidedToken": "Utilisation du token fourni",
"noAuthentication": "Sans authentification",
"sshKey": "Clé SSH",
"existingInfo": "L'espace de travail sera ajouté à votre liste de projets et disponible pour les sessions Claude/Cursor.",
"newWithClone": "Le dépôt sera cloné depuis ce dossier.",
"newEmpty": "L'espace de travail sera ajouté à votre liste de projets et disponible pour les sessions Claude/Cursor.",
"cloningRepository": "Clonage du dépôt..."
},
"buttons": {
"cancel": "Annuler",
"back": "Retour",
"next": "Suivant",
"createProject": "Créer le projet",
"creating": "Création...",
"cloning": "Clonage..."
},
"errors": {
"selectType": "Veuillez indiquer si vous avez un espace de travail existant ou si vous souhaitez en créer un nouveau",
"providePath": "Veuillez fournir un chemin d'espace de travail",
"failedToCreate": "Échec de la création de l'espace de travail",
"failedToCreateFolder": "Échec de la création du dossier"
}
},
"notifications": {
"genericTool": "un outil",
"codes": {
"generic": {
"info": {
"title": "Notification"
}
},
"permission": {
"required": {
"title": "Action requise",
"body": "{{toolName}} attend votre décision."
}
},
"run": {
"stopped": {
"title": "Exécution arrêtée",
"body": "Raison : {{reason}}"
},
"failed": {
"title": "Exécution échouée"
}
},
"agent": {
"notification": {
"title": "Notification de l'agent"
}
}
}
},
"versionUpdate": {
"title": "Mise à jour disponible",
"newVersionReady": "Une nouvelle version est prête",
"currentVersion": "Version actuelle",
"latestVersion": "Dernière version",
"whatsNew": "Nouveautés :",
"viewFullRelease": "Voir les notes de version complètes",
"updateProgress": "Progression de la mise à jour :",
"manualUpgrade": "Mise à jour manuelle :",
"npmUpgradeCommand": "npm install -g @cloudcli-ai/cloudcli@latest",
"manualUpgradeHint": "Ou cliquez sur « Mettre à jour maintenant » pour lancer la mise à jour automatiquement.",
"updateCompleted": "Mise à jour effectuée avec succès !",
"restartServer": "Veuillez redémarrer le serveur pour appliquer les modifications.",
"updateFailed": "Échec de la mise à jour",
"buttons": {
"close": "Fermer",
"later": "Plus tard",
"copyCommand": "Copier la commande",
"updateNow": "Mettre à jour maintenant",
"updating": "Mise à jour..."
},
"ariaLabels": {
"closeModal": "Fermer la fenêtre de mise à jour",
"showSidebar": "Afficher la barre latérale",
"settings": "Paramètres",
"updateAvailable": "Mise à jour disponible",
"closeSidebar": "Masquer la barre latérale"
}
}
}

View File

@@ -0,0 +1,548 @@
{
"title": "Paramètres",
"tabs": {
"account": "Compte",
"permissions": "Permissions",
"mcpServers": "Serveurs MCP",
"appearance": "Apparence"
},
"account": {
"title": "Compte",
"language": "Langue",
"languageLabel": "Langue d'affichage",
"languageDescription": "Choisissez votre langue préférée pour l'interface",
"username": "Nom d'utilisateur",
"email": "E-mail",
"profile": "Profil",
"changePassword": "Changer le mot de passe"
},
"mcp": {
"title": "Serveurs MCP",
"addServer": "Ajouter un serveur",
"editServer": "Modifier le serveur",
"deleteServer": "Supprimer le serveur",
"serverName": "Nom du serveur",
"serverType": "Type de serveur",
"config": "Configuration",
"testConnection": "Tester la connexion",
"status": "Statut",
"connected": "Connecté",
"disconnected": "Déconnecté",
"scope": {
"label": "Portée",
"user": "Utilisateur",
"project": "Projet"
}
},
"appearance": {
"title": "Apparence",
"theme": "Thème",
"codeEditor": "Éditeur de code",
"editorTheme": "Thème de l'éditeur",
"wordWrap": "Retour à la ligne",
"showMinimap": "Afficher la minimap",
"lineNumbers": "Numéros de ligne",
"fontSize": "Taille de police"
},
"actions": {
"saveChanges": "Enregistrer les modifications",
"resetToDefaults": "Rétablir les valeurs par défaut",
"cancelChanges": "Annuler les modifications"
},
"quickSettings": {
"title": "Paramètres rapides",
"sections": {
"appearance": "Apparence",
"toolDisplay": "Affichage des outils",
"viewOptions": "Options d'affichage",
"inputSettings": "Paramètres de saisie"
},
"darkMode": "Mode sombre",
"autoExpandTools": "Développer automatiquement les outils",
"showRawParameters": "Afficher les paramètres bruts",
"showThinking": "Afficher la réflexion",
"autoScrollToBottom": "Défilement automatique vers le bas",
"sendByCtrlEnter": "Envoyer avec Ctrl+Entrée",
"sendByCtrlEnterDescription": "Lorsqu'activé, appuyer sur Ctrl+Entrée envoie le message au lieu de simplement Entrée. Utile pour les utilisateurs IME pour éviter les envois accidentels.",
"dragHandle": {
"dragging": "Glissement en cours",
"closePanel": "Fermer le panneau de paramètres",
"openPanel": "Ouvrir le panneau de paramètres",
"draggingStatus": "Glissement...",
"toggleAndMove": "Cliquer pour basculer, glisser pour déplacer"
}
},
"terminalShortcuts": {
"title": "Raccourcis terminal",
"sectionKeys": "Touches",
"sectionNavigation": "Navigation",
"escape": "Échap",
"tab": "Tab",
"shiftTab": "Maj+Tab",
"arrowUp": "Flèche haut",
"arrowDown": "Flèche bas",
"scrollDown": "Défiler vers le bas",
"handle": {
"closePanel": "Fermer le panneau de raccourcis",
"openPanel": "Ouvrir le panneau de raccourcis"
}
},
"mainTabs": {
"label": "Paramètres",
"agents": "Agents",
"appearance": "Apparence",
"git": "Git",
"apiTokens": "API & Tokens",
"tasks": "Tâches",
"notifications": "Notifications",
"plugins": "Plugins",
"about": "À propos"
},
"notifications": {
"title": "Notifications",
"description": "Contrôlez les événements de notification que vous recevez.",
"webPush": {
"title": "Notifications push web",
"enable": "Activer les notifications push",
"disable": "Désactiver les notifications push",
"enabled": "Les notifications push sont activées",
"loading": "Mise à jour...",
"unsupported": "Les notifications push ne sont pas prises en charge dans ce navigateur.",
"denied": "Les notifications push sont bloquées. Veuillez les autoriser dans les paramètres de votre navigateur."
},
"sound": {
"title": "Son",
"description": "Jouer un court son lorsqu'une exécution de chat se termine.",
"enabled": "Activé",
"test": "Tester le son"
},
"events": {
"title": "Types d'événements",
"actionRequired": "Action requise",
"stop": "Exécution arrêtée",
"error": "Exécution échouée"
}
},
"appearanceSettings": {
"darkMode": {
"label": "Mode sombre",
"description": "Basculer entre les thèmes clair et sombre"
},
"projectSorting": {
"label": "Tri des projets",
"description": "Ordre d'affichage des projets dans la barre latérale",
"alphabetical": "Alphabétique",
"recentActivity": "Activité récente"
},
"codeEditor": {
"title": "Éditeur de code",
"theme": {
"label": "Thème de l'éditeur",
"description": "Thème par défaut pour l'éditeur de code"
},
"wordWrap": {
"label": "Retour à la ligne",
"description": "Activer le retour à la ligne par défaut dans l'éditeur"
},
"showMinimap": {
"label": "Afficher la minimap",
"description": "Afficher une minimap pour une navigation plus facile en vue diff"
},
"lineNumbers": {
"label": "Afficher les numéros de ligne",
"description": "Afficher les numéros de ligne dans l'éditeur"
},
"fontSize": {
"label": "Taille de police",
"description": "Taille de police de l'éditeur en pixels"
}
}
},
"mcpForm": {
"title": {
"add": "Ajouter un serveur MCP",
"edit": "Modifier le serveur MCP"
},
"importMode": {
"form": "Saisie via formulaire",
"json": "Import JSON"
},
"scope": {
"label": "Portée",
"userGlobal": "Utilisateur (global)",
"projectLocal": "Projet (local)",
"userDescription": "Portée utilisateur : Disponible dans tous les projets sur votre machine",
"projectDescription": "Portée locale : Disponible uniquement dans le projet sélectionné",
"cannotChange": "La portée ne peut pas être modifiée lors de la modification d'un serveur existant"
},
"fields": {
"serverName": "Nom du serveur",
"transportType": "Type de transport",
"command": "Commande",
"arguments": "Arguments (un par ligne)",
"jsonConfig": "Configuration JSON",
"url": "URL",
"envVars": "Variables d'environnement (CLÉ=valeur, une par ligne)",
"headers": "En-têtes (CLÉ=valeur, un par ligne)",
"selectProject": "Sélectionner un projet..."
},
"placeholders": {
"serverName": "mon-serveur"
},
"validation": {
"missingType": "Champ requis manquant : type",
"stdioRequiresCommand": "Le type stdio nécessite un champ command",
"httpRequiresUrl": "Le type {{type}} nécessite un champ url",
"invalidJson": "Format JSON invalide",
"jsonHelp": "Collez la configuration de votre serveur MCP en format JSON. Exemples :",
"jsonExampleStdio": "• stdio : {\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"@upstash/context7-mcp\"]}",
"jsonExampleHttp": "• http/sse : {\"type\":\"http\",\"url\":\"https://api.exemple.com/mcp\"}"
},
"configDetails": "Détails de configuration (depuis {{configFile}})",
"projectPath": "Chemin : {{path}}",
"actions": {
"cancel": "Annuler",
"saving": "Enregistrement...",
"addServer": "Ajouter le serveur",
"updateServer": "Mettre à jour le serveur"
}
},
"saveStatus": {
"success": "Paramètres enregistrés avec succès !",
"error": "Échec de l'enregistrement des paramètres",
"saving": "Enregistrement..."
},
"footerActions": {
"save": "Enregistrer les paramètres",
"cancel": "Annuler"
},
"git": {
"title": "Configuration Git",
"description": "Configurez votre identité git pour les commits. Ces paramètres seront appliqués globalement via git config --global",
"name": {
"label": "Nom Git",
"help": "Votre nom pour les commits git"
},
"email": {
"label": "E-mail Git",
"help": "Votre e-mail pour les commits git"
},
"actions": {
"save": "Enregistrer la configuration",
"saving": "Enregistrement..."
},
"status": {
"success": "Enregistré avec succès"
}
},
"apiKeys": {
"title": "Clés API",
"description": "Générez des clés API pour accéder à l'API externe depuis d'autres applications.",
"newKey": {
"alertTitle": "⚠️ Sauvegardez votre clé API",
"alertMessage": "C'est la seule fois que vous verrez cette clé. Stockez-la en lieu sûr.",
"iveSavedIt": "Je l'ai sauvegardée"
},
"form": {
"placeholder": "Nom de la clé API (ex. : Serveur de production)",
"createButton": "Créer",
"cancelButton": "Annuler"
},
"newButton": "Nouvelle clé API",
"empty": "Aucune clé API créée pour l'instant.",
"list": {
"created": "Créée :",
"lastUsed": "Dernière utilisation :"
},
"confirmDelete": "Êtes-vous sûr de vouloir supprimer cette clé API ?",
"status": {
"active": "Active",
"inactive": "Inactive"
},
"github": {
"title": "Tokens GitHub",
"description": "Ajoutez des tokens d'accès personnel GitHub pour cloner des dépôts privés via l'API externe.",
"descriptionAlt": "Ajoutez des tokens d'accès personnel GitHub pour cloner des dépôts privés. Vous pouvez aussi passer des tokens directement dans les requêtes API sans les stocker.",
"addButton": "Ajouter un token",
"form": {
"namePlaceholder": "Nom du token (ex. : Dépôts personnels)",
"tokenPlaceholder": "Token d'accès personnel GitHub (ghp_...)",
"descriptionPlaceholder": "Description (optionnel)",
"addButton": "Ajouter le token",
"cancelButton": "Annuler",
"howToCreate": "Comment créer un token d'accès personnel GitHub →"
},
"empty": "Aucun token GitHub ajouté pour l'instant.",
"added": "Ajouté :",
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce token GitHub ?"
},
"apiDocsLink": "Documentation API",
"documentation": {
"title": "Documentation de l'API externe",
"description": "Apprenez à utiliser l'API externe pour déclencher des sessions Claude/Cursor depuis vos applications.",
"viewLink": "Voir la documentation API →"
},
"loading": "Chargement...",
"version": {
"updateAvailable": "Mise à jour disponible : v{{version}}"
}
},
"tasks": {
"checking": "Vérification de l'installation TaskMaster...",
"notInstalled": {
"title": "CLI TaskMaster AI non installé",
"description": "Le CLI TaskMaster est requis pour utiliser les fonctionnalités de gestion des tâches. Installez-le pour commencer :",
"installCommand": "npm install -g task-master-ai",
"viewOnGitHub": "Voir sur GitHub",
"afterInstallation": "Après l'installation :",
"steps": {
"restart": "Redémarrez cette application",
"autoAvailable": "Les fonctionnalités TaskMaster deviendront automatiquement disponibles",
"initCommand": "Utilisez task-master init dans votre répertoire de projet"
}
},
"settings": {
"enableLabel": "Activer l'intégration TaskMaster",
"enableDescription": "Afficher les tâches TaskMaster, les bannières et les indicateurs dans la barre latérale"
}
},
"agents": {
"authStatus": {
"checking": "Vérification...",
"connected": "Connecté",
"notConnected": "Non connecté",
"disconnected": "Déconnecté",
"checkingAuth": "Vérification du statut d'authentification...",
"loggedInAs": "Connecté en tant que {{email}}",
"authenticatedUser": "utilisateur authentifié"
},
"account": {
"claude": {
"description": "Assistant IA Claude d'Anthropic"
},
"cursor": {
"description": "Éditeur de code IA Cursor"
},
"codex": {
"description": "Assistant IA Codex d'OpenAI"
},
"gemini": {
"description": "Assistant IA Gemini de Google"
},
"opencode": {
"description": "Assistant CLI OpenCode"
}
},
"connectionStatus": "Statut de la connexion",
"login": {
"title": "Connexion",
"reAuthenticate": "Se ré-authentifier",
"description": "Connectez-vous à votre compte {{agent}} pour activer les fonctionnalités IA",
"reAuthDescription": "Connectez-vous avec un autre compte ou actualisez les identifiants",
"button": "Se connecter",
"reLoginButton": "Se reconnecter"
},
"error": "Erreur : {{error}}"
},
"permissions": {
"title": "Paramètres de permission",
"skipPermissions": {
"label": "Ignorer les invites de permission (à utiliser avec précaution)",
"claudeDescription": "Équivalent au flag --dangerously-skip-permissions",
"cursorDescription": "Équivalent au flag -f dans le CLI Cursor"
},
"allowedTools": {
"title": "Outils autorisés",
"description": "Outils automatiquement autorisés sans demande de permission",
"placeholder": "ex. : \"Bash(git log:*)\" ou \"Write\"",
"quickAdd": "Ajout rapide d'outils courants :",
"empty": "Aucun outil autorisé configuré"
},
"blockedTools": {
"title": "Outils bloqués",
"description": "Outils automatiquement bloqués sans demande de permission",
"placeholder": "ex. : \"Bash(rm:*)\"",
"empty": "Aucun outil bloqué configuré"
},
"allowedCommands": {
"title": "Commandes shell autorisées",
"description": "Commandes shell automatiquement autorisées sans demande",
"placeholder": "ex. : \"Shell(ls)\" ou \"Shell(git status)\"",
"quickAdd": "Ajout rapide de commandes courantes :",
"empty": "Aucune commande autorisée configurée"
},
"blockedCommands": {
"title": "Commandes shell bloquées",
"description": "Commandes shell automatiquement bloquées",
"placeholder": "ex. : \"Shell(rm -rf)\" ou \"Shell(sudo)\"",
"empty": "Aucune commande bloquée configurée"
},
"toolExamples": {
"title": "Exemples de motifs d'outils :",
"bashGitLog": "- Autoriser toutes les commandes git log",
"bashGitDiff": "- Autoriser toutes les commandes git diff",
"write": "- Autoriser toutes les utilisations de l'outil Write",
"bashRm": "- Bloquer toutes les commandes rm (dangereux)"
},
"shellExamples": {
"title": "Exemples de commandes shell :",
"ls": "- Autoriser la commande ls",
"gitStatus": "- Autoriser git status",
"npmInstall": "- Autoriser npm install",
"rmRf": "- Bloquer la suppression récursive"
},
"codex": {
"permissionMode": "Mode de permission",
"description": "Contrôle comment Codex gère les modifications de fichiers et l'exécution des commandes",
"modes": {
"default": {
"title": "Par défaut",
"description": "Seules les commandes de confiance (ls, cat, grep, git status, etc.) s'exécutent automatiquement. Les autres sont ignorées. Peut écrire dans l'espace de travail."
},
"acceptEdits": {
"title": "Accepter les modifications",
"description": "Toutes les commandes s'exécutent automatiquement dans l'espace de travail. Mode entièrement automatique avec exécution sandboxée."
},
"bypassPermissions": {
"title": "Contourner les permissions",
"description": "Accès système complet sans restrictions. Toutes les commandes s'exécutent automatiquement avec accès disque et réseau complet. À utiliser avec précaution."
}
},
"technicalDetails": "Détails techniques",
"technicalInfo": {
"default": "sandboxMode=workspace-write, approvalPolicy=untrusted. Commandes de confiance : cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (sans -exec), etc.",
"acceptEdits": "sandboxMode=workspace-write, approvalPolicy=never. Toutes les commandes s'exécutent automatiquement dans le répertoire du projet.",
"bypassPermissions": "sandboxMode=danger-full-access, approvalPolicy=never. Accès système complet, à utiliser uniquement dans des environnements de confiance.",
"overrideNote": "Vous pouvez remplacer ce mode par session via le bouton de mode dans l'interface de chat."
}
},
"actions": {
"add": "Ajouter"
}
},
"mcpServers": {
"title": "Serveurs MCP",
"description": {
"claude": "Les serveurs Model Context Protocol fournissent des outils et sources de données supplémentaires à Claude",
"cursor": "Les serveurs Model Context Protocol fournissent des outils et sources de données supplémentaires à Cursor",
"codex": "Les serveurs Model Context Protocol fournissent des outils et sources de données supplémentaires à Codex",
"opencode": "Les serveurs Model Context Protocol fournissent des outils et sources de données supplémentaires à OpenCode"
},
"addButton": "Ajouter un serveur MCP",
"empty": "Aucun serveur MCP configuré",
"serverType": "Type",
"scope": {
"local": "local",
"user": "utilisateur"
},
"config": {
"command": "Commande",
"url": "URL",
"args": "Arguments",
"environment": "Environnement"
},
"tools": {
"title": "Outils",
"count": "({{count}}) :",
"more": "+{{count}} de plus"
},
"actions": {
"edit": "Modifier le serveur",
"delete": "Supprimer le serveur"
},
"help": {
"title": "À propos de Codex MCP",
"description": "Codex prend en charge les serveurs MCP basés sur stdio. Vous pouvez ajouter des serveurs qui étendent les capacités de Codex avec des outils et ressources supplémentaires."
}
},
"pluginSettings": {
"title": "Plugins",
"description": "Étendez l'interface avec des plugins personnalisés. Installez depuis git ou déposez un dossier dans ~/.claude-code-ui/plugins/",
"installPlaceholder": "https://github.com/utilisateur/mon-plugin",
"installButton": "Installer",
"installing": "Installation…",
"securityWarning": "N'installez que des plugins dont vous avez examiné le code source ou provenant d'auteurs de confiance.",
"scanningPlugins": "Analyse des plugins…",
"noPluginsInstalled": "Aucun plugin installé",
"pullLatest": "Récupérer la dernière version depuis git",
"noGitRemote": "Pas de remote git — mise à jour indisponible",
"uninstallPlugin": "Désinstaller le plugin",
"confirmUninstall": "Cliquez à nouveau pour confirmer",
"confirmUninstallMessage": "Supprimer {{name}} ? Cette action est irréversible.",
"cancel": "Annuler",
"remove": "Supprimer",
"updateFailed": "Échec de la mise à jour",
"installFailed": "Échec de l'installation",
"uninstallFailed": "Échec de la désinstallation",
"toggleFailed": "Échec du basculement",
"starterPluginLabel": "Plugin de démarrage",
"starter": "Démarrage",
"docs": "Docs",
"sections": {
"officialTitle": "Plugins officiels",
"officialDescription": "Maintenus par l'équipe CloudCLI et prêts à être installés directement.",
"unofficialTitle": "Autres plugins",
"unofficialDescription": "Plugins non officiels et intégrations d'autres utilisateurs. Examinez le code source avant d'installer."
},
"starterPlugin": {
"name": "Statistiques du projet",
"badge": "démarrage",
"description": "Nombre de fichiers, lignes de code, répartition par type de fichier et activité récente pour votre projet.",
"install": "Installer"
},
"terminalPlugin": {
"name": "Terminal",
"badge": "officiel",
"description": "Terminal intégré avec accès shell complet directement dans l'interface.",
"install": "Installer"
},
"scheduledPromptPlugin": {
"name": "Invites planifiées",
"badge": "non officiel",
"description": "Planifiez des invites d'espace de travail, consultez l'historique des exécutions et gérez les tâches locales récurrentes.",
"install": "Installer"
},
"claudeWatchPlugin": {
"name": "Claude Watch",
"badge": "non officiel",
"description": "Surveillez les sessions Claude Code longues pour détecter les blocages et exposez les contrôles de processus.",
"install": "Installer"
},
"prismCloudCLI": {
"name": "PRISM CloudCLI",
"badge": "non officiel",
"description": "Intelligence de session pour Claude Code, dans CloudCLI. Voyez pourquoi vos sessions consomment des tokens sans quitter le navigateur.",
"install": "Installer"
},
"sessionManagerPlugin": {
"name": "Sessions",
"badge": "non officiel",
"description": "Visualisez, gérez et terminez les sessions Claude Code actives.",
"install": "Installer"
},
"tokenCostCalculatorPlugin": {
"name": "Calculateur de coût en tokens",
"badge": "non officiel",
"description": "Calculez les coûts API à partir des prix des modèles et de l'utilisation des tokens, avec prise en charge des tarifs préréglés.",
"install": "Installer"
},
"taskQueuePlugin": {
"name": "File de tâches",
"badge": "non officiel",
"description": "Tableau de bord de file de tâches pour visualiser, filtrer et lancer des tâches d'agent.",
"install": "Installer"
},
"githubIssuesBoardPlugin": {
"name": "Tableau des issues GitHub",
"badge": "non officiel",
"description": "Tableau Kanban pour les issues GitHub avec synchronisation bidirectionnelle TaskMaster et installation automatique de la compétence CLI /github-task",
"install": "Installer"
},
"morePlugins": "Plus",
"enable": "Activer",
"disable": "Désactiver",
"installAriaLabel": "URL du dépôt git du plugin",
"tab": "onglet",
"runningStatus": "en cours"
}
}

View File

@@ -0,0 +1,137 @@
{
"projects": {
"title": "Projets",
"newProject": "Nouveau projet",
"deleteProject": "Supprimer le projet",
"renameProject": "Renommer le projet",
"noProjects": "Aucun projet trouvé",
"loadingProjects": "Chargement des projets...",
"searchPlaceholder": "Rechercher des projets...",
"projectNamePlaceholder": "Nom du projet",
"starred": "Favoris",
"all": "Tous",
"untitledSession": "Session sans titre",
"newSession": "Nouvelle session",
"codexSession": "Session Codex",
"fetchingProjects": "Récupération de vos projets et sessions Claude",
"projects": "projets",
"noMatchingProjects": "Aucun projet correspondant",
"tryDifferentSearch": "Essayez d'ajuster votre terme de recherche",
"runClaudeCli": "Exécutez le CLI Claude dans un répertoire de projet pour commencer"
},
"app": {
"title": "CloudCLI",
"subtitle": "Interface d'assistant de codage IA"
},
"sessions": {
"title": "Sessions",
"newSession": "Nouvelle session",
"deleteSession": "Supprimer la session",
"renameSession": "Renommer la session",
"noSessions": "Aucune session pour l'instant",
"loadingSessions": "Chargement des sessions...",
"unnamed": "Sans nom",
"loading": "Chargement...",
"showMore": "Afficher plus de sessions"
},
"tooltips": {
"viewEnvironments": "Voir les environnements",
"hideSidebar": "Masquer la barre latérale",
"createProject": "Créer un nouveau projet",
"refresh": "Actualiser les projets et sessions (Ctrl+R)",
"renameProject": "Renommer le projet (F2)",
"deleteProject": "Retirer le projet de la barre latérale (Suppr)",
"addToFavorites": "Ajouter aux favoris",
"removeFromFavorites": "Retirer des favoris",
"editSessionName": "Modifier manuellement le nom de la session",
"deleteSession": "Supprimer définitivement cette session",
"activeSessionIndicator": "Session récemment active (10 dernières minutes)",
"save": "Enregistrer",
"cancel": "Annuler",
"clearSearch": "Effacer la recherche",
"openCommandPalette": "Ouvrir la palette de commandes"
},
"navigation": {
"chat": "Chat",
"files": "Fichiers",
"git": "Git",
"terminal": "Terminal",
"tasks": "Tâches"
},
"actions": {
"refresh": "Actualiser",
"settings": "Paramètres",
"collapseAll": "Tout réduire",
"expandAll": "Tout développer",
"cancel": "Annuler",
"save": "Enregistrer",
"delete": "Supprimer",
"rename": "Renommer",
"joinCommunity": "Rejoindre la communauté",
"reportIssue": "Signaler un problème",
"starOnGithub": "Étoile sur GitHub"
},
"branding": {
"openSource": "Open Source"
},
"status": {
"active": "Actif",
"inactive": "Inactif",
"thinking": "Réflexion...",
"error": "Erreur",
"aborted": "Annulé",
"unknown": "Inconnu"
},
"time": {
"justNow": "À l'instant",
"oneMinuteAgo": "Il y a 1 min",
"minutesAgo": "Il y a {{count}} min",
"oneHourAgo": "Il y a 1 heure",
"hoursAgo": "Il y a {{count}} heures",
"oneDayAgo": "Il y a 1 jour",
"daysAgo": "Il y a {{count}} jours"
},
"messages": {
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer ceci ?",
"renameSuccess": "Renommé avec succès",
"deleteSuccess": "Supprimé avec succès",
"errorOccurred": "Une erreur s'est produite",
"deleteSessionConfirm": "Êtes-vous sûr de vouloir supprimer cette session ? Cette action est irréversible.",
"deleteProjectConfirm": "Retirer ce projet de la barre latérale ? Vos fichiers, mémoires et données de session ne seront pas supprimés.",
"enterProjectPath": "Veuillez entrer un chemin de projet",
"deleteSessionFailed": "Échec de la suppression de la session. Veuillez réessayer.",
"deleteSessionError": "Erreur lors de la suppression de la session. Veuillez réessayer.",
"renameSessionFailed": "Échec du renommage de la session. Veuillez réessayer.",
"renameSessionError": "Erreur lors du renommage de la session. Veuillez réessayer.",
"deleteProjectFailed": "Échec de la suppression du projet. Veuillez réessayer.",
"deleteProjectError": "Erreur lors de la suppression du projet. Veuillez réessayer.",
"createProjectFailed": "Échec de la création du projet. Veuillez réessayer.",
"createProjectError": "Erreur lors de la création du projet. Veuillez réessayer."
},
"version": {
"updateAvailable": "Mise à jour disponible"
},
"search": {
"modeProjects": "Projets",
"modeConversations": "Conversations",
"conversationsPlaceholder": "Rechercher dans les conversations...",
"searching": "Recherche en cours...",
"noResults": "Aucun résultat trouvé",
"tryDifferentQuery": "Essayez une autre requête de recherche",
"matches_one": "{{count}} résultat",
"matches_other": "{{count}} résultats",
"projectsScanned_one": "{{count}} projet analysé",
"projectsScanned_other": "{{count}} projets analysés"
},
"deleteConfirmation": {
"deleteProject": "Supprimer le projet",
"deleteSession": "Supprimer la session",
"confirmDelete": "Que souhaitez-vous faire avec",
"sessionCount_one": "Ce projet contient {{count}} conversation.",
"sessionCount_other": "Ce projet contient {{count}} conversations.",
"removeFromSidebar": "Retirer de la barre latérale uniquement",
"deleteAllData": "Supprimer toutes les données définitivement",
"allConversationsDeleted": "Le projet sera retiré de la barre latérale. Vos fichiers, mémoires et données de session seront conservés.",
"cannotUndo": "Vous pourrez rajouter le projet ultérieurement."
}
}

View File

@@ -0,0 +1,142 @@
{
"notConfigured": {
"title": "TaskMaster AI n'est pas configuré",
"description": "TaskMaster aide à décomposer des projets complexes en tâches gérables avec une assistance IA",
"whatIsTitle": "🎯 Qu'est-ce que TaskMaster ?",
"features": {
"aiPowered": "Gestion des tâches assistée par IA : Décomposez des projets complexes en sous-tâches gérables",
"prdTemplates": "Modèles PRD : Générez des tâches à partir de documents d'exigences produit",
"dependencyTracking": "Suivi des dépendances : Comprenez les relations entre tâches et l'ordre d'exécution",
"progressVisualization": "Visualisation de l'avancement : Tableaux Kanban et analyses détaillées des tâches",
"cliIntegration": "Intégration CLI : Utilisez les commandes taskmaster pour des flux de travail avancés"
},
"initializeButton": "Initialiser TaskMaster AI"
},
"gettingStarted": {
"title": "Démarrer avec TaskMaster",
"subtitle": "TaskMaster est initialisé ! Voici la suite :",
"steps": {
"createPRD": {
"title": "Créer un document d'exigences produit (PRD)",
"description": "Discutez de votre idée de projet et créez un PRD décrivant ce que vous voulez construire.",
"addButton": "Ajouter un PRD",
"existingPRDs": "PRDs existants :"
},
"generateTasks": {
"title": "Générer des tâches à partir du PRD",
"description": "Une fois votre PRD prêt, demandez à votre assistant IA de l'analyser et TaskMaster le décomposera automatiquement en tâches gérables avec des détails d'implémentation."
},
"analyzeTasks": {
"title": "Analyser et développer les tâches",
"description": "Demandez à votre assistant IA d'analyser la complexité des tâches et de les développer en sous-tâches détaillées pour une implémentation plus facile."
},
"startBuilding": {
"title": "Commencer à construire",
"description": "Demandez à votre assistant IA de commencer à travailler sur les tâches, mettre à jour leur statut et ajouter de nouvelles tâches au fur et à mesure."
}
},
"tip": "💡 Astuce : Commencez par un PRD pour tirer le meilleur parti de la génération de tâches IA de TaskMaster"
},
"setupModal": {
"title": "Configuration TaskMaster",
"subtitle": "CLI interactif pour {{projectName}}",
"willStart": "L'initialisation de TaskMaster démarrera automatiquement",
"completed": "Configuration TaskMaster terminée ! Vous pouvez fermer cette fenêtre.",
"closeButton": "Fermer",
"closeContinueButton": "Fermer et continuer"
},
"helpGuide": {
"title": "Démarrer avec TaskMaster",
"subtitle": "Votre guide pour une gestion productive des tâches",
"examples": {
"parsePRD": "💬 Exemple :\n« Je viens d'initialiser un nouveau projet avec Claude Task Master. J'ai un PRD dans .taskmaster/docs/prd.txt. Pouvez-vous m'aider à l'analyser et configurer les tâches initiales ? »",
"expandTask": "💬 Exemple :\n« La tâche 5 semble complexe. Pouvez-vous la décomposer en sous-tâches ? »",
"addTask": "💬 Exemple :\n« Veuillez ajouter une nouvelle tâche pour implémenter le téléchargement d'images de profil utilisateur avec Cloudinary, recherchez la meilleure approche. »"
},
"moreExamples": "Voir plus d'exemples et de patterns d'utilisation →",
"proTips": {
"title": "💡 Conseils pro",
"search": "Utilisez la barre de recherche pour trouver rapidement des tâches spécifiques",
"views": "Basculez entre les vues Kanban, Liste et Grille via les boutons de vue",
"filters": "Utilisez les filtres pour vous concentrer sur des statuts ou priorités de tâches spécifiques",
"details": "Cliquez sur une tâche pour voir les détails et gérer les sous-tâches"
},
"learnMore": {
"title": "📚 En savoir plus",
"description": "TaskMaster AI est un système avancé de gestion des tâches conçu pour les développeurs. Documentation, exemples et contributions au projet.",
"githubButton": "Voir sur GitHub"
}
},
"search": {
"placeholder": "Rechercher des tâches..."
},
"filters": {
"button": "Filtres",
"status": "Statut",
"priority": "Priorité",
"sortBy": "Trier par",
"allStatuses": "Tous les statuts",
"allPriorities": "Toutes les priorités",
"showing": "Affichage de {{filtered}} sur {{total}} tâches",
"clearFilters": "Effacer les filtres"
},
"sort": {
"id": "ID",
"status": "Statut",
"priority": "Priorité",
"idAsc": "ID (croissant)",
"idDesc": "ID (décroissant)",
"titleAsc": "Titre (A-Z)",
"titleDesc": "Titre (Z-A)",
"statusAsc": "Statut (en attente en premier)",
"statusDesc": "Statut (terminé en premier)",
"priorityAsc": "Priorité (haute en premier)",
"priorityDesc": "Priorité (basse en premier)"
},
"views": {
"kanban": "Vue Kanban",
"list": "Vue liste",
"grid": "Vue grille"
},
"kanban": {
"pending": "📋 À faire",
"inProgress": "🚀 En cours",
"done": "✅ Terminé",
"blocked": "🚫 Bloqué",
"deferred": "⏳ Différé",
"cancelled": "❌ Annulé",
"noTasksYet": "Aucune tâche pour l'instant",
"tasksWillAppear": "Les tâches apparaîtront ici",
"moveTasksHere": "Déplacez les tâches ici au démarrage",
"completedTasksHere": "Les tâches terminées apparaissent ici",
"statusTasksHere": "Les tâches avec ce statut apparaîtront ici"
},
"buttons": {
"help": "Guide de démarrage TaskMaster",
"prds": "PRDs",
"addPRD": "Ajouter un PRD",
"addTask": "Ajouter une tâche",
"createNewPRD": "Créer un nouveau PRD",
"prdsAvailable": "{{count}} PRD(s) disponible(s)"
},
"prd": {
"modified": "Modifié : {{date}}"
},
"statuses": {
"pending": "En attente",
"in-progress": "En cours",
"done": "Terminé",
"blocked": "Bloqué",
"deferred": "Différé",
"cancelled": "Annulé"
},
"priorities": {
"high": "Haute",
"medium": "Moyenne",
"low": "Basse"
},
"noMatchingTasks": {
"title": "Aucune tâche ne correspond à vos filtres",
"description": "Essayez d'ajuster votre recherche ou vos critères de filtre."
}
}

View File

@@ -166,6 +166,108 @@ function hasServerEchoForLocalUser(
}); });
} }
function compareMessagesChronologically(a: NormalizedMessage, b: NormalizedMessage): number {
const timeA = readMessageTime(a) ?? 0;
const timeB = readMessageTime(b) ?? 0;
if (timeA !== timeB) {
return timeA - timeB;
}
return 0;
}
/**
* Count how many user turns precede `message` in a chronologically merged view
* of server + realtime rows. Used to match a realtime row to the correct turn
* on disk when several turns share identical assistant text.
*/
function getUserTurnOrdinalBefore(
message: NormalizedMessage,
serverMessages: NormalizedMessage[],
realtimeMessages: NormalizedMessage[],
): number {
const messageTime = readMessageTime(message);
let userCount = 0;
for (const candidate of [...serverMessages, ...realtimeMessages].sort(compareMessagesChronologically)) {
if (candidate.id === message.id) {
break;
}
const candidateTime = readMessageTime(candidate);
if (
messageTime !== null
&& candidateTime !== null
&& candidateTime > messageTime
) {
break;
}
if (candidate.kind === 'text' && candidate.role === 'user') {
userCount++;
}
}
return Math.max(0, userCount - 1);
}
function findServerTurnRangeByOrdinal(
serverMessages: NormalizedMessage[],
turnOrdinal: number,
): { start: number; end: number } | null {
let userCount = -1;
let start = -1;
for (let index = 0; index < serverMessages.length; index++) {
const message = serverMessages[index];
if (message.kind === 'text' && message.role === 'user') {
userCount++;
if (userCount === turnOrdinal) {
start = index;
break;
}
}
}
if (start < 0) {
return null;
}
let end = serverMessages.length;
for (let index = start + 1; index < serverMessages.length; index++) {
if (serverMessages[index].kind === 'text' && serverMessages[index].role === 'user') {
end = index;
break;
}
}
return { start, end };
}
function isAssistantTextEchoedInSameTurnOnServer(
message: NormalizedMessage,
serverMessages: NormalizedMessage[],
realtimeMessages: NormalizedMessage[],
): boolean {
const assistantText = (message.content || '').trim();
if (!assistantText) {
return false;
}
const turnOrdinal = getUserTurnOrdinalBefore(message, serverMessages, realtimeMessages);
const turnRange = findServerTurnRangeByOrdinal(serverMessages, turnOrdinal);
if (!turnRange) {
return false;
}
return serverMessages
.slice(turnRange.start + 1, turnRange.end)
.some((serverMessage) =>
serverMessage.kind === 'text'
&& serverMessage.role === 'assistant'
&& (serverMessage.content || '').trim() === assistantText,
);
}
/** /**
* 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.
@@ -203,22 +305,92 @@ function dedupeAdjacentAssistantEchoes(merged: NormalizedMessage[]): NormalizedM
return out; return out;
} }
/**
* After a server refresh, drop only the realtime rows the persisted transcript
* already owns. Anything not yet on disk (common right after `complete`, while
* JSONL indexing lags) stays in `realtimeMessages` so the chat pane never
* flashes the empty "Continue your conversation" state.
*/
function pruneRealtimeSupersededByServer(
serverMessages: NormalizedMessage[],
realtimeMessages: NormalizedMessage[],
): NormalizedMessage[] {
if (realtimeMessages.length === 0) {
return realtimeMessages;
}
const serverIds = new Set(serverMessages.map((message) => message.id));
return realtimeMessages.filter((message) => {
if (serverIds.has(message.id)) {
return false;
}
if (message.id.startsWith('local_') && hasServerEchoForLocalUser(message, serverMessages)) {
return false;
}
if (message.kind === 'stream_delta' || message.id === `__streaming_${message.sessionId}`) {
if (isAssistantTextEchoedInSameTurnOnServer(message, serverMessages, realtimeMessages)) {
return false;
}
return true;
}
if (message.kind === 'text' && message.role === 'assistant') {
if (isAssistantTextEchoedInSameTurnOnServer(message, serverMessages, realtimeMessages)) {
return false;
}
return true;
}
if (message.kind === 'text' && message.role === 'user') {
return !hasServerEchoForLocalUser(message, serverMessages);
}
if (message.kind === 'tool_use' && message.toolId) {
if (serverMessages.some((serverMessage) => serverMessage.kind === 'tool_use' && serverMessage.toolId === message.toolId)) {
return false;
}
}
return true;
});
}
function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] { function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] {
if (realtime.length === 0) return server; if (realtime.length === 0) {
if (server.length === 0) return dedupeAdjacentAssistantEchoes(realtime); return dedupeAdjacentAssistantEchoes(server);
const serverIds = new Set(server.map(m => m.id)); }
const extra = realtime.filter((m) => { if (server.length === 0) {
if (serverIds.has(m.id)) return false; return dedupeAdjacentAssistantEchoes(realtime);
}
const serverIds = new Set(server.map((message) => message.id));
const extra = realtime.filter((message) => {
if (serverIds.has(message.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 from the same send window, drop the realtime echo to // server-backed copy from the same send window, drop the realtime echo to
// avoid duplicate bubbles without hiding repeated prompts from history. // avoid duplicate bubbles without hiding repeated prompts from history.
if (m.id.startsWith('local_')) { if (message.id.startsWith('local_')) {
if (hasServerEchoForLocalUser(m, server)) return false; if (hasServerEchoForLocalUser(message, server)) {
return false;
}
} }
return true; return true;
}); });
if (extra.length === 0) return server;
return dedupeAdjacentAssistantEchoes([...server, ...extra]); if (extra.length === 0) {
return dedupeAdjacentAssistantEchoes(server);
}
// Interleave by timestamp so live rows stay with their turn instead of
// piling up at the bottom after every refresh.
return dedupeAdjacentAssistantEchoes(
[...server, ...extra].sort(compareMessagesChronologically),
);
} }
/** /**
@@ -282,9 +454,6 @@ export function useSessionStore() {
const fetchFromServer = useCallback(async ( const fetchFromServer = useCallback(async (
sessionId: string, sessionId: string,
opts: { opts: {
provider?: LLMProvider;
projectId?: string;
projectPath?: string;
limit?: number | null; limit?: number | null;
offset?: number; offset?: number;
} = {}, } = {},
@@ -339,9 +508,6 @@ export function useSessionStore() {
const fetchMore = useCallback(async ( const fetchMore = useCallback(async (
sessionId: string, sessionId: string,
opts: { opts: {
provider?: LLMProvider;
projectId?: string;
projectPath?: string;
limit?: number; limit?: number;
} = {}, } = {},
) => { ) => {
@@ -420,11 +586,6 @@ export function useSessionStore() {
*/ */
const refreshFromServer = useCallback(async ( const refreshFromServer = useCallback(async (
sessionId: string, sessionId: string,
_opts: {
provider?: LLMProvider;
projectId?: string;
projectPath?: string;
} = {},
) => { ) => {
const slot = getSlot(sessionId); const slot = getSlot(sessionId);
try { try {
@@ -439,8 +600,13 @@ export function useSessionStore() {
slot.total = data.total ?? slot.serverMessages.length; slot.total = data.total ?? slot.serverMessages.length;
slot.hasMore = Boolean(data.hasMore); slot.hasMore = Boolean(data.hasMore);
slot.fetchedAt = Date.now(); slot.fetchedAt = Date.now();
// drop realtime messages that the server has caught up with to prevent unbounded growth. // Only drop realtime rows the server transcript now owns. A blind clear
slot.realtimeMessages = []; // here caused the chat pane to flash "Continue your conversation" after
// `complete` while JSONL / provider_session_id indexing was still behind.
slot.realtimeMessages = pruneRealtimeSupersededByServer(
slot.serverMessages,
slot.realtimeMessages,
);
recomputeMergedIfNeeded(slot); recomputeMergedIfNeeded(slot);
notify(sessionId); notify(sessionId);
} catch (error) { } catch (error) {

View File

@@ -29,6 +29,7 @@ export interface ProjectSession {
updated_at?: string; updated_at?: string;
lastActivity?: string; lastActivity?: string;
messageCount?: number; messageCount?: number;
provider?: LLMProvider;
__provider?: LLMProvider; __provider?: LLMProvider;
// Tags the session with the owning project's DB `projectId` so UI handlers // Tags the session with the owning project's DB `projectId` so UI handlers
// (session switching, sidebar focus, etc.) can match against selectedProject. // (session switching, sidebar focus, etc.) can match against selectedProject.
@@ -60,10 +61,6 @@ export interface Project {
path?: string; path?: string;
isStarred?: boolean; isStarred?: boolean;
sessions?: ProjectSession[]; sessions?: ProjectSession[];
cursorSessions?: ProjectSession[];
codexSessions?: ProjectSession[];
geminiSessions?: ProjectSession[];
opencodeSessions?: ProjectSession[];
sessionMeta?: ProjectSessionMeta; sessionMeta?: ProjectSessionMeta;
taskmaster?: ProjectTaskmasterInfo; taskmaster?: ProjectTaskmasterInfo;
[key: string]: unknown; [key: string]: unknown;