mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-06 21:52:43 +08:00
Compare commits
9 Commits
fix/resolv
...
feat/claud
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bcd82834c | ||
|
|
10a0e3ff8d | ||
|
|
78968fe33c | ||
|
|
7785763c14 | ||
|
|
85e8facfbb | ||
|
|
ff4f8d6ac7 | ||
|
|
eb08aadaa0 | ||
|
|
0206a1f6aa | ||
|
|
d618abb075 |
@@ -3,12 +3,6 @@
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [](https://github.com/siteboon/claudecodeui/compare/v1.35.1...vnull) (2026-07-03)
|
||||
|
||||
### New Features
|
||||
|
||||
* add Claude and Codex effort controls ([#943](https://github.com/siteboon/claudecodeui/issues/943)) ([d272922](https://github.com/siteboon/claudecodeui/commit/d272922d87e4ee74bf8b0fdeac83b2c1e77973f3))
|
||||
|
||||
## [1.35.1](https://github.com/siteboon/claudecodeui/compare/v1.35.0...v1.35.1) (2026-07-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -126,7 +126,7 @@ CloudCLI UI ist die Open-Source-UI-Schicht, die CloudCLI Cloud antreibt. Du kann
|
||||
| **REST API** | Ja | Ja |
|
||||
| **n8n-Node** | Nein | Ja |
|
||||
| **Team-Sharing** | Nein | Ja |
|
||||
| **Plattformkosten** | Kostenlos, Open Source | Ab €7/Monat |
|
||||
| **Plattformkosten** | Kostenlos, Open Source | Ab $7/Monat |
|
||||
|
||||
> Beide Optionen verwenden deine eigenen KI-Abonnements (Claude, Cursor usw.) – CloudCLI stellt die Umgebung bereit, nicht die KI.
|
||||
|
||||
@@ -202,7 +202,7 @@ Das bedeutet in der Praxis:
|
||||
<details>
|
||||
<summary>Muss ich ein KI-Abonnement separat bezahlen?</summary>
|
||||
|
||||
Ja. CloudCLI stellt die Umgebung bereit, nicht die KI. Du bringst dein eigenes Claude-, Cursor-, Codex- oder Gemini-Abonnement mit. CloudCLI Cloud beginnt bei €7/Monat für die gehostete Umgebung zusätzlich dazu.
|
||||
Ja. CloudCLI stellt die Umgebung bereit, nicht die KI. Du bringst dein eigenes Claude-, Cursor-, Codex- oder Gemini-Abonnement mit. CloudCLI Cloud beginnt bei $7/Monat für die gehostete Umgebung zusätzlich dazu.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ CloudCLI UI は、CloudCLI Cloud を支えるオープンソースの UI レイ
|
||||
| **REST API** | はい | はい |
|
||||
| **n8n ノード** | いいえ | はい |
|
||||
| **チーム共有** | いいえ | はい |
|
||||
| **料金プラン** | 無料(オープンソース) | 月 €7〜 |
|
||||
| **料金プラン** | 無料(オープンソース) | 月 $7〜 |
|
||||
|
||||
> どちらの選択肢でも、AI のサブスクリプション(Claude、Cursor など)はご自身のものを使用します — CloudCLI が提供するのは環境であり、AI そのものではありません。
|
||||
|
||||
@@ -194,7 +194,7 @@ CloudCLI UI と CloudCLI Cloud は、Claude Code の横に別物として存在
|
||||
<details>
|
||||
<summary>AI のサブスクリプションは別途支払いが必要ですか?</summary>
|
||||
|
||||
はい。CloudCLI は環境を提供するものであり、AI は含まれません。Claude、Cursor、Codex、または Gemini のサブスクリプションはご自身でご用意ください。CloudCLI Cloud のホスティング環境はそれに加えて月額 €7 から提供されます。
|
||||
はい。CloudCLI は環境を提供するものであり、AI は含まれません。Claude、Cursor、Codex、または Gemini のサブスクリプションはご自身でご用意ください。CloudCLI Cloud のホスティング環境はそれに加えて月額 $7 から提供されます。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ CloudCLI UI는 CloudCLI Cloud를 구동하는 오픈 소스 UI 계층입니다.
|
||||
| **REST API** | 예 | 예 |
|
||||
| **n8n 노드** | 아니오 | 예 |
|
||||
| **팀 공유** | 아니오 | 예 |
|
||||
| **플랫폼 비용** | 무료, 오픈 소스 | 월 €7부터 |
|
||||
| **플랫폼 비용** | 무료, 오픈 소스 | 월 $7부터 |
|
||||
|
||||
> 둘 다 자체 AI 구독(Claude, Cursor 등)을 그대로 사용합니다 — CloudCLI는 환경만 제공합니다.
|
||||
|
||||
@@ -195,7 +195,7 @@ CloudCLI UI와 CloudCLI Cloud는 Claude Code를 확장하며 별도로 존재하
|
||||
<details>
|
||||
<summary>AI 구독을 별도로 결제해야 하나요?</summary>
|
||||
|
||||
네. CloudCLI는 환경만 제공합니다. Claude, Cursor, Codex, Gemini 구독 비용은 별도로 부과됩니다. CloudCLI Cloud는 관리형 환경을 월 €7부터 제공합니다.
|
||||
네. CloudCLI는 환경만 제공합니다. Claude, Cursor, Codex, Gemini 구독 비용은 별도로 부과됩니다. CloudCLI Cloud는 관리형 환경을 월 $7부터 제공합니다.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self
|
||||
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
|
||||
| **REST API** | Yes | Yes | Yes |
|
||||
| **Team sharing** | No | No | Yes |
|
||||
| **Platform cost** | Free, open source | Free, open source | Starts at €7/month |
|
||||
| **Platform cost** | Free, open source | Free, open source | Starts at $7/month |
|
||||
|
||||
> All options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI.
|
||||
|
||||
@@ -212,7 +212,7 @@ Here's what that means in practice:
|
||||
<details>
|
||||
<summary>Do I need to pay for an AI subscription separately?</summary>
|
||||
|
||||
Yes. CloudCLI provides the environment, not the AI. You bring your own Claude, Cursor, Codex, or Gemini subscription. CloudCLI Cloud starts at €7/month for the hosted environment on top of that.
|
||||
Yes. CloudCLI provides the environment, not the AI. You bring your own Claude, Cursor, Codex, or Gemini subscription. CloudCLI Cloud starts at $7/month for the hosted environment on top of that.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ CloudCLI UI — это open source UI-слой, на котором постро
|
||||
| **REST API** | Да | Да |
|
||||
| **n8n node** | Нет | Да |
|
||||
| **Совместная работа** | Нет | Да |
|
||||
| **Стоимость платформы** | Бесплатно, open source | От €7/месяц |
|
||||
| **Стоимость платформы** | Бесплатно, open source | От $7/месяц |
|
||||
|
||||
> В обоих вариантах используются ваши собственные AI-подписки (Claude, Cursor и т.д.) — CloudCLI предоставляет среду, а не сам AI.
|
||||
|
||||
@@ -202,7 +202,7 @@ CloudCLI UI и CloudCLI Cloud расширяют Claude Code, а не работ
|
||||
<details>
|
||||
<summary>Нужно ли отдельно платить за AI-подписку?</summary>
|
||||
|
||||
Да. CloudCLI предоставляет среду, а не сам AI. Вы приносите свою подписку Claude, Cursor, Codex или Gemini. CloudCLI Cloud начинается от €7/месяц за хостируемую среду поверх этого.
|
||||
Да. CloudCLI предоставляет среду, а не сам AI. Вы приносите свою подписку Claude, Cursor, Codex или Gemini. CloudCLI Cloud начинается от $7/месяц за хостируемую среду поверх этого.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ CloudCLI UI, CloudCLI Cloud'u güçlendiren açık kaynak arayüz katmanıdır.
|
||||
| **MCP yapılandırması** | `~/.claude` ile senkron | UI üzerinden yönetilir | UI üzerinden yönetilir |
|
||||
| **REST API** | Evet | Evet | Evet |
|
||||
| **Ekip paylaşımı** | Hayır | Hayır | Evet |
|
||||
| **Platform maliyeti** | Ücretsiz, açık kaynak | Ücretsiz, açık kaynak | Aylık 7 €'dan başlar |
|
||||
| **Platform maliyeti** | Ücretsiz, açık kaynak | Ücretsiz, açık kaynak | Aylık 7 $'dan başlar |
|
||||
|
||||
> Tüm seçenekler kendi AI aboneliklerini (Claude, Cursor, vb.) kullanır — CloudCLI AI'ı değil, ortamı sağlar.
|
||||
|
||||
@@ -201,7 +201,7 @@ Pratikte bu ne demek:
|
||||
<details>
|
||||
<summary>AI aboneliği için ayrıca ödeme yapmam gerekiyor mu?</summary>
|
||||
|
||||
Evet. CloudCLI AI'yi değil, ortamı sağlar. Kendi Claude, Cursor, Codex veya Gemini aboneliğini getirirsin. CloudCLI Cloud, barındırılan ortam için aylık 7 €'dan başlar — bunun üzerine eklenir.
|
||||
Evet. CloudCLI AI'yi değil, ortamı sağlar. Kendi Claude, Cursor, Codex veya Gemini aboneliğini getirirsin. CloudCLI Cloud, barındırılan ortam için aylık 7 $'dan başlar — bunun üzerine eklenir.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ CloudCLI UI 是 CloudCLI Cloud 的开源 UI 层。你可以在本地机器上自
|
||||
| **REST API** | 是 | 是 |
|
||||
| **n8n 节点** | 否 | 是 |
|
||||
| **团队共享** | 否 | 是 |
|
||||
| **平台费用** | 免费开源 | 起价 €7/月 |
|
||||
| **平台费用** | 免费开源 | 起价 $7/月 |
|
||||
|
||||
> 两种方式都使用你自己的 AI 订阅(Claude、Cursor 等)— CloudCLI 提供环境,而非 AI。
|
||||
|
||||
@@ -195,7 +195,7 @@ CloudCLI UI 与 CloudCLI Cloud 是对 Claude Code 的扩展,而非旁观 — M
|
||||
<details>
|
||||
<summary>需要额外购买 AI 订阅吗?</summary>
|
||||
|
||||
需要。CloudCLI 只提供环境。你仍需自行获取 Claude、Cursor、Codex 或 Gemini 订阅。CloudCLI Cloud 从 €7/月起提供托管环境。
|
||||
需要。CloudCLI 只提供环境。你仍需自行获取 Claude、Cursor、Codex 或 Gemini 订阅。CloudCLI Cloud 从 $7/月起提供托管环境。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ CloudCLI UI 是 CloudCLI Cloud 的開源 UI 層。你可以在本機上自架它
|
||||
| **REST API** | 是 | 是 |
|
||||
| **n8n 節點** | 否 | 是 |
|
||||
| **團隊共享** | 否 | 是 |
|
||||
| **平台費用** | 免費開源 | 起價 €7/月 |
|
||||
| **平台費用** | 免費開源 | 起價 $7/月 |
|
||||
|
||||
> 兩種方式都使用你自己的 AI 訂閱(Claude、Cursor 等)— CloudCLI 提供環境,而非 AI。
|
||||
|
||||
@@ -195,7 +195,7 @@ CloudCLI UI 與 CloudCLI Cloud 是對 Claude Code 的擴充,而非旁觀 — M
|
||||
<details>
|
||||
<summary>需要額外購買 AI 訂閱嗎?</summary>
|
||||
|
||||
需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 €7/月起提供託管環境。
|
||||
需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 $7/月起提供託管環境。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -161,7 +161,6 @@ export default tseslint.config(
|
||||
"server/shared/utils.{js,ts}",
|
||||
"server/shared/frontmatter.ts",
|
||||
"server/shared/claude-cli-path.ts",
|
||||
"server/shared/image-attachments.ts",
|
||||
], // classify shared utility files so modules can depend on them explicitly
|
||||
mode: "file",
|
||||
},
|
||||
|
||||
23
package-lock.json
generated
23
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.36.0",
|
||||
"version": "1.35.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.36.0",
|
||||
"version": "1.35.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
@@ -80,8 +80,6 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cross-spawn": "^6.0.6",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^22.19.7",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
@@ -5996,29 +5994,12 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mime-types": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz",
|
||||
"integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.36.0",
|
||||
"version": "1.35.1",
|
||||
"productName": "CloudCLI",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
@@ -203,8 +203,6 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cross-spawn": "^6.0.6",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^22.19.7",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
|
||||
@@ -19,7 +19,6 @@ import path from 'path';
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
import { buildClaudeUserContent, normalizeImageDescriptors } from './shared/image-attachments.js';
|
||||
import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
|
||||
@@ -237,13 +236,16 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
* Adds a session to the active sessions map
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @param {Object} queryInstance - SDK query instance
|
||||
* @param {Object} writer - WebSocket writer for reconnect support
|
||||
* @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
|
||||
* @param {string} tempDir - Temp directory for cleanup
|
||||
*/
|
||||
function addSession(sessionId, queryInstance, writer = null) {
|
||||
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {
|
||||
activeSessions.set(sessionId, {
|
||||
instance: queryInstance,
|
||||
startTime: Date.now(),
|
||||
status: 'active',
|
||||
tempImagePaths,
|
||||
tempDir,
|
||||
writer
|
||||
});
|
||||
}
|
||||
@@ -362,35 +364,90 @@ function extractTokenBudget(sdkMessage) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the SDK `prompt` payload for one turn.
|
||||
*
|
||||
* Plain text turns pass the string through unchanged. Turns with image
|
||||
* attachments use the SDK's streaming-input mode: a single SDKUserMessage
|
||||
* whose content carries the prompt text plus one base64 `image` block per
|
||||
* attachment (read from the global `~/.cloudcli/assets` folder).
|
||||
*
|
||||
* @param {string} command - User prompt
|
||||
* @param {Array} images - Image descriptors ({ path, name?, mimeType? })
|
||||
* @param {string} cwd - Project working directory image paths resolve against
|
||||
* @returns {Promise<string|AsyncIterable>} SDK prompt payload
|
||||
* Handles image processing for SDK queries
|
||||
* Saves base64 images to temporary files and returns modified prompt with file paths
|
||||
* @param {string} command - Original user prompt
|
||||
* @param {Array} images - Array of image objects with base64 data
|
||||
* @param {string} cwd - Working directory for temp file creation
|
||||
* @returns {Promise<Object>} {modifiedCommand, tempImagePaths, tempDir}
|
||||
*/
|
||||
async function buildPromptPayload(command, images, cwd) {
|
||||
if (normalizeImageDescriptors(images).length === 0) {
|
||||
return command;
|
||||
async function handleImages(command, images, cwd) {
|
||||
const tempImagePaths = [];
|
||||
let tempDir = null;
|
||||
|
||||
if (!images || images.length === 0) {
|
||||
return { modifiedCommand: command, tempImagePaths, tempDir };
|
||||
}
|
||||
|
||||
const content = await buildClaudeUserContent(command, images, cwd);
|
||||
return (async function* () {
|
||||
yield {
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
})();
|
||||
try {
|
||||
// Create temp directory in the project directory
|
||||
const workingDir = cwd || process.cwd();
|
||||
tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
// Save each image to a temp file
|
||||
for (const [index, image] of images.entries()) {
|
||||
// Extract base64 data and mime type
|
||||
const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
console.error('Invalid image data format');
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, mimeType, base64Data] = matches;
|
||||
const extension = mimeType.split('/')[1] || 'png';
|
||||
const filename = `image_${index}.${extension}`;
|
||||
const filepath = path.join(tempDir, filename);
|
||||
|
||||
// Write base64 data to file
|
||||
await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
|
||||
tempImagePaths.push(filepath);
|
||||
}
|
||||
|
||||
// Include the full image paths in the prompt
|
||||
let modifiedCommand = command;
|
||||
if (tempImagePaths.length > 0 && command && command.trim()) {
|
||||
const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
|
||||
modifiedCommand = command + imageNote;
|
||||
}
|
||||
|
||||
// Images processed
|
||||
return { modifiedCommand, tempImagePaths, tempDir };
|
||||
} catch (error) {
|
||||
console.error('Error processing images for SDK:', error);
|
||||
return { modifiedCommand: command, tempImagePaths, tempDir };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up temporary image files
|
||||
* @param {Array<string>} tempImagePaths - Array of temp file paths to delete
|
||||
* @param {string} tempDir - Temp directory to remove
|
||||
*/
|
||||
async function cleanupTempFiles(tempImagePaths, tempDir) {
|
||||
if (!tempImagePaths || tempImagePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete individual temp files
|
||||
for (const imagePath of tempImagePaths) {
|
||||
await fs.unlink(imagePath).catch(err =>
|
||||
console.error(`Failed to delete temp image ${imagePath}:`, err)
|
||||
);
|
||||
}
|
||||
|
||||
// Delete temp directory
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(err =>
|
||||
console.error(`Failed to delete temp directory ${tempDir}:`, err)
|
||||
);
|
||||
}
|
||||
|
||||
// Temp files cleaned
|
||||
} catch (error) {
|
||||
console.error('Error during temp file cleanup:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -461,6 +518,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
const { sessionId, sessionSummary } = options;
|
||||
let capturedSessionId = sessionId;
|
||||
let sessionCreatedSent = false;
|
||||
let tempImagePaths = [];
|
||||
let tempDir = null;
|
||||
|
||||
const emitNotification = (event) => {
|
||||
notifyUserIfEnabled({
|
||||
@@ -494,10 +553,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
sdkOptions.mcpServers = mcpServers;
|
||||
}
|
||||
|
||||
// Turns with image attachments switch to streaming input so the images
|
||||
// ride along as real content blocks. Built per query attempt because an
|
||||
// async generator cannot be replayed once consumed.
|
||||
const createPrompt = () => buildPromptPayload(command, options.images, options.cwd);
|
||||
const imageResult = await handleImages(command, options.images, options.cwd);
|
||||
const finalCommand = imageResult.modifiedCommand;
|
||||
tempImagePaths = imageResult.tempImagePaths;
|
||||
tempDir = imageResult.tempDir;
|
||||
|
||||
sdkOptions.hooks = {
|
||||
Notification: [{
|
||||
@@ -604,7 +663,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
let queryInstance;
|
||||
try {
|
||||
queryInstance = query({
|
||||
prompt: await createPrompt(),
|
||||
prompt: finalCommand,
|
||||
options: sdkOptions
|
||||
});
|
||||
} catch (hookError) {
|
||||
@@ -613,7 +672,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
|
||||
delete sdkOptions.hooks;
|
||||
queryInstance = query({
|
||||
prompt: await createPrompt(),
|
||||
prompt: finalCommand,
|
||||
options: sdkOptions
|
||||
});
|
||||
}
|
||||
@@ -627,7 +686,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
|
||||
// Track the query instance for abort capability
|
||||
if (capturedSessionId) {
|
||||
addSession(capturedSessionId, queryInstance, ws);
|
||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
|
||||
}
|
||||
|
||||
// Process streaming messages
|
||||
@@ -637,7 +696,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
if (message.session_id && !capturedSessionId) {
|
||||
|
||||
capturedSessionId = message.session_id;
|
||||
addSession(capturedSessionId, queryInstance, ws);
|
||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
|
||||
|
||||
// Set session ID on writer
|
||||
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||
@@ -679,6 +738,9 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
removeSession(capturedSessionId);
|
||||
}
|
||||
|
||||
// Clean up temporary image files
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
// Send the terminal completion event — skipped for aborted runs, whose
|
||||
// terminal `complete` (aborted: true) was already sent by abort-session.
|
||||
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
|
||||
@@ -702,6 +764,9 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
removeSession(capturedSessionId);
|
||||
}
|
||||
|
||||
// Clean up temporary image files on error
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
|
||||
if (wasAborted) {
|
||||
// The abort already produced the terminal complete; a generator throw
|
||||
@@ -754,6 +819,9 @@ async function abortClaudeSDKSession(sessionId) {
|
||||
// Update session status
|
||||
session.status = 'aborted';
|
||||
|
||||
// Clean up temporary image files
|
||||
await cleanupTempFiles(session.tempImagePaths, session.tempDir);
|
||||
|
||||
// Clean up session
|
||||
removeSession(sessionId);
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { spawn } from 'child_process';
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
import { appendImagesInputTag } from './shared/image-attachments.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell } from './shared/utils.js';
|
||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to
|
||||
// child_process.spawn everywhere else.
|
||||
const spawnFunction = crossSpawn;
|
||||
// Use cross-spawn on Windows for better command execution
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
|
||||
let activeCursorProcesses = new Map(); // Track active processes by session ID
|
||||
|
||||
@@ -30,7 +28,7 @@ function isWorkspaceTrustPrompt(text = '') {
|
||||
|
||||
async function spawnCursor(command, options = {}, ws) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary, images } = options;
|
||||
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary } = options;
|
||||
const resolvedModel = await providerModelsService.resolveResumeModel('cursor', sessionId, model);
|
||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
@@ -57,12 +55,8 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
}
|
||||
|
||||
if (command && command.trim()) {
|
||||
// Provide a prompt (works for both new and resumed sessions). Image
|
||||
// attachments ride along as an <images_input> path list appended to the
|
||||
// prompt; the session history reader strips the tag back out for display.
|
||||
// cursor-agent is a .cmd shim on Windows, so the whole argument must be
|
||||
// newline-free or cmd.exe silently truncates it at the first newline.
|
||||
baseArgs.push('-p', flattenPromptForWindowsShell(appendImagesInputTag(command, images)));
|
||||
// Provide a prompt (works for both new and resumed sessions)
|
||||
baseArgs.push('-p', command);
|
||||
|
||||
// Model overrides are applied to both new and resumed sessions so a
|
||||
// session-scoped change request can take effect on the next turn.
|
||||
@@ -77,6 +71,7 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
// Add skip permissions flag if enabled
|
||||
if (skipPermissions || settings.skipPermissions) {
|
||||
baseArgs.push('-f');
|
||||
console.log('Using -f flag (skip permissions)');
|
||||
}
|
||||
|
||||
// Use cwd (actual project directory) instead of projectPath
|
||||
@@ -131,6 +126,10 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
|
||||
}
|
||||
|
||||
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
|
||||
console.log('Working directory:', workingDir);
|
||||
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
||||
|
||||
const cursorProcess = spawnFunction('cursor-agent', args, {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
@@ -6,15 +7,13 @@ import crossSpawn from 'cross-spawn';
|
||||
|
||||
import sessionManager from './sessionManager.js';
|
||||
import GeminiResponseHandler from './gemini-response-handler.js';
|
||||
import { appendImagesInputTag } from './shared/image-attachments.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell } from './shared/utils.js';
|
||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to
|
||||
// child_process.spawn everywhere else.
|
||||
const spawnFunction = crossSpawn;
|
||||
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
|
||||
let activeGeminiProcesses = new Map(); // Track active processes by session ID
|
||||
|
||||
@@ -144,17 +143,9 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
// Build Gemini CLI command - start with print/resume flags first
|
||||
const args = [];
|
||||
|
||||
// Image attachments ride along as an <images_input> path list appended to
|
||||
// the prompt; session history readers strip the tag back out for display.
|
||||
// gemini is a .cmd shim on Windows, so the whole argument must be
|
||||
// newline-free or cmd.exe silently truncates it at the first newline.
|
||||
const finalCommand = command && command.trim()
|
||||
? flattenPromptForWindowsShell(appendImagesInputTag(command, images))
|
||||
: command;
|
||||
|
||||
// Add prompt flag with command if we have a command
|
||||
if (finalCommand && finalCommand.trim()) {
|
||||
args.push('--prompt', finalCommand);
|
||||
if (command && command.trim()) {
|
||||
args.push('--prompt', command);
|
||||
}
|
||||
|
||||
// If we have a sessionId, we want to resume
|
||||
@@ -170,6 +161,53 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
const cleanPath = (cwd || projectPath || process.cwd()).replace(/[^\x20-\x7E]/g, '').trim();
|
||||
const workingDir = cleanPath;
|
||||
|
||||
// Handle images by saving them to temporary files and passing paths to Gemini
|
||||
const tempImagePaths = [];
|
||||
let tempDir = null;
|
||||
if (images && images.length > 0) {
|
||||
try {
|
||||
// Create temp directory in the project directory so Gemini can access it
|
||||
tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
// Save each image to a temp file
|
||||
for (const [index, image] of images.entries()) {
|
||||
// Extract base64 data and mime type
|
||||
const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, mimeType, base64Data] = matches;
|
||||
const extension = mimeType.split('/')[1] || 'png';
|
||||
const filename = `image_${index}.${extension}`;
|
||||
const filepath = path.join(tempDir, filename);
|
||||
|
||||
// Write base64 data to file
|
||||
await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
|
||||
tempImagePaths.push(filepath);
|
||||
}
|
||||
|
||||
// Include the full image paths in the prompt for Gemini to reference
|
||||
// Gemini CLI can read images from file paths in the prompt
|
||||
if (tempImagePaths.length > 0 && command && command.trim()) {
|
||||
const imageNote = `\n\n[Images given: ${tempImagePaths.length} images are located at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
|
||||
const modifiedCommand = command + imageNote;
|
||||
|
||||
// Update the command in args
|
||||
const promptIndex = args.indexOf('--prompt');
|
||||
if (promptIndex !== -1 && args[promptIndex + 1] === command) {
|
||||
args[promptIndex + 1] = modifiedCommand;
|
||||
} else if (promptIndex !== -1) {
|
||||
// If we're using context, update the full prompt
|
||||
args[promptIndex + 1] = args[promptIndex + 1] + imageNote;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing images for Gemini:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Add basic flags for Gemini
|
||||
if (options.debug) {
|
||||
args.push('--debug');
|
||||
@@ -284,6 +322,10 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
});
|
||||
};
|
||||
|
||||
// Attach temp file info to process for cleanup later
|
||||
geminiProcess.tempImagePaths = tempImagePaths;
|
||||
geminiProcess.tempDir = tempDir;
|
||||
|
||||
// Store process reference for potential abort
|
||||
const processKey = capturedSessionId || sessionId || Date.now().toString();
|
||||
activeGeminiProcesses.set(processKey, geminiProcess);
|
||||
@@ -454,6 +496,16 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: finalSessionId, exitCode: code }));
|
||||
}
|
||||
|
||||
// Clean up temporary image files if any
|
||||
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
|
||||
for (const imagePath of geminiProcess.tempImagePaths) {
|
||||
await fs.unlink(imagePath).catch(err => { });
|
||||
}
|
||||
if (geminiProcess.tempDir) {
|
||||
await fs.rm(geminiProcess.tempDir, { recursive: true, force: true }).catch(err => { });
|
||||
}
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
notifyTerminalState({ code });
|
||||
resolve();
|
||||
|
||||
@@ -5,10 +5,8 @@ import fs, { promises as fsPromises } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import http from 'http';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
// cross-spawn is a drop-in for child_process.spawn that resolves .cmd
|
||||
// shims/PATHEXT on Windows and delegates to the native spawn elsewhere.
|
||||
import spawn from 'cross-spawn';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import mime from 'mime-types';
|
||||
@@ -66,7 +64,6 @@ import pluginsRoutes from './routes/plugins.js';
|
||||
import providerRoutes from './modules/providers/provider.routes.js';
|
||||
import voiceRoutes from './voice-proxy.js';
|
||||
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
|
||||
import { assetsRoutes } from './modules/assets/index.js';
|
||||
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
|
||||
import { browserUseService } from './modules/browser-use/browser-use.service.js';
|
||||
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
||||
@@ -188,9 +185,6 @@ app.use('/api/auth', authRoutes);
|
||||
// Projects API Routes (protected)
|
||||
app.use('/api/projects', authenticateToken, projectModuleRoutes);
|
||||
|
||||
// Chat image asset upload/serving (global ~/.cloudcli/assets store, protected)
|
||||
app.use('/api/assets', authenticateToken, assetsRoutes);
|
||||
|
||||
// Git API Routes (protected)
|
||||
app.use('/api/git', authenticateToken, gitRoutes);
|
||||
|
||||
@@ -1078,8 +1072,92 @@ const uploadFilesHandler = async (req, res) => {
|
||||
|
||||
app.post('/api/projects/:projectId/files/upload', authenticateToken, uploadFilesHandler);
|
||||
|
||||
// Chat image uploads moved to POST /api/assets/images (server/modules/assets),
|
||||
// which stores them in the global ~/.cloudcli/assets folder.
|
||||
// Image upload endpoint. Accepts the DB-assigned `projectId` (not a folder name)
|
||||
// but the current implementation doesn't need to touch the project directory,
|
||||
// so we just leave the param rename for consistency with the rest of the API.
|
||||
app.post('/api/projects/:projectId/upload-images', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const multer = (await import('multer')).default;
|
||||
const path = (await import('path')).default;
|
||||
const fs = (await import('fs')).promises;
|
||||
const os = (await import('os')).default;
|
||||
|
||||
// Configure multer for image uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id));
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||
cb(null, uniqueSuffix + '-' + sanitizedName);
|
||||
}
|
||||
});
|
||||
|
||||
const fileFilter = (req, file, cb) => {
|
||||
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||
if (allowedMimes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
|
||||
}
|
||||
};
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB
|
||||
files: 5
|
||||
}
|
||||
});
|
||||
|
||||
// Handle multipart form data
|
||||
upload.array('images', 5)(req, res, async (err) => {
|
||||
if (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({ error: 'No image files provided' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Process uploaded images
|
||||
const processedImages = await Promise.all(
|
||||
req.files.map(async (file) => {
|
||||
// Read file and convert to base64
|
||||
const buffer = await fs.readFile(file.path);
|
||||
const base64 = buffer.toString('base64');
|
||||
const mimeType = file.mimetype;
|
||||
|
||||
// Clean up temp file immediately
|
||||
await fs.unlink(file.path);
|
||||
|
||||
return {
|
||||
name: file.originalname,
|
||||
data: `data:${mimeType};base64,${base64}`,
|
||||
size: file.size,
|
||||
mimeType: mimeType
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json({ images: processedImages });
|
||||
} catch (error) {
|
||||
console.error('Error processing images:', error);
|
||||
// Clean up any remaining files
|
||||
await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { })));
|
||||
res.status(500).json({ error: 'Failed to process images' });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in image upload endpoint:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get token usage for a specific session. `projectId` is the DB primary key;
|
||||
// the Claude branch below resolves it to an absolute path via the DB.
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import fsSync, { promises as fs } from 'node:fs';
|
||||
|
||||
import express from 'express';
|
||||
import mime from 'mime-types';
|
||||
import multer from 'multer';
|
||||
|
||||
import {
|
||||
buildStoredImageRecords,
|
||||
ensureImageAssetsDir,
|
||||
isAllowedImageMimeType,
|
||||
resolveImageAssetFile,
|
||||
} from '@/modules/assets/services/image-assets.service.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Multer writes uploads straight into the global assets folder; the service
|
||||
// owns the folder location and the response record shape.
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
ensureImageAssetsDir()
|
||||
.then((assetsDir) => cb(null, assetsDir))
|
||||
.catch((error) => cb(error as Error, ''));
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||
cb(null, `${uniqueSuffix}-${sanitizedName}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (isAllowedImageMimeType(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
|
||||
}
|
||||
},
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB
|
||||
files: 5,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Stores chat image attachments in the global `~/.cloudcli/assets` folder and
|
||||
* returns their absolute paths for use in provider prompts and chat history.
|
||||
*/
|
||||
router.post('/images', (req, res) => {
|
||||
upload.array('images', 5)(req, res, (err: unknown) => {
|
||||
if (err) {
|
||||
const message = err instanceof Error ? err.message : 'Upload failed';
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
|
||||
const files = Array.isArray(req.files) ? req.files : [];
|
||||
if (files.length === 0) {
|
||||
return res.status(400).json({ error: 'No image files provided' });
|
||||
}
|
||||
|
||||
res.json({ images: buildStoredImageRecords(files) });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Serves one stored image asset by filename. Only files directly inside the
|
||||
* global assets folder are reachable; traversal attempts resolve to null.
|
||||
*/
|
||||
router.get('/images/:filename', async (req, res) => {
|
||||
const resolved = resolveImageAssetFile(req.params.filename);
|
||||
if (!resolved) {
|
||||
return res.status(400).json({ error: 'Invalid asset filename' });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(resolved);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'Asset not found' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', mime.lookup(resolved) || 'application/octet-stream');
|
||||
const fileStream = fsSync.createReadStream(resolved);
|
||||
fileStream.pipe(res);
|
||||
fileStream.on('error', (error) => {
|
||||
console.error('Error streaming image asset:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Error reading asset' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Express router mounted at /api/assets by server/index.js (upload + serving
|
||||
// of chat image attachments stored in the global ~/.cloudcli/assets folder).
|
||||
export { default as assetsRoutes } from './assets.routes.js';
|
||||
@@ -1,82 +0,0 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getGlobalImageAssetsDir, toPosixPath } from '@/shared/image-attachments.js';
|
||||
|
||||
/**
|
||||
* Image mime types accepted for chat attachment uploads. SVG is allowed for
|
||||
* storage/preview even though some providers (Claude API) skip it at send time.
|
||||
*/
|
||||
const ALLOWED_IMAGE_MIME_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
]);
|
||||
|
||||
// Used only by this service and the assets routes via the barrel file.
|
||||
type StoredImageAsset = {
|
||||
/** Original upload filename, for display. */
|
||||
name: string;
|
||||
/** Absolute posix-normalized path inside the global assets folder. */
|
||||
path: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
// Shape of one multer-stored file; kept local because only this module reads it.
|
||||
type UploadedImageFile = {
|
||||
originalname: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
mimetype: string;
|
||||
};
|
||||
|
||||
/** Returns whether one uploaded mime type may be stored as a chat image asset. */
|
||||
export function isAllowedImageMimeType(mimeType: string): boolean {
|
||||
return ALLOWED_IMAGE_MIME_TYPES.has(mimeType);
|
||||
}
|
||||
|
||||
/** Creates the global `~/.cloudcli/assets` folder if needed and returns it. */
|
||||
export async function ensureImageAssetsDir(): Promise<string> {
|
||||
const assetsDir = getGlobalImageAssetsDir();
|
||||
await fs.mkdir(assetsDir, { recursive: true });
|
||||
return assetsDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps multer-stored upload files to the attachment records returned to the
|
||||
* chat composer. The absolute path is what providers receive and what session
|
||||
* history carries back to the UI.
|
||||
*/
|
||||
export function buildStoredImageRecords(files: UploadedImageFile[]): StoredImageAsset[] {
|
||||
const assetsDir = getGlobalImageAssetsDir();
|
||||
return files.map((file) => ({
|
||||
name: file.originalname,
|
||||
path: toPosixPath(path.join(assetsDir, file.filename)),
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one asset filename to its absolute path inside the global assets
|
||||
* folder, or null when the name is empty, contains path separators/traversal,
|
||||
* or would escape the folder. This is the only lookup the serving route uses,
|
||||
* so nothing outside `~/.cloudcli/assets` can ever be read through it.
|
||||
*/
|
||||
export function resolveImageAssetFile(filename: string): string | null {
|
||||
const trimmed = typeof filename === 'string' ? filename.trim() : '';
|
||||
if (!trimmed || trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('..')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assetsDir = path.resolve(getGlobalImageAssetsDir());
|
||||
const resolved = path.resolve(assetsDir, trimmed);
|
||||
if (!resolved.startsWith(assetsDir + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildStoredImageRecords,
|
||||
isAllowedImageMimeType,
|
||||
resolveImageAssetFile,
|
||||
} from '@/modules/assets/services/image-assets.service.js';
|
||||
|
||||
const ASSETS_DIR = path.join(os.homedir(), '.cloudcli', 'assets');
|
||||
|
||||
test('isAllowedImageMimeType accepts image formats and rejects the rest', () => {
|
||||
assert.equal(isAllowedImageMimeType('image/png'), true);
|
||||
assert.equal(isAllowedImageMimeType('image/svg+xml'), true);
|
||||
assert.equal(isAllowedImageMimeType('application/pdf'), false);
|
||||
assert.equal(isAllowedImageMimeType('text/html'), false);
|
||||
});
|
||||
|
||||
test('buildStoredImageRecords returns absolute posix paths in the assets dir', () => {
|
||||
const records = buildStoredImageRecords([
|
||||
{ originalname: 'shot.png', filename: '123-456-shot.png', size: 42, mimetype: 'image/png' },
|
||||
]);
|
||||
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].name, 'shot.png');
|
||||
assert.equal(records[0].size, 42);
|
||||
assert.equal(records[0].mimeType, 'image/png');
|
||||
assert.equal(records[0].path, `${ASSETS_DIR.replace(/\\/g, '/')}/123-456-shot.png`);
|
||||
});
|
||||
|
||||
test('resolveImageAssetFile resolves plain filenames inside the assets dir', () => {
|
||||
const resolved = resolveImageAssetFile('123-shot.png');
|
||||
assert.equal(resolved, path.join(path.resolve(ASSETS_DIR), '123-shot.png'));
|
||||
});
|
||||
|
||||
test('resolveImageAssetFile rejects traversal and separator attempts', () => {
|
||||
assert.equal(resolveImageAssetFile(''), null);
|
||||
assert.equal(resolveImageAssetFile(' '), null);
|
||||
assert.equal(resolveImageAssetFile('../auth.db'), null);
|
||||
assert.equal(resolveImageAssetFile('..'), null);
|
||||
assert.equal(resolveImageAssetFile('sub/dir.png'), null);
|
||||
assert.equal(resolveImageAssetFile('sub\\dir.png'), null);
|
||||
assert.equal(resolveImageAssetFile('a..b/../c.png'), null);
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution.
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import { appConfigDb } from '@/modules/database/index.js';
|
||||
import { providerMcpService } from '@/modules/providers/index.js';
|
||||
import { getModuleDir } from '@/utils/runtime-paths.js';
|
||||
@@ -272,10 +270,8 @@ function runCommand(command: string, args: string[]): Promise<void> {
|
||||
}, INSTALL_COMMAND_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
|
||||
// stdio config above guarantees the pipes exist; cross-spawn's types
|
||||
// just don't narrow them the way node's spawn overloads do.
|
||||
child.stdout?.on('data', (chunk) => output.push(String(chunk)));
|
||||
child.stderr?.on('data', (chunk) => output.push(String(chunk)));
|
||||
child.stdout.on('data', (chunk) => output.push(String(chunk)));
|
||||
child.stderr.on('data', (chunk) => output.push(String(chunk)));
|
||||
child.on('error', (error) => finish(() => reject(error)));
|
||||
child.on('close', (code) => finish(() => {
|
||||
if (code === 0) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { access, mkdir, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution.
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import { githubTokensDb } from '@/modules/database/index.js';
|
||||
import { createProject } from '@/modules/projects/services/project-management.service.js';
|
||||
import type { WorkspacePathValidationResult } from '@/shared/types.js';
|
||||
|
||||
@@ -313,18 +313,6 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
|
||||
if (raw.message?.role === 'user' && raw.message?.content && raw.isMeta !== true) {
|
||||
if (Array.isArray(raw.message.content)) {
|
||||
// Image attachments sent through the SDK are persisted as base64
|
||||
// `image` blocks next to the prompt text. Collect them so the UI can
|
||||
// render them on the user bubble.
|
||||
const imageAttachments: Array<{ data: string }> = [];
|
||||
for (const part of raw.message.content) {
|
||||
if (part?.type === 'image' && part.source?.type === 'base64' && typeof part.source.data === 'string') {
|
||||
const mediaType = typeof part.source.media_type === 'string' ? part.source.media_type : 'image/png';
|
||||
imageAttachments.push({ data: `data:${mediaType};base64,${part.source.data}` });
|
||||
}
|
||||
}
|
||||
let imagesAttached = false;
|
||||
|
||||
for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) {
|
||||
const part = raw.message.content[partIndex];
|
||||
if (part.type === 'tool_result') {
|
||||
@@ -351,9 +339,7 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: text,
|
||||
images: !imagesAttached && imageAttachments.length > 0 ? imageAttachments : undefined,
|
||||
}));
|
||||
imagesAttached = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,25 +359,9 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: textParts,
|
||||
images: imageAttachments.length > 0 ? imageAttachments : undefined,
|
||||
}));
|
||||
imagesAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Image-only turns still deserve a user bubble even without text.
|
||||
if (!imagesAttached && imageAttachments.length > 0) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_images`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: '',
|
||||
images: imageAttachments,
|
||||
}));
|
||||
}
|
||||
} else if (typeof raw.message.content === 'string') {
|
||||
const text = raw.message.content;
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import fsSync from 'node:fs';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import { toImageAttachments } from '@/shared/image-attachments.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||
@@ -32,42 +31,6 @@ function isVisibleCodexUserMessage(payload: AnyRecord | null | undefined): boole
|
||||
return typeof payload.message === 'string' && payload.message.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the image attachments Codex records on `user_message` events.
|
||||
* Turns sent with `local_image` input items land in `local_images` as file
|
||||
* paths (verified against real rollout JSONL); the `images` array can carry
|
||||
* base64 data URLs, which are passed through as inline `data` attachments so
|
||||
* the UI can preview them without a file lookup.
|
||||
*
|
||||
* Exported for tests.
|
||||
*/
|
||||
export function extractCodexUserImages(
|
||||
payload: AnyRecord | null | undefined,
|
||||
): Array<{ path?: string; data?: string }> | undefined {
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
...(Array.isArray(payload.local_images) ? payload.local_images : []),
|
||||
...(Array.isArray(payload.images) ? payload.images : []),
|
||||
];
|
||||
|
||||
const attachments: Array<{ path?: string; data?: string }> = [];
|
||||
for (const entry of candidates) {
|
||||
if (typeof entry !== 'string' || !entry.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.startsWith('data:')) {
|
||||
attachments.push({ data: entry });
|
||||
} else {
|
||||
attachments.push(...toImageAttachments([entry]));
|
||||
}
|
||||
}
|
||||
|
||||
return attachments.length > 0 ? attachments : undefined;
|
||||
}
|
||||
|
||||
function extractCodexTextContent(content: unknown): string {
|
||||
if (!Array.isArray(content)) {
|
||||
return typeof content === 'string' ? content : '';
|
||||
@@ -141,7 +104,6 @@ async function getCodexSessionMessages(
|
||||
role: 'user',
|
||||
content: entry.payload.message,
|
||||
},
|
||||
images: extractCodexUserImages(entry.payload as AnyRecord),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -334,8 +296,7 @@ export class CodexSessionsProvider implements IProviderSessions {
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: String(raw.message.content || '');
|
||||
const rawImages = Array.isArray(raw.images) && raw.images.length > 0 ? raw.images : undefined;
|
||||
if (!content.trim() && !rawImages) {
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
@@ -346,7 +307,6 @@ export class CodexSessionsProvider implements IProviderSessions {
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content,
|
||||
images: rawImages,
|
||||
})];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { access, readdir } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
@@ -588,9 +589,7 @@ type CursorModelRow = {
|
||||
|
||||
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
|
||||
const CURSOR_CHATS_ROOT = path.join(os.homedir(), '.cursor', 'chats');
|
||||
// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to
|
||||
// child_process.spawn everywhere else.
|
||||
const spawnFunction = crossSpawn;
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
const ANSI_PATTERN = new RegExp(
|
||||
// eslint-disable-next-line no-control-regex
|
||||
'[\\u001B\\u009B][[\\]()#;?]*(?:'
|
||||
|
||||
@@ -141,13 +141,7 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
}
|
||||
|
||||
const text = typeof data.message?.content?.[0]?.text === 'string' ? data.message.content[0].text : '';
|
||||
// Drop Cursor's `<timestamp>…</timestamp>` prefix and `<user_query>` tags
|
||||
// so the session name comes from the actual first line the user typed.
|
||||
const firstLine = text
|
||||
.replace(/<timestamp>[\s\S]*?<\/timestamp>/g, '')
|
||||
.replace(/<\/?user_query>/g, '')
|
||||
.trim()
|
||||
.split('\n')[0];
|
||||
const firstLine = text.replace(/<\/?user_query>/g, '').trim().split('\n')[0];
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
|
||||
@@ -2,7 +2,6 @@ import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { parseImagesInputTag } from '@/shared/image-attachments.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import {
|
||||
@@ -25,7 +24,7 @@ type CursorJsonBlob = CursorDbBlob & {
|
||||
parsed: AnyRecord;
|
||||
};
|
||||
|
||||
export type CursorMessageBlob = {
|
||||
type CursorMessageBlob = {
|
||||
id: string;
|
||||
sequence: number;
|
||||
rowid: number;
|
||||
@@ -60,42 +59,17 @@ function unwrapUserQueryText(value: string, role: 'user' | 'assistant'): string
|
||||
return value;
|
||||
}
|
||||
|
||||
// Cursor wraps user turns as `<timestamp>…</timestamp>\n<user_query>…</user_query>`.
|
||||
// Show only the `<user_query>` content, trimmed so there are no blank lines
|
||||
// at the top/bottom and the `<timestamp>` prefix is dropped entirely.
|
||||
const normalized = value.trimStart();
|
||||
const openTag = '<user_query>';
|
||||
const closeTag = '</user_query>';
|
||||
const openIndex = value.indexOf(openTag);
|
||||
if (openIndex >= 0) {
|
||||
const afterOpen = value.slice(openIndex + openTag.length);
|
||||
const closeIndex = afterOpen.lastIndexOf(closeTag);
|
||||
const inner = closeIndex >= 0 ? afterOpen.slice(0, closeIndex) : afterOpen;
|
||||
return inner.trim();
|
||||
if (!normalized.startsWith(openTag)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// No `<user_query>` wrapper: still strip a leading `<timestamp>…</timestamp>`.
|
||||
return value.replace(/^\s*<timestamp>[\s\S]*?<\/timestamp>\s*/, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps one user-authored text payload and splits off the `<images_input>`
|
||||
* attachment block appended by the chat composer. Assistant text passes
|
||||
* through untouched.
|
||||
*/
|
||||
function extractUserTextAndImages(
|
||||
value: string,
|
||||
role: 'user' | 'assistant',
|
||||
): { text: string; images?: Array<{ path: string; name?: string }> } {
|
||||
const unwrapped = unwrapUserQueryText(value, role);
|
||||
if (role !== 'user') {
|
||||
return { text: unwrapped };
|
||||
}
|
||||
|
||||
const { text, attachments } = parseImagesInputTag(unwrapped);
|
||||
return {
|
||||
text,
|
||||
images: attachments.length > 0 ? attachments : undefined,
|
||||
};
|
||||
const afterOpen = normalized.slice(openTag.length);
|
||||
const closeIndex = afterOpen.lastIndexOf(closeTag);
|
||||
const inner = closeIndex >= 0 ? afterOpen.slice(0, closeIndex) : afterOpen;
|
||||
return inner.trim();
|
||||
}
|
||||
|
||||
function normalizeToolId(value: unknown): string | null {
|
||||
@@ -427,11 +401,8 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
/**
|
||||
* Converts Cursor SQLite message blobs into normalized messages and attaches
|
||||
* matching tool results to their tool_use entries.
|
||||
*
|
||||
* Public so tests can drive history normalization with synthetic blobs
|
||||
* without needing a real Cursor store.db.
|
||||
*/
|
||||
normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] {
|
||||
private normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] {
|
||||
const messages: NormalizedMessage[] = [];
|
||||
const toolUseMap = new Map<string, NormalizedMessage>();
|
||||
const baseTime = Date.now();
|
||||
@@ -471,16 +442,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
text = unwrapUserQueryText(content.message.content, role);
|
||||
}
|
||||
}
|
||||
const { text: cleanText, images } = role === 'user'
|
||||
? (() => {
|
||||
const parsed = parseImagesInputTag(text);
|
||||
return {
|
||||
text: parsed.text,
|
||||
images: parsed.attachments.length > 0 ? parsed.attachments : undefined,
|
||||
};
|
||||
})()
|
||||
: { text, images: undefined };
|
||||
if (cleanText?.trim() || images) {
|
||||
if (text?.trim()) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
@@ -488,8 +450,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: cleanText,
|
||||
images,
|
||||
content: text,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
@@ -541,8 +502,8 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
|
||||
if (part?.type === 'text' && part?.text) {
|
||||
const { text: normalizedPartText, images } = extractUserTextAndImages(part.text, role);
|
||||
if (!normalizedPartText && !images) {
|
||||
const normalizedPartText = unwrapUserQueryText(part.text, role);
|
||||
if (!normalizedPartText) {
|
||||
continue;
|
||||
}
|
||||
messages.push(createNormalizedMessage({
|
||||
@@ -553,7 +514,6 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
kind: 'text',
|
||||
role,
|
||||
content: normalizedPartText,
|
||||
images,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
@@ -593,8 +553,8 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
&& content.content.trim()
|
||||
&& !isInternalCursorText(content.content)
|
||||
) {
|
||||
const { text: normalizedText, images } = extractUserTextAndImages(content.content, role);
|
||||
if (!normalizedText && !images) {
|
||||
const normalizedText = unwrapUserQueryText(content.content, role);
|
||||
if (!normalizedText) {
|
||||
continue;
|
||||
}
|
||||
messages.push(createNormalizedMessage({
|
||||
@@ -605,7 +565,6 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
kind: 'text',
|
||||
role,
|
||||
content: normalizedText,
|
||||
images,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
|
||||
@@ -3,7 +3,6 @@ import fs from 'node:fs/promises';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import { parseImagesInputTag } from '@/shared/image-attachments.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||
@@ -129,8 +128,7 @@ async function getGeminiLegacySessionMessages(sessionFilePath: string): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
// Exported for tests: parses one Gemini CLI JSONL transcript from disk.
|
||||
export async function getGeminiJsonlSessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
|
||||
async function getGeminiJsonlSessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
|
||||
const messages: AnyRecord[] = [];
|
||||
let tokenUsage: AnyRecord | undefined;
|
||||
|
||||
@@ -161,18 +159,12 @@ export async function getGeminiJsonlSessionMessages(sessionFilePath: string): Pr
|
||||
|
||||
const role = mapGeminiRole(entry.type);
|
||||
if (role) {
|
||||
const rawTextContent = extractGeminiTextContent(entry.content);
|
||||
// User prompts sent with attachments carry an <images_input> path
|
||||
// list; strip it for display and surface the paths as images.
|
||||
const { text: textContent, attachments } = role === 'user'
|
||||
? parseImagesInputTag(rawTextContent)
|
||||
: { text: rawTextContent, attachments: [] };
|
||||
if (textContent.trim() || attachments.length > 0) {
|
||||
const textContent = extractGeminiTextContent(entry.content);
|
||||
if (textContent.trim()) {
|
||||
messages.push({
|
||||
type: 'message',
|
||||
uuid: typeof entry.id === 'string' ? entry.id : undefined,
|
||||
message: { role, content: textContent },
|
||||
images: attachments.length > 0 ? attachments : undefined,
|
||||
timestamp: entry.timestamp || null,
|
||||
});
|
||||
}
|
||||
@@ -421,9 +413,8 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
|
||||
const role = raw.message?.role || raw.role;
|
||||
const content = raw.message?.content ?? raw.content ?? '';
|
||||
const rawImages = Array.isArray(raw.images) && raw.images.length > 0 ? raw.images : undefined;
|
||||
if (!role || (!content && !rawImages)) {
|
||||
const content = raw.message?.content || raw.content;
|
||||
if (!role || !content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -484,12 +475,7 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (typeof content === 'string' && (content.trim() || rawImages)) {
|
||||
// Legacy (non-JSONL) session files reach this branch with the raw
|
||||
// prompt text, so strip any <images_input> block here as well.
|
||||
const { text: cleanContent, attachments } = normalizedRole === 'user'
|
||||
? parseImagesInputTag(content)
|
||||
: { text: content, attachments: [] };
|
||||
} else if (typeof content === 'string' && content.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
@@ -497,8 +483,7 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content: cleanContent,
|
||||
images: rawImages ?? (attachments.length > 0 ? attachments : undefined),
|
||||
content,
|
||||
}));
|
||||
} else {
|
||||
const textContent = extractGeminiTextContent(content);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
@@ -65,9 +67,7 @@ export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
|
||||
const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000;
|
||||
const MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
|
||||
// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to
|
||||
// child_process.spawn everywhere else.
|
||||
const spawnFunction = crossSpawn;
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
const DATE_TOKEN = /^\d{8}$/;
|
||||
const SIMPLE_NUMBER_TOKEN = /^\d$/;
|
||||
const VERSION_TOKEN = /^[a-z]\d+$/i;
|
||||
|
||||
@@ -2,7 +2,6 @@ import fsSync from 'node:fs';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { parseImagesInputTag } from '@/shared/image-attachments.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import {
|
||||
@@ -419,13 +418,8 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
|
||||
if (partType === 'text') {
|
||||
const rawContent = extractText(partData);
|
||||
// User prompts sent with attachments carry an <images_input> path
|
||||
// list; strip it for display and surface the paths as images.
|
||||
const { text: content, attachments } = messageRole === 'user'
|
||||
? parseImagesInputTag(rawContent)
|
||||
: { text: rawContent, attachments: [] };
|
||||
if (content.trim() || attachments.length > 0) {
|
||||
const content = extractText(partData);
|
||||
if (content.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
@@ -434,7 +428,6 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
|
||||
kind: 'text',
|
||||
role: messageRole === 'user' ? 'user' : 'assistant',
|
||||
content,
|
||||
images: attachments.length > 0 ? attachments : undefined,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
|
||||
@@ -46,7 +46,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
provider: 'cursor',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: true,
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: false,
|
||||
@@ -56,7 +56,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
provider: 'codex',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: true,
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
@@ -66,7 +66,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
provider: 'gemini',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: true,
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
@@ -74,12 +74,9 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
},
|
||||
opencode: {
|
||||
provider: 'opencode',
|
||||
// Mapped by the runtime onto OpenCode's controls: `--agent plan` (plan),
|
||||
// `--auto` (bypassPermissions) and the OPENCODE_PERMISSION env var
|
||||
// (acceptEdits). See resolveOpenCodePermissionOptions in opencode-cli.js.
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
permissionModes: ['default'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: true,
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
|
||||
@@ -9,7 +9,6 @@ import Database from 'better-sqlite3';
|
||||
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
|
||||
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
|
||||
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
|
||||
import { appendImagesInputTag } from '@/shared/image-attachments.js';
|
||||
|
||||
const patchHomeDir = (nextHomeDir: string) => {
|
||||
const original = os.homedir;
|
||||
@@ -322,41 +321,6 @@ test('OpenCode session synchronizer adopts the pending app session before watche
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenCode sessions provider strips <images_input> from user turns and exposes attachments', { concurrency: false }, async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-images-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
|
||||
try {
|
||||
await createOpenCodeDatabase(tempRoot, workspacePath);
|
||||
|
||||
// Rewrite the user text part with the tagged prompt the runtime sends.
|
||||
const taggedPrompt = appendImagesInputTag('Look at this screenshot.', [
|
||||
{ path: 'C:/Users/x/.cloudcli/assets/shot.png' },
|
||||
]);
|
||||
const db = new Database(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db'));
|
||||
try {
|
||||
db.prepare('UPDATE part SET data = ? WHERE id = ?').run(
|
||||
JSON.stringify({ type: 'text', text: taggedPrompt }),
|
||||
'part-user-text',
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
const provider = new OpenCodeSessionsProvider();
|
||||
const history = await provider.fetchHistory('open-session-1');
|
||||
const userMessage = history.messages.find((message) => message.kind === 'text' && message.role === 'user');
|
||||
|
||||
assert.equal(userMessage?.content, 'Look at this screenshot.');
|
||||
assert.deepEqual(userMessage?.images, [{ path: 'C:/Users/x/.cloudcli/assets/shot.png' }]);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
|
||||
const provider = new OpenCodeSessionsProvider();
|
||||
const normalized = provider.normalizeMessage({
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
|
||||
import { CodexSessionsProvider, extractCodexUserImages } from '@/modules/providers/list/codex/codex-sessions.provider.js';
|
||||
import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js';
|
||||
import { getGeminiJsonlSessionMessages } from '@/modules/providers/list/gemini/gemini-sessions.provider.js';
|
||||
import { appendImagesInputTag } from '@/shared/image-attachments.js';
|
||||
|
||||
const SESSION_ID = 'session-1';
|
||||
|
||||
// ---------------------------------------------------------------- Claude
|
||||
|
||||
test('claude history: base64 image blocks surface as user message images', () => {
|
||||
const provider = new ClaudeSessionsProvider();
|
||||
const entry = {
|
||||
uuid: 'u1',
|
||||
timestamp: '2026-07-03T10:00:00.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'What is in this screenshot?' },
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'QUJD' } },
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'REVG' } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const messages = provider.normalizeMessage(entry, SESSION_ID);
|
||||
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].kind, 'text');
|
||||
assert.equal(messages[0].role, 'user');
|
||||
assert.equal(messages[0].content, 'What is in this screenshot?');
|
||||
assert.deepEqual(messages[0].images, [
|
||||
{ data: 'data:image/png;base64,QUJD' },
|
||||
{ data: 'data:image/jpeg;base64,REVG' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('claude history: image-only user turns still produce a bubble', () => {
|
||||
const provider = new ClaudeSessionsProvider();
|
||||
const entry = {
|
||||
uuid: 'u2',
|
||||
timestamp: '2026-07-03T10:00:00.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'QUJD' } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const messages = provider.normalizeMessage(entry, SESSION_ID);
|
||||
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].role, 'user');
|
||||
assert.equal(messages[0].content, '');
|
||||
assert.deepEqual(messages[0].images, [{ data: 'data:image/png;base64,QUJD' }]);
|
||||
});
|
||||
|
||||
test('claude history: plain text user turns carry no images field', () => {
|
||||
const provider = new ClaudeSessionsProvider();
|
||||
const entry = {
|
||||
uuid: 'u3',
|
||||
timestamp: '2026-07-03T10:00:00.000Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
||||
};
|
||||
|
||||
const messages = provider.normalizeMessage(entry, SESSION_ID);
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].images, undefined);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Codex
|
||||
|
||||
test('codex history: user_message payload images become path attachments', () => {
|
||||
// Real rollout shape: local_image input items land in `local_images`,
|
||||
// while `images` stays an empty array.
|
||||
assert.deepEqual(
|
||||
extractCodexUserImages({
|
||||
type: 'user_message',
|
||||
message: 'can u see attached image?',
|
||||
images: [],
|
||||
local_images: ['C:\\proj\\.cloudcli\\assets\\a.png'],
|
||||
}),
|
||||
[{ path: 'C:/proj/.cloudcli/assets/a.png' }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
extractCodexUserImages({ type: 'user_message', message: 'hi', images: ['/proj/b.jpg'] }),
|
||||
[{ path: '/proj/b.jpg' }],
|
||||
);
|
||||
assert.equal(extractCodexUserImages({ type: 'user_message', message: 'hi' }), undefined);
|
||||
assert.equal(extractCodexUserImages({ type: 'user_message', message: 'hi', images: [], local_images: [] }), undefined);
|
||||
});
|
||||
|
||||
test('codex history: base64 data URLs pass through as inline data attachments', () => {
|
||||
const dataUrl = 'data:image/png;base64,QUJD';
|
||||
assert.deepEqual(
|
||||
extractCodexUserImages({
|
||||
type: 'user_message',
|
||||
message: 'look',
|
||||
images: [dataUrl],
|
||||
local_images: ['C:\\proj\\a.png'],
|
||||
}),
|
||||
[{ path: 'C:/proj/a.png' }, { data: dataUrl }],
|
||||
);
|
||||
});
|
||||
|
||||
test('codex history: normalized user entries keep their images', () => {
|
||||
const provider = new CodexSessionsProvider();
|
||||
const messages = provider.normalizeMessage(
|
||||
{
|
||||
timestamp: '2026-07-03T10:00:00.000Z',
|
||||
message: { role: 'user', content: 'Look at this' },
|
||||
images: [{ path: '.cloudcli/assets/a.png' }],
|
||||
},
|
||||
SESSION_ID,
|
||||
);
|
||||
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].role, 'user');
|
||||
assert.equal(messages[0].content, 'Look at this');
|
||||
assert.deepEqual(messages[0].images, [{ path: '.cloudcli/assets/a.png' }]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Gemini
|
||||
|
||||
test('gemini history: <images_input> tag is stripped and paths attached', async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'gemini-image-history-'));
|
||||
const jsonlPath = path.join(tempDir, 'session.jsonl');
|
||||
try {
|
||||
const taggedPrompt = appendImagesInputTag('Compare these designs', [
|
||||
{ path: '.cloudcli/assets/1-a.png' },
|
||||
{ path: '.cloudcli/assets/2-b.png' },
|
||||
]);
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'user', id: 'm1', content: taggedPrompt, timestamp: '2026-07-03T10:00:00.000Z' }),
|
||||
JSON.stringify({ type: 'gemini', id: 'm2', content: 'They differ in spacing.', timestamp: '2026-07-03T10:00:05.000Z' }),
|
||||
];
|
||||
await writeFile(jsonlPath, `${lines.join('\n')}\n`, 'utf8');
|
||||
|
||||
const { messages } = await getGeminiJsonlSessionMessages(jsonlPath);
|
||||
|
||||
assert.equal(messages.length, 2);
|
||||
assert.equal(messages[0].message.role, 'user');
|
||||
assert.equal(messages[0].message.content, 'Compare these designs');
|
||||
assert.deepEqual(messages[0].images, [
|
||||
{ path: '.cloudcli/assets/1-a.png' },
|
||||
{ path: '.cloudcli/assets/2-b.png' },
|
||||
]);
|
||||
// Assistant text is left untouched.
|
||||
assert.equal(messages[1].message.role, 'assistant');
|
||||
assert.equal(messages[1].images, undefined);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('gemini history: prompts without a tag are unchanged', async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'gemini-image-history-'));
|
||||
const jsonlPath = path.join(tempDir, 'session.jsonl');
|
||||
try {
|
||||
await writeFile(
|
||||
jsonlPath,
|
||||
`${JSON.stringify({ type: 'user', id: 'm1', content: 'plain prompt', timestamp: '2026-07-03T10:00:00.000Z' })}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const { messages } = await getGeminiJsonlSessionMessages(jsonlPath);
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].message.content, 'plain prompt');
|
||||
assert.equal(messages[0].images, undefined);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Cursor
|
||||
|
||||
test('cursor history: <images_input> inside user_query is stripped and attached', () => {
|
||||
const provider = new CursorSessionsProvider();
|
||||
const taggedPrompt = appendImagesInputTag('Fix the layout bug', [{ path: '.cloudcli/assets/shot.png' }]);
|
||||
const blobs = [
|
||||
{
|
||||
id: 'blob1',
|
||||
sequence: 1,
|
||||
rowid: 1,
|
||||
content: {
|
||||
role: 'user',
|
||||
content: `<timestamp>2026-07-03</timestamp>\n<user_query>${taggedPrompt}</user_query>`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'blob2',
|
||||
sequence: 2,
|
||||
rowid: 2,
|
||||
content: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Done — the flex container was wrong.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const messages = provider.normalizeCursorBlobs(blobs, SESSION_ID);
|
||||
|
||||
assert.equal(messages.length, 2);
|
||||
assert.equal(messages[0].role, 'user');
|
||||
assert.equal(messages[0].content, 'Fix the layout bug');
|
||||
assert.deepEqual(messages[0].images, [{ path: '.cloudcli/assets/shot.png' }]);
|
||||
assert.equal(messages[1].role, 'assistant');
|
||||
assert.equal(messages[1].images, undefined);
|
||||
});
|
||||
|
||||
test('cursor history: user text without a tag keeps existing behavior', () => {
|
||||
const provider = new CursorSessionsProvider();
|
||||
const blobs = [
|
||||
{
|
||||
id: 'blob1',
|
||||
sequence: 1,
|
||||
rowid: 1,
|
||||
content: {
|
||||
role: 'user',
|
||||
content: '<timestamp>2026-07-03</timestamp>\n<user_query>plain question</user_query>',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const messages = provider.normalizeCursorBlobs(blobs, SESSION_ID);
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].content, 'plain question');
|
||||
assert.equal(messages[0].images, undefined);
|
||||
});
|
||||
@@ -14,8 +14,6 @@
|
||||
*/
|
||||
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
|
||||
import { buildCodexInputItems, normalizeImageDescriptors } from './shared/image-attachments.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
@@ -230,7 +228,6 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
projectPath,
|
||||
model,
|
||||
effort,
|
||||
images,
|
||||
permissionMode = 'default'
|
||||
} = options;
|
||||
|
||||
@@ -291,12 +288,7 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
registerSession(capturedSessionId);
|
||||
}
|
||||
|
||||
// Execute with streaming. Turns with image attachments send structured
|
||||
// input items so Codex reads the images from their local asset paths.
|
||||
const turnInput = normalizeImageDescriptors(images).length > 0
|
||||
? buildCodexInputItems(command, images, workingDirectory)
|
||||
: command;
|
||||
const streamedTurn = await thread.runStreamed(turnInput, {
|
||||
const streamedTurn = await thread.runStreamed(command, {
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
|
||||
@@ -1,51 +1,19 @@
|
||||
import { spawn } from 'child_process';
|
||||
import fsSync from 'node:fs';
|
||||
|
||||
import crossSpawn from 'cross-spawn';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { appendImagesInputTag } from './shared/image-attachments.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell, getOpenCodeDatabasePath } from './shared/utils.js';
|
||||
import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
|
||||
|
||||
// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to
|
||||
// child_process.spawn everywhere else.
|
||||
const spawnFunction = crossSpawn;
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
|
||||
const activeOpenCodeProcesses = new Map();
|
||||
|
||||
/**
|
||||
* Maps the UI permission mode onto OpenCode's non-interactive controls.
|
||||
*
|
||||
* OpenCode has no single "permission mode" flag; each mode uses a different
|
||||
* lever of the `opencode run` CLI (verified against v1.17.13):
|
||||
* - plan → the built-in read-only `plan` agent (`--agent plan`).
|
||||
* - bypassPermissions → `--auto`, which auto-approves every permission that
|
||||
* is not explicitly denied in the user's config.
|
||||
* - acceptEdits → the OPENCODE_PERMISSION env var, whose JSON body the
|
||||
* CLI merges into its permission config. Forcing
|
||||
* `edit: allow` guarantees file edits go through while
|
||||
* every other rule stays under the user's own config.
|
||||
* - default → nothing; the user's opencode.json governs. In
|
||||
* non-interactive `run` mode any `ask` rule is denied.
|
||||
*
|
||||
* Exported for tests only.
|
||||
*/
|
||||
export function resolveOpenCodePermissionOptions(permissionMode) {
|
||||
switch (permissionMode) {
|
||||
case 'plan':
|
||||
return { args: ['--agent', 'plan'], env: {} };
|
||||
case 'bypassPermissions':
|
||||
return { args: ['--auto'], env: {} };
|
||||
case 'acceptEdits':
|
||||
return { args: [], env: { OPENCODE_PERMISSION: JSON.stringify({ edit: 'allow' }) } };
|
||||
default:
|
||||
return { args: [], env: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOpenCodeEffort(model, effort, modelsDefinition) {
|
||||
const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model);
|
||||
const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || [];
|
||||
@@ -124,7 +92,7 @@ function readOpenCodeTokenUsage(sessionId) {
|
||||
|
||||
async function spawnOpenCode(command, options = {}, ws) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { sessionId, projectPath, cwd, model, effort, sessionSummary, images, permissionMode } = options;
|
||||
const { sessionId, projectPath, cwd, model, effort, sessionSummary } = options;
|
||||
const workingDir = cwd || projectPath || process.cwd();
|
||||
const processKey = sessionId || Date.now().toString();
|
||||
let capturedSessionId = sessionId || null;
|
||||
@@ -255,20 +223,14 @@ async function spawnOpenCode(command, options = {}, ws) {
|
||||
if (resolvedEffort) {
|
||||
args.push('--variant', resolvedEffort);
|
||||
}
|
||||
const permissionOptions = resolveOpenCodePermissionOptions(permissionMode);
|
||||
args.push(...permissionOptions.args);
|
||||
if (command && command.trim()) {
|
||||
// Image attachments ride along as an <images_input> path list appended
|
||||
// to the prompt; the session history reader strips the tag back out.
|
||||
// opencode is a .cmd shim on Windows, so the whole argument must be
|
||||
// newline-free or cmd.exe silently truncates it at the first newline.
|
||||
args.push(flattenPromptForWindowsShell(appendImagesInputTag(command.trim(), images)));
|
||||
args.push(command.trim());
|
||||
}
|
||||
|
||||
opencodeProcess = spawnFunction('opencode', args, {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...permissionOptions.env },
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
activeOpenCodeProcesses.set(processKey, opencodeProcess);
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveOpenCodePermissionOptions, spawnOpenCode } from './opencode-cli.js';
|
||||
import { spawnOpenCode } from './opencode-cli.js';
|
||||
|
||||
const findEnvKey = (name) =>
|
||||
Object.keys(process.env).find((key) => key.toLowerCase() === name.toLowerCase()) || name;
|
||||
@@ -14,10 +14,7 @@ async function createFakeOpenCodeExecutable(binDir) {
|
||||
await writeFile(scriptPath, `
|
||||
const capturePath = process.env.OPENCODE_ARGS_CAPTURE;
|
||||
if (capturePath) {
|
||||
require('node:fs').writeFileSync(capturePath, JSON.stringify({
|
||||
args: process.argv.slice(2),
|
||||
permissionEnv: process.env.OPENCODE_PERMISSION ?? null,
|
||||
}));
|
||||
require('node:fs').writeFileSync(capturePath, JSON.stringify(process.argv.slice(2)));
|
||||
}
|
||||
|
||||
const events = [
|
||||
@@ -89,116 +86,10 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
|
||||
assert.equal(complete?.sessionId, 'open-live-1');
|
||||
assert.equal(messages.some((message) => message.kind === 'error'), false);
|
||||
|
||||
const capture = JSON.parse(await readFile(argsCapturePath, 'utf8'));
|
||||
const launchedArgs = capture.args;
|
||||
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);
|
||||
// No permission mode requested → no permission flags and no env override.
|
||||
assert.equal(launchedArgs.includes('--auto'), false);
|
||||
assert.equal(launchedArgs.includes('--agent'), false);
|
||||
assert.equal(capture.permissionEnv, null);
|
||||
} finally {
|
||||
if (previousPath === undefined) {
|
||||
delete process.env[pathKey];
|
||||
} else {
|
||||
process.env[pathKey] = previousPath;
|
||||
}
|
||||
|
||||
if (previousPathExt === undefined) {
|
||||
delete process.env[pathExtKey];
|
||||
} else {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveOpenCodePermissionOptions maps UI permission modes onto OpenCode controls', () => {
|
||||
assert.deepEqual(resolveOpenCodePermissionOptions('plan'), {
|
||||
args: ['--agent', 'plan'],
|
||||
env: {},
|
||||
});
|
||||
assert.deepEqual(resolveOpenCodePermissionOptions('bypassPermissions'), {
|
||||
args: ['--auto'],
|
||||
env: {},
|
||||
});
|
||||
assert.deepEqual(resolveOpenCodePermissionOptions('acceptEdits'), {
|
||||
args: [],
|
||||
env: { OPENCODE_PERMISSION: '{"edit":"allow"}' },
|
||||
});
|
||||
// default and anything unknown leave the user's own opencode config in charge.
|
||||
assert.deepEqual(resolveOpenCodePermissionOptions('default'), { args: [], env: {} });
|
||||
assert.deepEqual(resolveOpenCodePermissionOptions(undefined), { args: [], env: {} });
|
||||
});
|
||||
|
||||
test('spawnOpenCode passes permission mode flags and env to the CLI', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-perms-'));
|
||||
const pathKey = findEnvKey('PATH');
|
||||
const pathExtKey = findEnvKey('PATHEXT');
|
||||
const previousPath = process.env[pathKey];
|
||||
const previousPathExt = process.env[pathExtKey];
|
||||
const previousArgsCapture = process.env.OPENCODE_ARGS_CAPTURE;
|
||||
const writer = {
|
||||
userId: null,
|
||||
sessionId: null,
|
||||
send() {},
|
||||
setSessionId(sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await createFakeOpenCodeExecutable(tempRoot);
|
||||
process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`;
|
||||
if (process.platform === 'win32') {
|
||||
process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD')
|
||||
? previousPathExt
|
||||
: `.COM;.EXE;.BAT;.CMD${previousPathExt ? `;${previousPathExt}` : ''}`;
|
||||
}
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
permissionMode: 'plan',
|
||||
expectArgs: ['--agent', 'plan'],
|
||||
expectPermissionEnv: null,
|
||||
},
|
||||
{
|
||||
permissionMode: 'bypassPermissions',
|
||||
expectArgs: ['--auto'],
|
||||
expectPermissionEnv: null,
|
||||
},
|
||||
{
|
||||
permissionMode: 'acceptEdits',
|
||||
expectArgs: [],
|
||||
expectPermissionEnv: '{"edit":"allow"}',
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const argsCapturePath = path.join(tempRoot, `opencode-args-${scenario.permissionMode}.json`);
|
||||
process.env.OPENCODE_ARGS_CAPTURE = argsCapturePath;
|
||||
|
||||
await spawnOpenCode('Hi', { cwd: tempRoot, permissionMode: scenario.permissionMode }, writer);
|
||||
|
||||
const capture = JSON.parse(await readFile(argsCapturePath, 'utf8'));
|
||||
for (const expectedArg of scenario.expectArgs) {
|
||||
assert.ok(
|
||||
capture.args.includes(expectedArg),
|
||||
`${scenario.permissionMode}: expected "${expectedArg}" in ${JSON.stringify(capture.args)}`,
|
||||
);
|
||||
}
|
||||
// The prompt stays the last positional argument, after any permission flags.
|
||||
assert.equal(capture.args[capture.args.length - 1], 'Hi');
|
||||
assert.equal(capture.permissionEnv, scenario.expectPermissionEnv);
|
||||
}
|
||||
} finally {
|
||||
if (previousPath === undefined) {
|
||||
delete process.env[pathKey];
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import express from 'express';
|
||||
// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution.
|
||||
import spawn from 'cross-spawn';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { promises as fs } from 'fs';
|
||||
@@ -1006,8 +1005,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
cwd: finalProjectPath,
|
||||
sessionId: sessionId || null,
|
||||
model: model || opencodeModels.DEFAULT,
|
||||
effort,
|
||||
permissionMode: 'bypassPermissions' // Agent runs are non-interactive, like the other providers above
|
||||
effort
|
||||
}, writer);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import express from 'express';
|
||||
// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution.
|
||||
import spawn from 'cross-spawn';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { projectsDb } from '../modules/database/index.js';
|
||||
@@ -294,76 +293,6 @@ async function resolveRepositoryFilePath(projectPath, filePath) {
|
||||
}
|
||||
|
||||
// Get git status for a project
|
||||
/**
|
||||
* Parses `git status --porcelain=v1 -z` output into the response shape the
|
||||
* git panel consumes. NUL-separated entries carry no path quoting, so names
|
||||
* with spaces/unicode survive intact (the plain porcelain output quotes and
|
||||
* escapes them, which broke the old line-based parser).
|
||||
*
|
||||
* `staged` lists paths with index-side changes. The UI renders its "Staged"
|
||||
* section from this list so it always mirrors the real git index (including
|
||||
* files staged outside the app, e.g. via VSCode or the terminal).
|
||||
*
|
||||
* Exported for tests.
|
||||
*/
|
||||
export function parseGitStatusOutput(statusOutput) {
|
||||
const modified = [];
|
||||
const added = [];
|
||||
const deleted = [];
|
||||
const untracked = [];
|
||||
const staged = [];
|
||||
|
||||
const statusEntries = statusOutput.split('\0');
|
||||
for (let entryIndex = 0; entryIndex < statusEntries.length; entryIndex++) {
|
||||
const entry = statusEntries[entryIndex];
|
||||
if (!entry || entry.length < 4) continue;
|
||||
|
||||
// Porcelain v1: X = index (staged) status, Y = worktree (unstaged) status.
|
||||
const indexStatus = entry[0];
|
||||
const worktreeStatus = entry[1];
|
||||
const file = entry.slice(3);
|
||||
|
||||
// Renames/copies carry the original path as the following NUL entry;
|
||||
// the UI tracks the post-rename path only.
|
||||
if (indexStatus === 'R' || indexStatus === 'C') {
|
||||
entryIndex += 1;
|
||||
}
|
||||
|
||||
if (indexStatus === '?') {
|
||||
untracked.push(file);
|
||||
continue;
|
||||
}
|
||||
if (indexStatus === '!') {
|
||||
continue; // ignored files are never reported
|
||||
}
|
||||
|
||||
const isConflict =
|
||||
indexStatus === 'U' || worktreeStatus === 'U' ||
|
||||
(indexStatus === 'A' && worktreeStatus === 'A') ||
|
||||
(indexStatus === 'D' && worktreeStatus === 'D');
|
||||
if (isConflict) {
|
||||
// Merge conflicts must be resolved in the worktree first; surface them
|
||||
// as modified and never as staged.
|
||||
modified.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indexStatus !== ' ') {
|
||||
staged.push(file);
|
||||
}
|
||||
|
||||
if (indexStatus === 'D' || worktreeStatus === 'D') {
|
||||
deleted.push(file);
|
||||
} else if (indexStatus === 'A' || worktreeStatus === 'A') {
|
||||
added.push(file);
|
||||
} else {
|
||||
modified.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
return { modified, added, deleted, untracked, staged };
|
||||
}
|
||||
|
||||
router.get('/status', async (req, res) => {
|
||||
const { project } = req.query;
|
||||
|
||||
@@ -380,8 +309,30 @@ router.get('/status', async (req, res) => {
|
||||
const branch = await getCurrentBranchName(projectPath);
|
||||
const hasCommits = await repositoryHasCommits(projectPath);
|
||||
|
||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain=v1', '-z'], { cwd: projectPath });
|
||||
const { modified, added, deleted, untracked, staged } = parseGitStatusOutput(statusOutput);
|
||||
// Get git status
|
||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
|
||||
|
||||
const modified = [];
|
||||
const added = [];
|
||||
const deleted = [];
|
||||
const untracked = [];
|
||||
|
||||
statusOutput.split('\n').forEach(line => {
|
||||
if (!line.trim()) return;
|
||||
|
||||
const status = line.substring(0, 2);
|
||||
const file = line.substring(3);
|
||||
|
||||
if (status === 'M ' || status === ' M' || status === 'MM') {
|
||||
modified.push(file);
|
||||
} else if (status === 'A ' || status === 'AM') {
|
||||
added.push(file);
|
||||
} else if (status === 'D ' || status === ' D') {
|
||||
deleted.push(file);
|
||||
} else if (status === '??') {
|
||||
untracked.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
branch,
|
||||
@@ -389,8 +340,7 @@ router.get('/status', async (req, res) => {
|
||||
modified,
|
||||
added,
|
||||
deleted,
|
||||
untracked,
|
||||
staged
|
||||
untracked
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git status error:', error);
|
||||
@@ -643,64 +593,6 @@ router.post('/commit', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Stage files (git add). Mirrors what the UI shows as the "Staged" section,
|
||||
// so the app's staging state and the real git index never drift apart.
|
||||
router.post('/stage', async (req, res) => {
|
||||
const { project, files } = req.body;
|
||||
|
||||
if (!project || !Array.isArray(files) || files.length === 0) {
|
||||
return res.status(400).json({ error: 'Project id and files are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
||||
|
||||
for (const file of files) {
|
||||
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
||||
await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Git stage error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Unstage files (remove from the index, keep the worktree changes)
|
||||
router.post('/unstage', async (req, res) => {
|
||||
const { project, files } = req.body;
|
||||
|
||||
if (!project || !Array.isArray(files) || files.length === 0) {
|
||||
return res.status(400).json({ error: 'Project id and files are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
||||
const hasCommits = await repositoryHasCommits(projectPath);
|
||||
|
||||
for (const file of files) {
|
||||
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
||||
if (hasCommits) {
|
||||
await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
||||
} else {
|
||||
// No HEAD to reset against before the first commit; dropping the
|
||||
// index entry is the only way to unstage while keeping the file.
|
||||
await spawnAsync('git', ['rm', '--cached', '-r', '--force', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Git unstage error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Revert latest local commit (keeps changes staged)
|
||||
router.post('/revert-local-commit', async (req, res) => {
|
||||
const { project } = req.body;
|
||||
@@ -862,57 +754,10 @@ router.post('/delete-branch', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Fields are joined with the ASCII unit separator so pipes (or anything else
|
||||
// typed into a commit subject) cannot break parsing.
|
||||
const GIT_LOG_FIELD_SEPARATOR = '\u001f';
|
||||
const GIT_LOG_PRETTY_FORMAT = '%H%x1f%P%x1f%D%x1f%an%x1f%ae%x1f%ad%x1f%s';
|
||||
|
||||
/**
|
||||
* Parses `git log --shortstat` output produced with GIT_LOG_PRETTY_FORMAT.
|
||||
*
|
||||
* Each commit is one format line (hash, parent hashes, ref decorations,
|
||||
* author, email, date, subject) optionally followed by its `--shortstat`
|
||||
* summary line ("N files changed, ..."). Parents and refs feed the commit
|
||||
* graph rendered by the History view; merge commits carry no shortstat line,
|
||||
* so their `stats` stays empty.
|
||||
*
|
||||
* Exported for tests.
|
||||
*/
|
||||
export function parseGitLogWithStats(stdout) {
|
||||
const commits = [];
|
||||
|
||||
for (const rawLine of stdout.split('\n')) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line.trim()) continue;
|
||||
|
||||
if (line.includes(GIT_LOG_FIELD_SEPARATOR)) {
|
||||
const [hash, parents, refs, author, email, date, ...messageParts] = line.split(GIT_LOG_FIELD_SEPARATOR);
|
||||
commits.push({
|
||||
hash,
|
||||
parents: parents ? parents.split(' ').filter(Boolean) : [],
|
||||
// `%D` decorations, e.g. "HEAD -> main", "origin/main", "tag: v1.0".
|
||||
refs: refs ? refs.split(', ').filter(Boolean) : [],
|
||||
author,
|
||||
email,
|
||||
date,
|
||||
message: messageParts.join(GIT_LOG_FIELD_SEPARATOR),
|
||||
stats: ''
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (commits.length > 0 && /files? changed/.test(line)) {
|
||||
commits[commits.length - 1].stats = line.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return commits;
|
||||
}
|
||||
|
||||
// Get recent commits (across all branches, in graph order)
|
||||
// Get recent commits
|
||||
router.get('/commits', async (req, res) => {
|
||||
const { project, limit = 10 } = req.query;
|
||||
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project id is required' });
|
||||
}
|
||||
@@ -924,28 +769,42 @@ router.get('/commits', async (req, res) => {
|
||||
const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0
|
||||
? Math.min(parsedLimit, 100)
|
||||
: 10;
|
||||
|
||||
// Branches/remotes/tags (not --all, which would drag in refs/stash) with
|
||||
// `--topo-order` guarantee children appear before their parents across
|
||||
// every branch, which the frontend lane-assignment relies on.
|
||||
// `--shortstat` replaces the previous per-commit `git show --stat` calls.
|
||||
|
||||
// Get commit log with stats
|
||||
const { stdout } = await spawnAsync(
|
||||
'git',
|
||||
[
|
||||
'log',
|
||||
'--branches',
|
||||
'--remotes',
|
||||
'--tags',
|
||||
'--topo-order',
|
||||
'--shortstat',
|
||||
`--pretty=format:${GIT_LOG_PRETTY_FORMAT}`,
|
||||
'--date=iso-strict',
|
||||
'-n', String(safeLimit)
|
||||
],
|
||||
['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=iso-strict', '-n', String(safeLimit)],
|
||||
{ cwd: projectPath },
|
||||
);
|
||||
|
||||
res.json({ commits: parseGitLogWithStats(stdout) });
|
||||
|
||||
const commits = stdout
|
||||
.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => {
|
||||
const [hash, author, email, date, ...messageParts] = line.split('|');
|
||||
return {
|
||||
hash,
|
||||
author,
|
||||
email,
|
||||
date,
|
||||
message: messageParts.join('|')
|
||||
};
|
||||
});
|
||||
|
||||
// Get stats for each commit
|
||||
for (const commit of commits) {
|
||||
try {
|
||||
const { stdout: stats } = await spawnAsync(
|
||||
'git', ['show', '--stat', '--format=', commit.hash],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
commit.stats = stats.trim().split('\n').pop(); // Get the summary line
|
||||
} catch (error) {
|
||||
commit.stats = '';
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ commits });
|
||||
} catch (error) {
|
||||
console.error('Git commits error:', error);
|
||||
res.json({ error: error.message });
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseGitLogWithStats, parseGitStatusOutput } from './git.js';
|
||||
|
||||
// Builds `git status --porcelain=v1 -z` output: NUL-separated entries with a
|
||||
// trailing NUL, exactly as git emits it.
|
||||
const porcelain = (...entries) => entries.join('\0') + '\0';
|
||||
|
||||
test('parseGitStatusOutput buckets files and reports index-side staging', () => {
|
||||
const output = porcelain(
|
||||
'M staged-modified.ts',
|
||||
' M unstaged-modified.ts',
|
||||
'MM staged-and-unstaged.ts',
|
||||
'A staged-new.ts',
|
||||
'D staged-deleted.ts',
|
||||
' D unstaged-deleted.ts',
|
||||
'?? untracked.ts',
|
||||
);
|
||||
|
||||
const result = parseGitStatusOutput(output);
|
||||
|
||||
assert.deepEqual(result.modified, ['staged-modified.ts', 'unstaged-modified.ts', 'staged-and-unstaged.ts']);
|
||||
assert.deepEqual(result.added, ['staged-new.ts']);
|
||||
assert.deepEqual(result.deleted, ['staged-deleted.ts', 'unstaged-deleted.ts']);
|
||||
assert.deepEqual(result.untracked, ['untracked.ts']);
|
||||
// Only index-side (X) changes count as staged.
|
||||
assert.deepEqual(result.staged, [
|
||||
'staged-modified.ts',
|
||||
'staged-and-unstaged.ts',
|
||||
'staged-new.ts',
|
||||
'staged-deleted.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseGitStatusOutput keeps paths with spaces intact (-z output has no quoting)', () => {
|
||||
const result = parseGitStatusOutput(porcelain('M src/my folder/some file.ts'));
|
||||
assert.deepEqual(result.modified, ['src/my folder/some file.ts']);
|
||||
assert.deepEqual(result.staged, ['src/my folder/some file.ts']);
|
||||
});
|
||||
|
||||
test('parseGitStatusOutput tracks the post-rename path and skips the original', () => {
|
||||
const output = porcelain('R renamed-to.ts', 'renamed-from.ts', ' M other.ts');
|
||||
const result = parseGitStatusOutput(output);
|
||||
|
||||
assert.deepEqual(result.modified, ['renamed-to.ts', 'other.ts']);
|
||||
assert.deepEqual(result.staged, ['renamed-to.ts']);
|
||||
// The pre-rename path is metadata, not a change entry.
|
||||
assert.equal(JSON.stringify(result).includes('renamed-from.ts'), false);
|
||||
});
|
||||
|
||||
test('parseGitStatusOutput never reports merge conflicts as staged', () => {
|
||||
const output = porcelain('UU conflicted.ts', 'AA both-added.ts', 'DD both-deleted.ts');
|
||||
const result = parseGitStatusOutput(output);
|
||||
|
||||
assert.deepEqual(result.modified, ['conflicted.ts', 'both-added.ts', 'both-deleted.ts']);
|
||||
assert.deepEqual(result.staged, []);
|
||||
});
|
||||
|
||||
test('parseGitStatusOutput handles empty output', () => {
|
||||
assert.deepEqual(parseGitStatusOutput(''), {
|
||||
modified: [],
|
||||
added: [],
|
||||
deleted: [],
|
||||
untracked: [],
|
||||
staged: [],
|
||||
});
|
||||
});
|
||||
|
||||
// Builds one `git log --pretty=format:%H%x1f%P%x1f%D%x1f%an%x1f%ae%x1f%ad%x1f%s` line.
|
||||
const US = '';
|
||||
const logLine = (hash, parents, refs, subject) =>
|
||||
[hash, parents, refs, 'Alice', 'a@x.com', '2026-07-06T10:00:00+03:00', subject].join(US);
|
||||
|
||||
test('parseGitLogWithStats parses commits with parents, refs, and shortstat lines', () => {
|
||||
const output = [
|
||||
logLine('c3', 'c2', 'HEAD -> main, origin/main, tag: v1.0', 'feat: add | pipes | to subject'),
|
||||
' 3 files changed, 10 insertions(+), 2 deletions(-)',
|
||||
'',
|
||||
logLine('c2', 'c1 c0', '', 'Merge branch feature'),
|
||||
'',
|
||||
logLine('c0', '', '', 'initial commit'),
|
||||
' 1 file changed, 1 insertion(+)',
|
||||
].join('\n');
|
||||
|
||||
const commits = parseGitLogWithStats(output);
|
||||
|
||||
assert.equal(commits.length, 3);
|
||||
assert.deepEqual(commits[0].parents, ['c2']);
|
||||
assert.deepEqual(commits[0].refs, ['HEAD -> main', 'origin/main', 'tag: v1.0']);
|
||||
// Pipes in the subject survive because fields are joined with .
|
||||
assert.equal(commits[0].message, 'feat: add | pipes | to subject');
|
||||
assert.equal(commits[0].stats, '3 files changed, 10 insertions(+), 2 deletions(-)');
|
||||
|
||||
// Merge commit: two parents, no shortstat line.
|
||||
assert.deepEqual(commits[1].parents, ['c1', 'c0']);
|
||||
assert.equal(commits[1].stats, '');
|
||||
|
||||
// Root commit: no parents.
|
||||
assert.deepEqual(commits[2].parents, []);
|
||||
assert.equal(commits[2].stats, '1 file changed, 1 insertion(+)');
|
||||
});
|
||||
|
||||
test('parseGitLogWithStats handles empty output', () => {
|
||||
assert.deepEqual(parseGitLogWithStats(''), []);
|
||||
});
|
||||
@@ -12,9 +12,7 @@ import express from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution — required
|
||||
// here since task-master/npx are .cmd shims on Windows.
|
||||
import spawn from 'cross-spawn';
|
||||
import { spawn } from 'child_process';
|
||||
import { projectsDb } from '../modules/database/index.js';
|
||||
import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
|
||||
import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js';
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import express from 'express';
|
||||
// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution.
|
||||
import spawn from 'cross-spawn';
|
||||
import { userDb } from '../modules/database/index.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import { getSystemGitConfig } from '../utils/gitConfig.js';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Shared image-attachment plumbing for every provider runtime.
|
||||
*
|
||||
* Uploaded chat images are persisted once in the global `~/.cloudcli/assets`
|
||||
* folder and referenced by absolute path everywhere else:
|
||||
* - Claude: paths are read back into base64 `image` content blocks.
|
||||
* - Codex: paths become `local_image` input items.
|
||||
* - Gemini/Cursor: paths are appended to the prompt inside an
|
||||
* `<images_input>` tag, which is stripped again when history is read.
|
||||
*
|
||||
* The chat UI loads them through the dedicated `/api/assets/images/:filename`
|
||||
* route, which serves only from this folder.
|
||||
*/
|
||||
|
||||
/** Global storage folder for uploaded chat image attachments. */
|
||||
export function getGlobalImageAssetsDir(): string {
|
||||
return path.join(os.homedir(), '.cloudcli', 'assets');
|
||||
}
|
||||
|
||||
export type ImageAttachmentDescriptor = {
|
||||
/** Project-relative (preferred) or absolute path to the stored image. */
|
||||
path: string;
|
||||
name?: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
/** Media types the Claude Messages API accepts for base64 image blocks. */
|
||||
const CLAUDE_IMAGE_MEDIA_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
const EXTENSION_TO_MEDIA_TYPE: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
/**
|
||||
* Accepts the loosely-typed `options.images` payload from chat.send and
|
||||
* returns only well-formed descriptors. Plain path strings are supported so
|
||||
* callers can also pass bare path arrays.
|
||||
*/
|
||||
export function normalizeImageDescriptors(images: unknown): ImageAttachmentDescriptor[] {
|
||||
if (!Array.isArray(images)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const descriptors: ImageAttachmentDescriptor[] = [];
|
||||
for (const entry of images) {
|
||||
if (typeof entry === 'string' && entry.trim()) {
|
||||
descriptors.push({ path: entry.trim() });
|
||||
continue;
|
||||
}
|
||||
if (entry && typeof entry === 'object') {
|
||||
const record = entry as Record<string, unknown>;
|
||||
const entryPath = typeof record.path === 'string' ? record.path.trim() : '';
|
||||
if (!entryPath) {
|
||||
continue;
|
||||
}
|
||||
descriptors.push({
|
||||
path: entryPath,
|
||||
name: typeof record.name === 'string' ? record.name : undefined,
|
||||
mimeType: typeof record.mimeType === 'string' ? record.mimeType : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
/** Normalizes Windows separators so stored references stay portable. */
|
||||
export function toPosixPath(value: string): string {
|
||||
return value.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/** Resolves a project-relative image path against the run's working directory. */
|
||||
export function resolveImageAbsolutePath(cwd: string | undefined, imagePath: string): string {
|
||||
if (path.isAbsolute(imagePath)) {
|
||||
return imagePath;
|
||||
}
|
||||
return path.resolve(cwd || process.cwd(), imagePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the media type for one image, preferring the uploaded mime type and
|
||||
* falling back to the file extension.
|
||||
*/
|
||||
export function resolveImageMediaType(descriptor: ImageAttachmentDescriptor): string | null {
|
||||
if (descriptor.mimeType) {
|
||||
return descriptor.mimeType;
|
||||
}
|
||||
const extension = path.extname(descriptor.path).toLowerCase();
|
||||
return EXTENSION_TO_MEDIA_TYPE[extension] || null;
|
||||
}
|
||||
|
||||
const IMAGES_INPUT_TAG_PATTERN = /\s*<images_input>([\s\S]*?)<\/images_input>\s*/g;
|
||||
|
||||
// One image reference recovered from an <images_input> block: the stored
|
||||
// asset path plus the user's original filename when it was recorded.
|
||||
export type ParsedImageAttachment = {
|
||||
path: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
// Result of stripping an <images_input> block out of persisted prompt text.
|
||||
// `imagePaths` mirrors `attachments` for callers that only need paths.
|
||||
export type ParsedImagesInput = {
|
||||
text: string;
|
||||
imagePaths: string[];
|
||||
attachments: ParsedImageAttachment[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Appends the `<images_input>` reference block used by the Gemini, Cursor,
|
||||
* and OpenCode CLIs. The block carries one numbered line per attachment with
|
||||
* the stored file path (quote-free on purpose — Windows .cmd shims mangle
|
||||
* quoted text) and the user's original filename, plus an explicit instruction
|
||||
* to read the files and keep the block out of the reply. The same block is
|
||||
* stripped back out of persisted history by {@link parseImagesInputTag}.
|
||||
*/
|
||||
export function appendImagesInputTag(prompt: string, images: unknown): string {
|
||||
const descriptors = normalizeImageDescriptors(images);
|
||||
if (descriptors.length === 0) {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
const entryLines = descriptors.map((descriptor, index) => {
|
||||
const entryPath = toPosixPath(descriptor.path);
|
||||
// Parentheses and newlines would break the "(original name: ...)" suffix
|
||||
// the parser looks for, so drop them from the display name.
|
||||
const cleanName = descriptor.name?.replace(/[()\r\n]/g, '').trim();
|
||||
return cleanName
|
||||
? `${index + 1}. ${entryPath} (original name: ${cleanName})`
|
||||
: `${index + 1}. ${entryPath}`;
|
||||
});
|
||||
|
||||
return [
|
||||
prompt,
|
||||
'',
|
||||
'<images_input>',
|
||||
`The user attached ${descriptors.length} image(s) to this message. Read each file listed below with your file/image reading tool and use what you see to answer the prompt above. Respond as if the images were attached directly. Do not mention this block or the file paths unless the user asks about them.`,
|
||||
...entryLines,
|
||||
'</images_input>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Matches one numbered attachment entry inside the tag body. Works for both
|
||||
// the multi-line block and the Windows-flattened single-line form, where the
|
||||
// next ` N. ` marker (or the end of the body) delimits each entry.
|
||||
const IMAGES_INPUT_ENTRY_PATTERN = /\d+\.\s+(.+?)(?=\s+\d+\.\s+|\s*$)/g;
|
||||
|
||||
const ORIGINAL_NAME_SUFFIX_PATTERN = /\(original name: ([^)]*)\)\s*$/;
|
||||
|
||||
function parseNumberedImageEntries(inner: string): ParsedImageAttachment[] {
|
||||
const attachments: ParsedImageAttachment[] = [];
|
||||
for (const entryMatch of inner.matchAll(IMAGES_INPUT_ENTRY_PATTERN)) {
|
||||
let entryText = entryMatch[1].trim();
|
||||
let name: string | undefined;
|
||||
|
||||
const nameMatch = ORIGINAL_NAME_SUFFIX_PATTERN.exec(entryText);
|
||||
if (nameMatch) {
|
||||
name = nameMatch[1].trim() || undefined;
|
||||
entryText = entryText.slice(0, nameMatch.index).trim();
|
||||
}
|
||||
|
||||
if (entryText) {
|
||||
attachments.push(name ? { path: toPosixPath(entryText), name } : { path: toPosixPath(entryText) });
|
||||
}
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips one `<images_input>` block from persisted prompt text and returns
|
||||
* the clean text plus the referenced attachments (path and original name).
|
||||
*
|
||||
* Only the LAST block in the text is treated as the attachment carrier — the
|
||||
* composer always appends it at the end, so a user who literally typed
|
||||
* `<images_input>` earlier in their prompt keeps that text intact.
|
||||
*
|
||||
* Understands the numbered-line body in both its multi-line and
|
||||
* Windows-flattened single-line forms.
|
||||
*/
|
||||
export function parseImagesInputTag(text: string): ParsedImagesInput {
|
||||
if (typeof text !== 'string' || !text.includes('<images_input>')) {
|
||||
return { text, imagePaths: [], attachments: [] };
|
||||
}
|
||||
|
||||
let lastMatch: RegExpExecArray | null = null;
|
||||
IMAGES_INPUT_TAG_PATTERN.lastIndex = 0;
|
||||
for (let match = IMAGES_INPUT_TAG_PATTERN.exec(text); match; match = IMAGES_INPUT_TAG_PATTERN.exec(text)) {
|
||||
lastMatch = match;
|
||||
}
|
||||
if (!lastMatch) {
|
||||
return { text, imagePaths: [], attachments: [] };
|
||||
}
|
||||
|
||||
const attachments = parseNumberedImageEntries(lastMatch[1]);
|
||||
|
||||
const stripped = (
|
||||
text.slice(0, lastMatch.index) + '\n' + text.slice(lastMatch.index + lastMatch[0].length)
|
||||
).trim();
|
||||
|
||||
return {
|
||||
text: stripped,
|
||||
imagePaths: attachments.map((attachment) => attachment.path),
|
||||
attachments,
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps raw image paths to the attachment shape carried by NormalizedMessage.images. */
|
||||
export function toImageAttachments(imagePaths: string[]): Array<{ path: string }> {
|
||||
return imagePaths.map((imagePath) => ({ path: toPosixPath(imagePath) }));
|
||||
}
|
||||
|
||||
type ClaudeContentBlock =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } };
|
||||
|
||||
/**
|
||||
* Builds the Claude user-message content list: the prompt text followed by one
|
||||
* base64 `image` block per attachment. Images the Claude API cannot accept
|
||||
* (e.g. SVG) or that fail to read are skipped with a warning so the prompt
|
||||
* itself still goes through.
|
||||
*/
|
||||
export async function buildClaudeUserContent(
|
||||
prompt: string,
|
||||
images: unknown,
|
||||
cwd?: string,
|
||||
): Promise<ClaudeContentBlock[]> {
|
||||
const blocks: ClaudeContentBlock[] = [{ type: 'text', text: prompt }];
|
||||
|
||||
for (const descriptor of normalizeImageDescriptors(images)) {
|
||||
const mediaType = resolveImageMediaType(descriptor);
|
||||
if (!mediaType || !CLAUDE_IMAGE_MEDIA_TYPES.has(mediaType)) {
|
||||
console.warn(`[Images] Skipping unsupported Claude image type for ${descriptor.path}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const bytes = await fs.readFile(resolveImageAbsolutePath(cwd, descriptor.path));
|
||||
blocks.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: mediaType,
|
||||
data: bytes.toString('base64'),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[Images] Failed to read image ${descriptor.path}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
type CodexInputItem =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'local_image'; path: string };
|
||||
|
||||
/**
|
||||
* Builds the Codex `runStreamed` input list: prompt text plus one
|
||||
* `local_image` item per attachment, resolved to absolute paths so the Codex
|
||||
* runtime can read them regardless of its own working directory handling.
|
||||
*/
|
||||
export function buildCodexInputItems(prompt: string, images: unknown, cwd?: string): CodexInputItem[] {
|
||||
const items: CodexInputItem[] = [{ type: 'text', text: prompt }];
|
||||
for (const descriptor of normalizeImageDescriptors(images)) {
|
||||
items.push({
|
||||
type: 'local_image',
|
||||
path: resolveImageAbsolutePath(cwd, descriptor.path),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
appendImagesInputTag,
|
||||
buildClaudeUserContent,
|
||||
buildCodexInputItems,
|
||||
normalizeImageDescriptors,
|
||||
parseImagesInputTag,
|
||||
resolveImageMediaType,
|
||||
toImageAttachments,
|
||||
} from '@/shared/image-attachments.js';
|
||||
|
||||
// 1x1 transparent PNG
|
||||
const PNG_BYTES = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
test('normalizeImageDescriptors accepts objects and bare paths, drops junk', () => {
|
||||
const descriptors = normalizeImageDescriptors([
|
||||
{ path: '.cloudcli/assets/a.png', name: 'a.png', mimeType: 'image/png' },
|
||||
'scripts/pic.jpg',
|
||||
{ name: 'no-path.png' },
|
||||
42,
|
||||
null,
|
||||
'',
|
||||
]);
|
||||
|
||||
assert.deepEqual(descriptors, [
|
||||
{ path: '.cloudcli/assets/a.png', name: 'a.png', mimeType: 'image/png' },
|
||||
{ path: 'scripts/pic.jpg' },
|
||||
]);
|
||||
assert.deepEqual(normalizeImageDescriptors(undefined), []);
|
||||
assert.deepEqual(normalizeImageDescriptors('not-an-array'), []);
|
||||
});
|
||||
|
||||
test('appendImagesInputTag and parseImagesInputTag round-trip', () => {
|
||||
const prompt = 'Describe these screenshots.\n\nFocus on the header.';
|
||||
const tagged = appendImagesInputTag(prompt, [
|
||||
{ path: '.cloudcli/assets/1-a.png' },
|
||||
{ path: '.cloudcli\\assets\\2-b.jpg' },
|
||||
]);
|
||||
|
||||
assert.ok(tagged.startsWith(prompt));
|
||||
assert.ok(tagged.includes('<images_input>'));
|
||||
assert.ok(tagged.includes('</images_input>'));
|
||||
assert.ok(tagged.includes('The user attached 2 image(s)'));
|
||||
|
||||
const parsed = parseImagesInputTag(tagged);
|
||||
assert.equal(parsed.text, prompt);
|
||||
// Backslashes are normalized so references stay portable.
|
||||
assert.deepEqual(parsed.imagePaths, ['.cloudcli/assets/1-a.png', '.cloudcli/assets/2-b.jpg']);
|
||||
});
|
||||
|
||||
test('original filenames round-trip through the tag', () => {
|
||||
const tagged = appendImagesInputTag('compare these', [
|
||||
{ path: 'C:/Users/x/.cloudcli/assets/1-a.png', name: 'screenshot (final).png' },
|
||||
{ path: 'C:/Users/x/.cloudcli/assets/2-b.jpg' },
|
||||
]);
|
||||
|
||||
const parsed = parseImagesInputTag(tagged);
|
||||
assert.equal(parsed.text, 'compare these');
|
||||
// Parentheses are dropped from names so the "(original name: ...)" suffix
|
||||
// stays parseable; the path-only entry carries no name.
|
||||
assert.deepEqual(parsed.attachments, [
|
||||
{ path: 'C:/Users/x/.cloudcli/assets/1-a.png', name: 'screenshot final.png' },
|
||||
{ path: 'C:/Users/x/.cloudcli/assets/2-b.jpg' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('only the LAST images_input block is treated as the attachment carrier', () => {
|
||||
const userTypedTag = 'What does <images_input> mean in this codebase?';
|
||||
const tagged = appendImagesInputTag(
|
||||
`${userTypedTag}\n\n<images_input>\nfake user block\n</images_input>\n\nAlso check this.`,
|
||||
[{ path: 'C:/Users/x/.cloudcli/assets/real.png' }],
|
||||
);
|
||||
|
||||
const parsed = parseImagesInputTag(tagged);
|
||||
assert.ok(parsed.text.includes('fake user block'));
|
||||
assert.ok(parsed.text.includes('Also check this.'));
|
||||
assert.deepEqual(parsed.imagePaths, ['C:/Users/x/.cloudcli/assets/real.png']);
|
||||
});
|
||||
|
||||
test('appendImagesInputTag without images returns the prompt untouched', () => {
|
||||
assert.equal(appendImagesInputTag('hello', []), 'hello');
|
||||
assert.equal(appendImagesInputTag('hello', undefined), 'hello');
|
||||
});
|
||||
|
||||
test('parseImagesInputTag handles prompts flattened to one line for cmd.exe shims', () => {
|
||||
// Windows spawn runtimes collapse newlines before passing the argument to
|
||||
// .cmd-shimmed CLIs; the persisted prompt is then a single line.
|
||||
const flattened = appendImagesInputTag('now?', [{ path: 'C:/Users/x/.cloudcli/assets/a.jpg' }])
|
||||
.replace(/\s*\r?\n\s*/g, ' ')
|
||||
.trim();
|
||||
|
||||
assert.ok(!flattened.includes('\n'));
|
||||
const parsed = parseImagesInputTag(flattened);
|
||||
assert.equal(parsed.text, 'now?');
|
||||
assert.deepEqual(parsed.imagePaths, ['C:/Users/x/.cloudcli/assets/a.jpg']);
|
||||
});
|
||||
|
||||
test('parseImagesInputTag leaves text without a tag untouched', () => {
|
||||
const text = 'Just a normal prompt with [brackets] and JSON ["like"] content.';
|
||||
const parsed = parseImagesInputTag(text);
|
||||
assert.equal(parsed.text, text);
|
||||
assert.deepEqual(parsed.imagePaths, []);
|
||||
});
|
||||
|
||||
test('parseImagesInputTag strips a malformed tag body without attaching images', () => {
|
||||
const text = 'prompt\n\n<images_input>\nnot json here\n</images_input>';
|
||||
const parsed = parseImagesInputTag(text);
|
||||
assert.equal(parsed.text, 'prompt');
|
||||
assert.deepEqual(parsed.imagePaths, []);
|
||||
});
|
||||
|
||||
test('toImageAttachments maps paths to posix attachment records', () => {
|
||||
assert.deepEqual(toImageAttachments(['a\\b\\c.png', 'd/e.jpg']), [
|
||||
{ path: 'a/b/c.png' },
|
||||
{ path: 'd/e.jpg' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveImageMediaType prefers the mime type and falls back to the extension', () => {
|
||||
assert.equal(resolveImageMediaType({ path: 'x.bin', mimeType: 'image/webp' }), 'image/webp');
|
||||
assert.equal(resolveImageMediaType({ path: 'x.JPG' }), 'image/jpeg');
|
||||
assert.equal(resolveImageMediaType({ path: 'x.png' }), 'image/png');
|
||||
assert.equal(resolveImageMediaType({ path: 'x.unknown' }), null);
|
||||
});
|
||||
|
||||
test('buildClaudeUserContent reads image bytes into base64 blocks', async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-'));
|
||||
try {
|
||||
await writeFile(path.join(tempDir, 'shot.png'), PNG_BYTES);
|
||||
|
||||
const content = await buildClaudeUserContent(
|
||||
'What is in this image?',
|
||||
[{ path: 'shot.png', mimeType: 'image/png' }],
|
||||
tempDir,
|
||||
);
|
||||
|
||||
assert.equal(content.length, 2);
|
||||
assert.deepEqual(content[0], { type: 'text', text: 'What is in this image?' });
|
||||
assert.equal(content[1].type, 'image');
|
||||
const imageBlock = content[1] as Extract<(typeof content)[number], { type: 'image' }>;
|
||||
assert.equal(imageBlock.source.type, 'base64');
|
||||
assert.equal(imageBlock.source.media_type, 'image/png');
|
||||
assert.equal(imageBlock.source.data, PNG_BYTES.toString('base64'));
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('buildClaudeUserContent skips unsupported types and unreadable files', async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-'));
|
||||
try {
|
||||
await writeFile(path.join(tempDir, 'vector.svg'), '<svg></svg>');
|
||||
|
||||
const content = await buildClaudeUserContent(
|
||||
'prompt',
|
||||
[
|
||||
{ path: 'vector.svg', mimeType: 'image/svg+xml' },
|
||||
{ path: 'missing.png', mimeType: 'image/png' },
|
||||
],
|
||||
tempDir,
|
||||
);
|
||||
|
||||
// Only the text block survives; the prompt still goes through.
|
||||
assert.deepEqual(content, [{ type: 'text', text: 'prompt' }]);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('buildCodexInputItems emits text plus absolute local_image paths', () => {
|
||||
const cwd = path.join(os.tmpdir(), 'codex-project');
|
||||
const items = buildCodexInputItems('Describe this image:', [{ path: '.cloudcli/assets/pic.jpg' }], cwd);
|
||||
|
||||
assert.equal(items.length, 2);
|
||||
assert.deepEqual(items[0], { type: 'text', text: 'Describe this image:' });
|
||||
assert.equal(items[1].type, 'local_image');
|
||||
const imageItem = items[1] as Extract<(typeof items)[number], { type: 'local_image' }>;
|
||||
assert.ok(path.isAbsolute(imageItem.path));
|
||||
assert.equal(imageItem.path, path.resolve(cwd, '.cloudcli/assets/pic.jpg'));
|
||||
});
|
||||
@@ -1239,24 +1239,3 @@ export async function extractFirstValidJsonlData<T>(
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- CLI PROMPT ARGUMENT UTILITIES ------------
|
||||
/**
|
||||
* Makes a prompt safe to pass as one CLI argument to `.cmd`-shimmed tools on
|
||||
* Windows (cursor-agent, gemini, opencode installed via npm-style shims).
|
||||
*
|
||||
* cmd.exe cannot carry newlines inside an argument: everything after the
|
||||
* first newline is silently dropped before the target CLI ever sees it, which
|
||||
* truncates multi-line prompts and any appended `<images_input>` block.
|
||||
* Collapsing newline runs to single spaces loses formatting but never loses
|
||||
* content, so runtimes should call this on win32 right before spawning.
|
||||
*
|
||||
* Used by the cursor, gemini, and opencode spawn runtimes.
|
||||
*/
|
||||
export function flattenPromptForWindowsShell(prompt: string): string {
|
||||
if (process.platform !== 'win32' || typeof prompt !== 'string') {
|
||||
return prompt;
|
||||
}
|
||||
return prompt.replace(/\s*\r?\n\s*/g, ' ').trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution.
|
||||
import spawn from 'cross-spawn';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
function spawnAsync(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution.
|
||||
import spawn from 'cross-spawn';
|
||||
import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';
|
||||
|
||||
// Map<pluginName, { process, port }>
|
||||
|
||||
@@ -145,11 +145,6 @@ const createFakeSubmitEvent = () => {
|
||||
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
|
||||
};
|
||||
|
||||
export type QueuedDraft = {
|
||||
content: string;
|
||||
images: File[];
|
||||
};
|
||||
|
||||
const getNotificationSessionSummary = (
|
||||
selectedSession: ProjectSession | null,
|
||||
fallbackInput: string,
|
||||
@@ -220,18 +215,6 @@ export function useChatComposerState({
|
||||
>(null);
|
||||
const inputValueRef = useRef(input);
|
||||
const selectedProjectId = selectedProject?.projectId;
|
||||
// Prefer the stable backend-allocated id (selectedSession.id) but fall back
|
||||
// to currentSessionId for a just-established session that hasn't been
|
||||
// handed back to the parent's `selectedSession` prop yet.
|
||||
const sessionKey = selectedSession?.id || currentSessionId || null;
|
||||
|
||||
const [queuedDraft, setQueuedDraft] = useState<QueuedDraft | null>(() => {
|
||||
if (typeof window === 'undefined' || !sessionKey) {
|
||||
return null;
|
||||
}
|
||||
const saved = safeLocalStorage.getItem(`queued_message_${sessionKey}`);
|
||||
return saved ? { content: saved, images: [] } : null;
|
||||
});
|
||||
|
||||
const handleBuiltInCommand = useCallback(
|
||||
(result: CommandExecutionResult) => {
|
||||
@@ -572,28 +555,7 @@ export function useChatComposerState({
|
||||
) => {
|
||||
event.preventDefault();
|
||||
const currentInput = inputValueRef.current;
|
||||
if (!currentInput.trim() || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A turn is already in flight: stash this message instead of sending it.
|
||||
// It's auto-flushed (re-running this same function) once the turn ends,
|
||||
// so it still goes through slash-command interception, image upload, etc.
|
||||
if (isLoading) {
|
||||
setQueuedDraft({ content: currentInput, images: attachedImages });
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
setAttachedImages([]);
|
||||
setUploadingImages(new Map());
|
||||
setImageErrors(new Map());
|
||||
resetCommandMenuState();
|
||||
setIsTextareaExpanded(false);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
if (selectedProject) {
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
|
||||
}
|
||||
if (!currentInput.trim() || isLoading || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -642,7 +604,7 @@ export function useChatComposerState({
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/assets/images', {
|
||||
const response = await authenticatedFetch(`/api/projects/${selectedProject.projectId}/upload-images`, {
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: formData,
|
||||
@@ -837,40 +799,6 @@ export function useChatComposerState({
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
|
||||
// Once the in-flight turn ends, replay the queued draft through the normal
|
||||
// submit path (slash commands, image upload, etc. all still apply).
|
||||
const wasLoadingRef = useRef(isLoading);
|
||||
useEffect(() => {
|
||||
const wasLoading = wasLoadingRef.current;
|
||||
wasLoadingRef.current = isLoading;
|
||||
if (!wasLoading || isLoading || !queuedDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
setQueuedDraft(null);
|
||||
setInput(queuedDraft.content);
|
||||
inputValueRef.current = queuedDraft.content;
|
||||
setAttachedImages(queuedDraft.images);
|
||||
setTimeout(() => {
|
||||
handleSubmitRef.current?.(createFakeSubmitEvent());
|
||||
}, 0);
|
||||
}, [isLoading, queuedDraft]);
|
||||
|
||||
const editQueuedDraft = useCallback(() => {
|
||||
if (!queuedDraft) {
|
||||
return;
|
||||
}
|
||||
setQueuedDraft(null);
|
||||
setInput(queuedDraft.content);
|
||||
inputValueRef.current = queuedDraft.content;
|
||||
setAttachedImages(queuedDraft.images);
|
||||
textareaRef.current?.focus();
|
||||
}, [queuedDraft]);
|
||||
|
||||
const deleteQueuedDraft = useCallback(() => {
|
||||
setQueuedDraft(null);
|
||||
}, []);
|
||||
|
||||
// A voice transcript either fills the input (to edit before sending) or, when the
|
||||
// user tapped "stop and send", is submitted straight away. Mirror the value into
|
||||
// inputValueRef synchronously so handleSubmit reads the new text, not the stale state.
|
||||
@@ -909,28 +837,6 @@ export function useChatComposerState({
|
||||
}
|
||||
}, [input, selectedProjectId]);
|
||||
|
||||
// Switching sessions swaps in that session's queued draft (image
|
||||
// attachments can't survive a reload, so only the text is restored).
|
||||
useEffect(() => {
|
||||
if (!sessionKey) {
|
||||
setQueuedDraft(null);
|
||||
return;
|
||||
}
|
||||
const saved = safeLocalStorage.getItem(`queued_message_${sessionKey}`);
|
||||
setQueuedDraft(saved ? { content: saved, images: [] } : null);
|
||||
}, [sessionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
if (queuedDraft?.content) {
|
||||
safeLocalStorage.setItem(`queued_message_${sessionKey}`, queuedDraft.content);
|
||||
} else {
|
||||
safeLocalStorage.removeItem(`queued_message_${sessionKey}`);
|
||||
}
|
||||
}, [queuedDraft, sessionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current) {
|
||||
return;
|
||||
@@ -1138,9 +1044,6 @@ export function useChatComposerState({
|
||||
isDragActive,
|
||||
openImagePicker: open,
|
||||
handleSubmit,
|
||||
queuedDraft,
|
||||
editQueuedDraft,
|
||||
deleteQueuedDraft,
|
||||
handleVoiceTranscript,
|
||||
handleInputChange,
|
||||
handleKeyDown,
|
||||
|
||||
@@ -51,8 +51,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
switch (msg.kind) {
|
||||
case 'text': {
|
||||
const content = msg.content || '';
|
||||
const images = Array.isArray(msg.images) && msg.images.length > 0 ? msg.images : undefined;
|
||||
if (!content.trim() && !images) continue;
|
||||
if (!content.trim()) continue;
|
||||
|
||||
if (msg.role === 'user') {
|
||||
// Parse task notifications
|
||||
@@ -72,7 +71,6 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
type: 'user',
|
||||
content: unescapeWithMathProtection(decodeHtmlEntities(content)),
|
||||
timestamp: msg.timestamp,
|
||||
images,
|
||||
...sharedMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ const FALLBACK_PERMISSION_MODES: Record<LLMProvider, PermissionMode[]> = {
|
||||
cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
codex: ['default', 'acceptEdits', 'bypassPermissions'],
|
||||
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
opencode: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
opencode: ['default'],
|
||||
};
|
||||
|
||||
type ProviderCapabilities = {
|
||||
@@ -451,19 +451,17 @@ export function useChatProviderState({ selectedSession, selectedProject: _select
|
||||
}, [providerEfforts, providerModels, reconcileStoredEffort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSession?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
|
||||
const validModes = getPermissionModesForProvider(provider);
|
||||
const sessionSavedMode = selectedSession?.id
|
||||
? (localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null)
|
||||
: null;
|
||||
// Fall back to the last mode picked for this provider: a brand-new chat
|
||||
// only receives its session id after the first send, so without this the
|
||||
// mode chosen beforehand would snap back to the default as soon as the
|
||||
// session id appears.
|
||||
const providerSavedMode = localStorage.getItem(`permissionMode-last-${provider}`) as PermissionMode | null;
|
||||
const savedMode = [sessionSavedMode, providerSavedMode].find(
|
||||
(mode): mode is PermissionMode => Boolean(mode && validModes.includes(mode)),
|
||||
setPermissionMode(
|
||||
savedMode && validModes.includes(savedMode)
|
||||
? savedMode
|
||||
: getDefaultPermissionModeForProvider(provider),
|
||||
);
|
||||
setPermissionMode(savedMode ?? getDefaultPermissionModeForProvider(provider));
|
||||
}, [selectedSession?.id, provider, getDefaultPermissionModeForProvider, getPermissionModesForProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -513,10 +511,6 @@ export function useChatProviderState({ selectedSession, selectedProject: _select
|
||||
const nextMode = modes[nextIndex];
|
||||
setPermissionMode(nextMode);
|
||||
|
||||
// Persist per provider as well as per session: a brand-new chat has no
|
||||
// session id yet, and the per-provider key keeps the choice sticky when
|
||||
// the real id arrives (and for future sessions of this provider).
|
||||
localStorage.setItem(`permissionMode-last-${provider}`, nextMode);
|
||||
if (selectedSession?.id) {
|
||||
localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
|
||||
}
|
||||
|
||||
@@ -83,9 +83,6 @@ function chatMessageToNormalized(
|
||||
kind: 'text',
|
||||
role: msg.type === 'user' ? 'user' : 'assistant',
|
||||
content: msg.content || '',
|
||||
// Keep attachment references on the local echo so the user bubble shows
|
||||
// its images immediately, before the server-backed copy replaces it.
|
||||
images: Array.isArray(msg.images) && msg.images.length > 0 ? msg.images : undefined,
|
||||
} as NormalizedMessage;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,8 @@ export type Provider = LLMProvider;
|
||||
export type PermissionMode = 'default' | 'acceptEdits' | 'auto' | 'bypassPermissions' | 'plan';
|
||||
|
||||
export interface ChatImage {
|
||||
/** Inline data URL (Claude history stores attachments as base64). */
|
||||
data?: string;
|
||||
/** Project-relative path under `.cloudcli/assets` served via the files API. */
|
||||
path?: string;
|
||||
name?: string;
|
||||
mimeType?: string;
|
||||
data: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
|
||||
@@ -11,7 +11,7 @@ export const safeLocalStorage = {
|
||||
console.warn('localStorage quota exceeded, clearing old data');
|
||||
|
||||
const keys = Object.keys(localStorage);
|
||||
const draftKeys = keys.filter((k) => k.startsWith('draft_input_') || k.startsWith('queued_message_'));
|
||||
const draftKeys = keys.filter((k) => k.startsWith('draft_input_'));
|
||||
draftKeys.forEach((k) => {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
|
||||
@@ -174,9 +174,6 @@ function ChatInterface({
|
||||
isDragActive,
|
||||
openImagePicker,
|
||||
handleSubmit,
|
||||
queuedDraft,
|
||||
editQueuedDraft,
|
||||
deleteQueuedDraft,
|
||||
handleVoiceTranscript,
|
||||
handleInputChange,
|
||||
handleKeyDown,
|
||||
@@ -289,11 +286,6 @@ function ChatInterface({
|
||||
handlePermissionDecision,
|
||||
}), [pendingPermissionRequests, handlePermissionDecision]);
|
||||
|
||||
// Mirrors ChatComposer's own visibility check so the message pane can
|
||||
// reserve enough bottom space to keep the floating status tab from
|
||||
// overlapping the last message.
|
||||
const hasActivityIndicator = Boolean(sessionActivity && pendingPermissionRequests.length === 0);
|
||||
|
||||
if (!selectedProject) {
|
||||
const selectedProviderLabel =
|
||||
provider === 'cursor'
|
||||
@@ -329,7 +321,6 @@ function ChatInterface({
|
||||
onTouchMove={handleScroll}
|
||||
isLoadingSessionMessages={isLoadingSessionMessages}
|
||||
isProcessing={isProcessing}
|
||||
hasActivityIndicator={hasActivityIndicator}
|
||||
chatMessages={chatMessages}
|
||||
selectedSession={selectedSession}
|
||||
currentSessionId={currentSessionId}
|
||||
@@ -408,9 +399,6 @@ function ChatInterface({
|
||||
onClearInput={handleClearInput}
|
||||
onSubmit={handleSubmit}
|
||||
isDragActive={isDragActive}
|
||||
queuedDraft={queuedDraft}
|
||||
onEditQueuedDraft={editQueuedDraft}
|
||||
onDeleteQueuedDraft={deleteQueuedDraft}
|
||||
attachedImages={attachedImages}
|
||||
onRemoveImage={(index) =>
|
||||
setAttachedImages((previous) =>
|
||||
|
||||
@@ -11,11 +11,10 @@ import type {
|
||||
RefObject,
|
||||
TouchEvent,
|
||||
} from 'react';
|
||||
import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ChevronDown, Check, ArrowUpIcon } from 'lucide-react';
|
||||
import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ChevronDown, Check } from 'lucide-react';
|
||||
|
||||
import { useVoiceInput } from '../../hooks/useVoiceInput';
|
||||
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
|
||||
import type { QueuedDraft } from '../../hooks/useChatComposerState';
|
||||
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
||||
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
|
||||
import type { ProviderModelOption } from '../../../../types/app';
|
||||
@@ -36,7 +35,6 @@ import ImageAttachment from './ImageAttachment';
|
||||
import VoiceInputButton from './VoiceInputButton';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
import TokenUsageSummary from './TokenUsageSummary';
|
||||
import QueuedMessageCard from './QueuedMessageCard';
|
||||
|
||||
interface MentionableFile {
|
||||
name: string;
|
||||
@@ -76,9 +74,6 @@ interface ChatComposerProps {
|
||||
onClearInput: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
|
||||
isDragActive: boolean;
|
||||
queuedDraft: QueuedDraft | null;
|
||||
onEditQueuedDraft: () => void;
|
||||
onDeleteQueuedDraft: () => void;
|
||||
attachedImages: File[];
|
||||
onRemoveImage: (index: number) => void;
|
||||
uploadingImages: Map<string, number>;
|
||||
@@ -134,9 +129,6 @@ export default function ChatComposer({
|
||||
onClearInput,
|
||||
onSubmit,
|
||||
isDragActive,
|
||||
queuedDraft,
|
||||
onEditQueuedDraft,
|
||||
onDeleteQueuedDraft,
|
||||
attachedImages,
|
||||
onRemoveImage,
|
||||
uploadingImages,
|
||||
@@ -275,23 +267,6 @@ export default function ChatComposer({
|
||||
const hasPendingPermissions = pendingPermissionRequests.length > 0;
|
||||
const hasActivityIndicator = Boolean(activity && !hasPendingPermissions);
|
||||
|
||||
const hasQueuedDraft = Boolean(queuedDraft);
|
||||
const canQueueDraft = isLoading && Boolean(input.trim());
|
||||
const submitHint = canQueueDraft
|
||||
? hasQueuedDraft
|
||||
? t('input.hintText.updateQueued', { defaultValue: 'Enter to update queued message' })
|
||||
: t('input.hintText.queue', { defaultValue: 'Enter to queue your next message' })
|
||||
: sendByCtrlEnter
|
||||
? t('input.hintText.ctrlEnter')
|
||||
: t('input.hintText.enter');
|
||||
const submitAriaLabel = canQueueDraft
|
||||
? hasQueuedDraft
|
||||
? t('input.queue.update', { defaultValue: 'Update queued message' })
|
||||
: t('input.queue.sendNext', { defaultValue: 'Queue next message' })
|
||||
: isLoading
|
||||
? t('input.stop')
|
||||
: t('input.send');
|
||||
|
||||
return (
|
||||
<div className="chat-composer-shell relative flex-shrink-0 px-2 pb-2 pt-0 sm:px-4 sm:pb-4 md:px-4 md:pb-6">
|
||||
{!hasPendingPermissions && (
|
||||
@@ -310,15 +285,6 @@ export default function ChatComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queuedDraft && (
|
||||
<QueuedMessageCard
|
||||
content={queuedDraft.content}
|
||||
imageCount={queuedDraft.images.length}
|
||||
onEdit={onEditQueuedDraft}
|
||||
onDelete={onDeleteQueuedDraft}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hasQuestionPanel && <div className="relative mx-auto max-w-[54.25rem]">
|
||||
{showFileDropdown && filteredFiles.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
|
||||
@@ -574,37 +540,26 @@ export default function ChatComposer({
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`hidden text-xs text-muted-foreground/50 transition-opacity duration-200 lg:block ${
|
||||
input.trim() && !canQueueDraft ? 'opacity-0' : 'opacity-100'
|
||||
input.trim() ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
{submitHint}
|
||||
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
|
||||
</div>
|
||||
<PromptInputSubmit
|
||||
onClick={
|
||||
canQueueDraft
|
||||
? (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
onSubmit(e);
|
||||
}
|
||||
: isLoading
|
||||
? onAbortSession
|
||||
: isRecording
|
||||
? (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
voiceStop({ send: true });
|
||||
}
|
||||
: undefined
|
||||
isLoading
|
||||
? onAbortSession
|
||||
: isRecording
|
||||
? (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
voiceStop({ send: true });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={isLoading ? false : isRecording ? false : isTranscribing ? true : !input.trim()}
|
||||
aria-label={submitAriaLabel}
|
||||
title={submitAriaLabel}
|
||||
className="h-10 w-10 sm:h-10 sm:w-10"
|
||||
>
|
||||
{isTranscribing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : canQueueDraft ? (
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
) : undefined}
|
||||
{isTranscribing ? <Loader2 className="h-4 w-4 animate-spin" /> : undefined}
|
||||
</PromptInputSubmit>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { authenticatedFetch } from '../../../../utils/api';
|
||||
import type { ChatImage } from '../../types/types';
|
||||
|
||||
type ChatMessageImagesProps = {
|
||||
images: ChatImage[];
|
||||
projectId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves one chat image to a displayable src. Inline data URLs are used
|
||||
* directly; path-based attachments are fetched as blobs (a bare <img src>
|
||||
* cannot carry the auth header) — first from the global assets route
|
||||
* (`~/.cloudcli/assets`), then from the project files route as a fallback for
|
||||
* sessions recorded before attachments moved to the global store.
|
||||
*/
|
||||
function useChatImageSrc(image: ChatImage, projectId?: string | null): { src: string | null; failed: boolean } {
|
||||
const [src, setSrc] = useState<string | null>(image.data || null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (image.data) {
|
||||
setSrc(image.data);
|
||||
setFailed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const imagePath = image.path;
|
||||
if (!imagePath) {
|
||||
setSrc(null);
|
||||
setFailed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = imagePath.split(/[\\/]/).pop() || '';
|
||||
const candidateUrls = [
|
||||
`/api/assets/images/${encodeURIComponent(filename)}`,
|
||||
...(projectId
|
||||
? [`/api/projects/${projectId}/files/content?path=${encodeURIComponent(imagePath)}`]
|
||||
: []),
|
||||
];
|
||||
|
||||
let objectUrl: string | null = null;
|
||||
const controller = new AbortController();
|
||||
|
||||
const load = async () => {
|
||||
setFailed(false);
|
||||
for (const url of candidateUrls) {
|
||||
try {
|
||||
const response = await authenticatedFetch(url, { signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
continue;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setSrc(null);
|
||||
setFailed(true);
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [image.data, image.path, projectId]);
|
||||
|
||||
return { src, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen image overlay in the claude.ai style: dark backdrop, centered
|
||||
* image, closes on backdrop click, close button, or Escape.
|
||||
*/
|
||||
function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close image preview"
|
||||
className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="max-h-[90vh] max-w-[92vw] rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessageImage({ image, projectId }: { image: ChatImage; projectId?: string | null }) {
|
||||
const { src, failed } = useChatImageSrc(image, projectId);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const alt = image.name || 'Attached image';
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="flex h-28 w-28 items-center justify-center rounded-xl border border-border/50 bg-muted px-2 text-center text-[10px] text-muted-foreground">
|
||||
{alt}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
return <div className="h-28 w-28 animate-pulse rounded-xl border border-border/50 bg-muted" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
aria-label={`Expand ${alt}`}
|
||||
className="block overflow-hidden rounded-xl border border-border/50 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="h-28 w-28 cursor-zoom-in object-cover transition-transform duration-200 hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
{expanded && <ImageLightbox src={src} alt={alt} onClose={() => setExpanded(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Image attachments for a user turn, rendered claude.ai-style: standalone
|
||||
* rounded square cards shown above the message bubble. Each thumbnail
|
||||
* expands to a fullscreen lightbox on click.
|
||||
*/
|
||||
export default function ChatMessageImages({ images, projectId }: ChatMessageImagesProps) {
|
||||
if (!images || images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{images.map((image, index) => (
|
||||
<ChatMessageImage key={image.path || image.name || index} image={image} projectId={projectId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,8 +24,6 @@ interface ChatMessagesPaneProps {
|
||||
isLoadingSessionMessages: boolean;
|
||||
/** True while the viewed session has an active provider run in flight. */
|
||||
isProcessing?: boolean;
|
||||
/** True while ChatComposer's floating activity/stop tab is rendered above the input. */
|
||||
hasActivityIndicator?: boolean;
|
||||
chatMessages: ChatMessage[];
|
||||
selectedSession: ProjectSession | null;
|
||||
currentSessionId: string | null;
|
||||
@@ -75,7 +73,6 @@ function ChatMessagesPane({
|
||||
onTouchMove,
|
||||
isLoadingSessionMessages,
|
||||
isProcessing = false,
|
||||
hasActivityIndicator = false,
|
||||
chatMessages,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
@@ -164,9 +161,7 @@ function ChatMessagesPane({
|
||||
ref={scrollContainerRef}
|
||||
onWheel={onWheel}
|
||||
onTouchMove={onTouchMove}
|
||||
className={`chat-messages-pane relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden pt-3 sm:pt-4 ${
|
||||
hasActivityIndicator ? 'pb-12 sm:pb-14' : 'pb-3 sm:pb-4'
|
||||
}`}
|
||||
className="chat-messages-pane relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-3 sm:py-4"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[54.25rem] space-y-3 px-4 sm:space-y-4">
|
||||
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
|
||||
|
||||
@@ -18,16 +18,14 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<div className="overflow-hidden rounded-xl border border-border/50 shadow-sm">
|
||||
<img src={preview} alt={file.name} className="h-20 w-20 object-cover" />
|
||||
</div>
|
||||
<img src={preview} alt={file.name} className="h-20 w-20 rounded object-cover" />
|
||||
{uploadProgress !== undefined && uploadProgress < 100 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/50">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div className="text-xs text-white">{uploadProgress}%</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-red-500/50">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-red-500/50">
|
||||
<svg className="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@@ -36,7 +34,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="absolute -right-1.5 -top-1.5 rounded-full border border-border/40 bg-background/90 p-1 text-foreground shadow-sm backdrop-blur transition-opacity hover:bg-background focus:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
className="absolute -right-2 -top-2 rounded-full bg-red-500 p-1 text-white opacity-100 transition-opacity focus:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { Project } from '../../../../types/app';
|
||||
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
|
||||
|
||||
import ChatMessageImages from './ChatMessageImages';
|
||||
import { Markdown } from './Markdown';
|
||||
import MessageCopyControl from './MessageCopyControl';
|
||||
import MessageSpeakControl from './MessageSpeakControl';
|
||||
@@ -85,28 +84,31 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
|
||||
>
|
||||
{message.type === 'user' ? (
|
||||
/* User turn on the right: claude.ai-style attachment cards above the bubble */
|
||||
/* User message bubble on the right */
|
||||
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
|
||||
<div className="flex min-w-0 flex-1 flex-col items-end gap-2 sm:flex-initial">
|
||||
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
|
||||
<div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
|
||||
{message.content}
|
||||
</div>
|
||||
{message.images && message.images.length > 0 && (
|
||||
<ChatMessageImages
|
||||
images={message.images}
|
||||
projectId={selectedProject?.projectId}
|
||||
/>
|
||||
)}
|
||||
{(userCopyContent.trim().length > 0 || !message.images?.length) && (
|
||||
<div className="group max-w-full rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:px-4">
|
||||
<div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
|
||||
{message.content}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
|
||||
{shouldShowUserCopyControl && (
|
||||
<MessageCopyControl content={userCopyContent} messageType="user" />
|
||||
)}
|
||||
<span>{formattedTime}</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{message.images.map((img, idx) => (
|
||||
<img
|
||||
key={img.name || idx}
|
||||
src={img.data}
|
||||
alt={img.name}
|
||||
className="h-auto max-w-full cursor-pointer rounded-lg transition-opacity hover:opacity-90"
|
||||
onClick={() => window.open(img.data, '_blank')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
|
||||
{shouldShowUserCopyControl && (
|
||||
<MessageCopyControl content={userCopyContent} messageType="user" />
|
||||
)}
|
||||
<span>{formattedTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!isGrouped && (
|
||||
<div className="hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-sm text-white sm:flex">
|
||||
|
||||
@@ -34,17 +34,6 @@ const PROVIDER_META: { id: LLMProvider; name: string }[] = [
|
||||
const MOD_KEY =
|
||||
typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl";
|
||||
|
||||
// cmdk's default filter is fuzzy (loose character-subsequence scoring), which
|
||||
// surfaces unrelated models — e.g. searching "chatgpt" also matched "Fable".
|
||||
// Require every whitespace-separated search token to appear as a literal
|
||||
// substring instead, so "claude 4.5" still matches "Anthropic Claude Haiku 4.5"
|
||||
// but "chatgpt" only matches models that actually contain it.
|
||||
function modelSearchFilter(value: string, search: string): number {
|
||||
const haystack = value.toLowerCase();
|
||||
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return tokens.every((token) => haystack.includes(token)) ? 1 : 0;
|
||||
}
|
||||
|
||||
type ProviderSelectionEmptyStateProps = {
|
||||
selectedSession: ProjectSession | null;
|
||||
currentSessionId: string | null;
|
||||
@@ -245,7 +234,7 @@ export default function ProviderSelectionEmptyState({
|
||||
<div className="border-b border-border/60 bg-muted/20 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-foreground">Choose a model</p>
|
||||
</div>
|
||||
<Command filter={modelSearchFilter}>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("providerSelection.searchModels", {
|
||||
defaultValue: "Search models...",
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PencilIcon, XIcon } from 'lucide-react';
|
||||
|
||||
interface QueuedMessageCardProps {
|
||||
content: string;
|
||||
imageCount?: number;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function QueuedMessageCard({ content, imageCount = 0, onEdit, onDelete }: QueuedMessageCardProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
return (
|
||||
<div className="settings-content-enter mx-auto mb-2 max-w-[54.25rem] rounded-xl border border-dashed border-primary/25 bg-primary/[0.04] px-3 py-2">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary/60" aria-hidden />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-primary/70">
|
||||
<span>{t('input.queue.label', { defaultValue: 'Queued' })}</span>
|
||||
<span className="normal-case text-muted-foreground/60">
|
||||
· {t('input.queue.willSend', { defaultValue: 'Will send when this finishes' })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 break-words text-sm text-foreground/90">{content}</p>
|
||||
{imageCount > 0 && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{imageCount} {imageCount === 1 ? 'image' : 'images'} attached
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label={t('input.queue.edit', { defaultValue: 'Edit queued message' })}
|
||||
title={t('input.queue.edit', { defaultValue: 'Edit queued message' })}
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
aria-label={t('input.queue.delete', { defaultValue: 'Delete queued message' })}
|
||||
title={t('input.queue.delete', { defaultValue: 'Delete queued message' })}
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { ConfirmActionType, FileStatusCode, GitStatusGroupEntry } from '../types/types';
|
||||
|
||||
export const DEFAULT_BRANCH = 'main';
|
||||
// High enough for the commit graph to show meaningful branch structure.
|
||||
export const RECENT_COMMITS_LIMIT = 50;
|
||||
export const RECENT_COMMITS_LIMIT = 10;
|
||||
|
||||
export const FILE_STATUS_GROUPS: GitStatusGroupEntry[] = [
|
||||
{ key: 'modified', status: 'M' },
|
||||
|
||||
@@ -495,71 +495,6 @@ export function useGitPanelController({
|
||||
[fetchGitStatus, selectedProject],
|
||||
);
|
||||
|
||||
const stageFiles = useCallback(
|
||||
async (files: string[]) => {
|
||||
if (!selectedProject || files.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/git/stage', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.projectId,
|
||||
files,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await readJson<GitOperationResponse>(response);
|
||||
if (!data.success) {
|
||||
setOperationError(data.error ?? 'Stage failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Refresh so the Staged section re-syncs from the real index.
|
||||
await fetchGitStatus();
|
||||
return true;
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : 'Stage failed');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[fetchGitStatus, selectedProject],
|
||||
);
|
||||
|
||||
const unstageFiles = useCallback(
|
||||
async (files: string[]) => {
|
||||
if (!selectedProject || files.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/git/unstage', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.projectId,
|
||||
files,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await readJson<GitOperationResponse>(response);
|
||||
if (!data.success) {
|
||||
setOperationError(data.error ?? 'Unstage failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
await fetchGitStatus();
|
||||
return true;
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : 'Unstage failed');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[fetchGitStatus, selectedProject],
|
||||
);
|
||||
|
||||
const fetchRecentCommits = useCallback(async () => {
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
@@ -809,8 +744,6 @@ export function useGitPanelController({
|
||||
handlePublish,
|
||||
discardChanges,
|
||||
deleteUntrackedFile,
|
||||
stageFiles,
|
||||
unstageFiles,
|
||||
fetchCommitDiff,
|
||||
generateCommitMessage,
|
||||
commitChanges,
|
||||
|
||||
@@ -25,8 +25,6 @@ export type GitStatusResponse = {
|
||||
added?: string[];
|
||||
deleted?: string[];
|
||||
untracked?: string[];
|
||||
/** Paths with index-side changes — mirrors the real git index. */
|
||||
staged?: string[];
|
||||
error?: string;
|
||||
details?: string;
|
||||
};
|
||||
@@ -51,10 +49,6 @@ export type GitCommitSummary = {
|
||||
date: string;
|
||||
message: string;
|
||||
stats?: string;
|
||||
/** Parent commit hashes — drives the History view commit graph. */
|
||||
parents?: string[];
|
||||
/** Ref decorations, e.g. "HEAD -> main", "origin/main", "tag: v1.0". */
|
||||
refs?: string[];
|
||||
};
|
||||
|
||||
export type GitDiffMap = Record<string, string>;
|
||||
@@ -105,8 +99,6 @@ export type GitPanelController = {
|
||||
handlePublish: () => Promise<void>;
|
||||
discardChanges: (filePath: string) => Promise<void>;
|
||||
deleteUntrackedFile: (filePath: string) => Promise<void>;
|
||||
stageFiles: (files: string[]) => Promise<boolean>;
|
||||
unstageFiles: (files: string[]) => Promise<boolean>;
|
||||
fetchCommitDiff: (commitHash: string) => Promise<void>;
|
||||
generateCommitMessage: (files: string[]) => Promise<string | null>;
|
||||
commitChanges: (message: string, files: string[]) => Promise<boolean>;
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { computeCommitGraph } from './commitGraph';
|
||||
|
||||
test('linear history stays in a single lane', () => {
|
||||
const rows = computeCommitGraph([
|
||||
{ hash: 'c3', parents: ['c2'] },
|
||||
{ hash: 'c2', parents: ['c1'] },
|
||||
{ hash: 'c1', parents: [] },
|
||||
]);
|
||||
|
||||
assert.deepEqual(rows.map((row) => row.nodeLane), [0, 0, 0]);
|
||||
assert.deepEqual(rows.map((row) => row.laneCount), [1, 1, 1]);
|
||||
// Tip has no line from above; root has no line below.
|
||||
assert.equal(rows[0].hasTopContinuation, false);
|
||||
assert.equal(rows[0].hasParentContinuation, true);
|
||||
assert.equal(rows[2].hasParentContinuation, false);
|
||||
assert.deepEqual(rows[1].passThrough, []);
|
||||
});
|
||||
|
||||
test('merge commit opens a second lane that joins back at the fork point', () => {
|
||||
// main: m2 --- m1 --- base
|
||||
// feature: \- f1 -/ (m2 merges f1; both branch from base)
|
||||
const rows = computeCommitGraph([
|
||||
{ hash: 'm2', parents: ['m1', 'f1'] },
|
||||
{ hash: 'm1', parents: ['base'] },
|
||||
{ hash: 'f1', parents: ['base'] },
|
||||
{ hash: 'base', parents: [] },
|
||||
]);
|
||||
|
||||
// Merge commit sits in lane 0 and branches a line out to lane 1.
|
||||
assert.equal(rows[0].nodeLane, 0);
|
||||
assert.deepEqual(rows[0].outbound, [1]);
|
||||
assert.equal(rows[0].laneCount, 2);
|
||||
|
||||
// m1 passes lane 1 (feature line) straight through.
|
||||
assert.equal(rows[1].nodeLane, 0);
|
||||
assert.deepEqual(rows[1].passThrough, [1]);
|
||||
|
||||
// f1 is the feature commit in lane 1; lane 0 (main) passes through.
|
||||
assert.equal(rows[2].nodeLane, 1);
|
||||
assert.deepEqual(rows[2].passThrough, [0]);
|
||||
|
||||
// base: both lanes converge — lane 1 merges into the node in lane 0.
|
||||
assert.equal(rows[3].nodeLane, 0);
|
||||
assert.deepEqual(rows[3].inbound, [1]);
|
||||
assert.deepEqual(rows[3].bottomLanes, []);
|
||||
});
|
||||
|
||||
test('independent branch tips get their own lanes', () => {
|
||||
// Two branch tips pointing at the same parent (e.g. main and a feature).
|
||||
const rows = computeCommitGraph([
|
||||
{ hash: 'tipA', parents: ['base'] },
|
||||
{ hash: 'tipB', parents: ['base'] },
|
||||
{ hash: 'base', parents: [] },
|
||||
]);
|
||||
|
||||
assert.equal(rows[0].nodeLane, 0);
|
||||
assert.equal(rows[1].nodeLane, 1);
|
||||
// Both lanes collapse into base.
|
||||
assert.equal(rows[2].nodeLane, 0);
|
||||
assert.deepEqual(rows[2].inbound, [1]);
|
||||
});
|
||||
|
||||
test('freed lanes are reused by later branch tips', () => {
|
||||
const rows = computeCommitGraph([
|
||||
{ hash: 'a2', parents: ['a1'] },
|
||||
{ hash: 'a1', parents: [] }, // lane 0 ends here
|
||||
{ hash: 'b1', parents: [] }, // new tip should reuse lane 0
|
||||
]);
|
||||
|
||||
assert.equal(rows[2].nodeLane, 0);
|
||||
assert.equal(rows[2].laneCount, 1);
|
||||
});
|
||||
|
||||
test('commits without parents metadata degrade gracefully', () => {
|
||||
const rows = computeCommitGraph([{ hash: 'x' }, { hash: 'y' }]);
|
||||
// Without parent info every commit is a standalone tip; the second one
|
||||
// reuses the freed lane.
|
||||
assert.deepEqual(rows.map((row) => row.nodeLane), [0, 0]);
|
||||
assert.deepEqual(rows.map((row) => row.hasParentContinuation), [false, false]);
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Lane assignment for the History view commit graph (VSCode Git Graph style).
|
||||
*
|
||||
* Commits must arrive in graph order (children before their parents — the
|
||||
* backend guarantees this via `git log --all --topo-order`). Each commit is
|
||||
* assigned a lane; lines connect commits to their parents across rows.
|
||||
*/
|
||||
|
||||
export type CommitGraphRow = {
|
||||
/** Lane the commit dot sits in. */
|
||||
nodeLane: number;
|
||||
/** Total lanes visible in this row — determines the strip width. */
|
||||
laneCount: number;
|
||||
/** A line arrives at the node from the row above (some child expects this commit). */
|
||||
hasTopContinuation: boolean;
|
||||
/** The node's own lane continues below toward its first parent. */
|
||||
hasParentContinuation: boolean;
|
||||
/** Extra top lanes that merge into the node (multiple children / branch tips joining). */
|
||||
inbound: number[];
|
||||
/** Bottom lanes branching out of the node toward its extra parents (merge commits). */
|
||||
outbound: number[];
|
||||
/** Lanes whose lines pass straight through this row untouched. */
|
||||
passThrough: number[];
|
||||
/** Every lane still active below this row — rails continue through expanded content. */
|
||||
bottomLanes: number[];
|
||||
};
|
||||
|
||||
type GraphCommit = {
|
||||
hash: string;
|
||||
parents?: string[];
|
||||
};
|
||||
|
||||
// Colors cycle per lane, VSCode Git Graph style. Chosen to stay readable on
|
||||
// both light and dark backgrounds.
|
||||
const GRAPH_COLORS = [
|
||||
'#0ea5e9', // sky
|
||||
'#f97316', // orange
|
||||
'#a855f7', // purple
|
||||
'#22c55e', // green
|
||||
'#ef4444', // red
|
||||
'#eab308', // yellow
|
||||
'#14b8a6', // teal
|
||||
'#ec4899', // pink
|
||||
'#6366f1', // indigo
|
||||
'#84cc16', // lime
|
||||
];
|
||||
|
||||
export const laneColor = (lane: number) => GRAPH_COLORS[lane % GRAPH_COLORS.length];
|
||||
|
||||
export function computeCommitGraph(commits: GraphCommit[]): CommitGraphRow[] {
|
||||
// Each slot holds the commit hash that lane is waiting to reach, or null
|
||||
// when the lane is free.
|
||||
const lanes: (string | null)[] = [];
|
||||
const rows: CommitGraphRow[] = [];
|
||||
|
||||
const takeFirstFreeLane = (): number => {
|
||||
const free = lanes.indexOf(null);
|
||||
if (free !== -1) {
|
||||
return free;
|
||||
}
|
||||
lanes.push(null);
|
||||
return lanes.length - 1;
|
||||
};
|
||||
|
||||
for (const commit of commits) {
|
||||
const activeBefore = new Set<number>();
|
||||
lanes.forEach((expected, index) => {
|
||||
if (expected !== null) {
|
||||
activeBefore.add(index);
|
||||
}
|
||||
});
|
||||
|
||||
// Lanes whose next expected commit is this one.
|
||||
const waiting: number[] = [];
|
||||
lanes.forEach((expected, index) => {
|
||||
if (expected === commit.hash) {
|
||||
waiting.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
const hasTopContinuation = waiting.length > 0;
|
||||
const nodeLane = hasTopContinuation ? waiting[0] : takeFirstFreeLane();
|
||||
|
||||
// Additional lanes converging on this commit merge into the node and free up.
|
||||
const inbound = waiting.slice(1);
|
||||
for (const lane of inbound) {
|
||||
lanes[lane] = null;
|
||||
}
|
||||
|
||||
const parents = commit.parents ?? [];
|
||||
lanes[nodeLane] = parents.length > 0 ? parents[0] : null;
|
||||
|
||||
// Extra parents (merge commits) either join a lane already heading to that
|
||||
// parent or open a new lane for it.
|
||||
const outbound: number[] = [];
|
||||
for (const parent of parents.slice(1)) {
|
||||
const existing = lanes.findIndex((expected) => expected === parent);
|
||||
if (existing !== -1 && existing !== nodeLane) {
|
||||
outbound.push(existing);
|
||||
} else {
|
||||
const lane = takeFirstFreeLane();
|
||||
lanes[lane] = parent;
|
||||
outbound.push(lane);
|
||||
}
|
||||
}
|
||||
|
||||
const passThrough = [...activeBefore]
|
||||
.filter((lane) => lane !== nodeLane && !waiting.includes(lane))
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const bottomLanes: number[] = [];
|
||||
lanes.forEach((expected, index) => {
|
||||
if (expected !== null) {
|
||||
bottomLanes.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
const laneCount = Math.max(lanes.length, nodeLane + 1);
|
||||
|
||||
// Keep the lane array tight so later rows don't inherit phantom width.
|
||||
while (lanes.length > 0 && lanes[lanes.length - 1] === null) {
|
||||
lanes.pop();
|
||||
}
|
||||
|
||||
rows.push({
|
||||
nodeLane,
|
||||
laneCount,
|
||||
hasTopContinuation,
|
||||
hasParentContinuation: parents.length > 0,
|
||||
inbound,
|
||||
outbound,
|
||||
passThrough,
|
||||
bottomLanes,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
@@ -46,8 +46,6 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
handlePublish,
|
||||
discardChanges,
|
||||
deleteUntrackedFile,
|
||||
stageFiles,
|
||||
unstageFiles,
|
||||
fetchCommitDiff,
|
||||
generateCommitMessage,
|
||||
commitChanges,
|
||||
@@ -140,8 +138,6 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
onOpenFile={openFile}
|
||||
onDiscardFile={discardChanges}
|
||||
onDeleteFile={deleteUntrackedFile}
|
||||
onStageFiles={stageFiles}
|
||||
onUnstageFiles={unstageFiles}
|
||||
onCommitChanges={commitChanges}
|
||||
onGenerateCommitMessage={generateCommitMessage}
|
||||
onRequestConfirmation={setConfirmAction}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AlertCircle, Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Search, Upload, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertCircle, Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
|
||||
import NewBranchModal from './modals/NewBranchModal';
|
||||
|
||||
@@ -54,26 +54,7 @@ export default function GitPanelHeader({
|
||||
}: GitPanelHeaderProps) {
|
||||
const [showBranchDropdown, setShowBranchDropdown] = useState(false);
|
||||
const [showNewBranchModal, setShowNewBranchModal] = useState(false);
|
||||
const [branchSearchQuery, setBranchSearchQuery] = useState('');
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const branchSearchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Focus the search box on open; drop any stale query on close.
|
||||
useEffect(() => {
|
||||
if (showBranchDropdown) {
|
||||
branchSearchInputRef.current?.focus();
|
||||
} else {
|
||||
setBranchSearchQuery('');
|
||||
}
|
||||
}, [showBranchDropdown]);
|
||||
|
||||
const filteredBranches = useMemo(() => {
|
||||
const query = branchSearchQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return branches;
|
||||
}
|
||||
return branches.filter((branch) => branch.toLowerCase().includes(query));
|
||||
}, [branches, branchSearchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -168,45 +149,21 @@ export default function GitPanelHeader({
|
||||
|
||||
{showBranchDropdown && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1 w-64 overflow-hidden rounded-xl border border-border bg-card shadow-lg">
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={branchSearchInputRef}
|
||||
type="text"
|
||||
value={branchSearchQuery}
|
||||
onChange={(event) => setBranchSearchQuery(event.target.value)}
|
||||
placeholder="Search branches..."
|
||||
className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
/>
|
||||
{branchSearchQuery && (
|
||||
<button
|
||||
onClick={() => setBranchSearchQuery('')}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Clear search"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-4 py-3 text-center text-sm text-muted-foreground">No matching branches</div>
|
||||
) : (
|
||||
filteredBranches.map((branch) => (
|
||||
<button
|
||||
key={branch}
|
||||
onClick={() => void handleSwitchBranch(branch)}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-accent ${
|
||||
branch === currentBranch ? 'bg-accent/50 text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{branch === currentBranch && <Check className="h-3 w-3 text-primary" />}
|
||||
<span className={branch === currentBranch ? 'font-medium' : ''}>{branch}</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{branches.map((branch) => (
|
||||
<button
|
||||
key={branch}
|
||||
onClick={() => void handleSwitchBranch(branch)}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-accent ${
|
||||
branch === currentBranch ? 'bg-accent/50 text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{branch === currentBranch && <Check className="h-3 w-3 text-primary" />}
|
||||
<span className={branch === currentBranch ? 'font-medium' : ''}>{branch}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border py-1">
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Check, GitBranch, Globe, Plus, RefreshCw, Search, Trash2, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, GitBranch, Globe, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { ConfirmationRequest, GitRemoteStatus } from '../../types/types';
|
||||
import NewBranchModal from '../modals/NewBranchModal';
|
||||
|
||||
@@ -138,21 +138,10 @@ export default function BranchesView({
|
||||
onRequestConfirmation,
|
||||
}: BranchesViewProps) {
|
||||
const [showNewBranchModal, setShowNewBranchModal] = useState(false);
|
||||
const [branchSearchQuery, setBranchSearchQuery] = useState('');
|
||||
|
||||
const aheadCount = remoteStatus?.ahead ?? 0;
|
||||
const behindCount = remoteStatus?.behind ?? 0;
|
||||
|
||||
const normalizedQuery = branchSearchQuery.trim().toLowerCase();
|
||||
const filteredLocalBranches = useMemo(
|
||||
() => (normalizedQuery ? localBranches.filter((branch) => branch.toLowerCase().includes(normalizedQuery)) : localBranches),
|
||||
[localBranches, normalizedQuery],
|
||||
);
|
||||
const filteredRemoteBranches = useMemo(
|
||||
() => (normalizedQuery ? remoteBranches.filter((branch) => branch.toLowerCase().includes(normalizedQuery)) : remoteBranches),
|
||||
[normalizedQuery, remoteBranches],
|
||||
);
|
||||
|
||||
const requestSwitch = (branch: string) => {
|
||||
onRequestConfirmation({
|
||||
type: 'commit', // reuse neutral type for switch
|
||||
@@ -193,33 +182,12 @@ export default function BranchesView({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Branch search */}
|
||||
<div className="flex items-center gap-2 border-b border-border/40 px-4 py-2">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={branchSearchQuery}
|
||||
onChange={(event) => setBranchSearchQuery(event.target.value)}
|
||||
placeholder="Search branches..."
|
||||
className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
/>
|
||||
{branchSearchQuery && (
|
||||
<button
|
||||
onClick={() => setBranchSearchQuery('')}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Clear search"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Branch list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredLocalBranches.length > 0 && (
|
||||
{localBranches.length > 0 && (
|
||||
<>
|
||||
<SectionHeader label="Local" count={filteredLocalBranches.length} />
|
||||
{filteredLocalBranches.map((branch) => (
|
||||
<SectionHeader label="Local" count={localBranches.length} />
|
||||
{localBranches.map((branch) => (
|
||||
<BranchRow
|
||||
key={`local:${branch}`}
|
||||
name={branch}
|
||||
@@ -235,10 +203,10 @@ export default function BranchesView({
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredRemoteBranches.length > 0 && (
|
||||
{remoteBranches.length > 0 && (
|
||||
<>
|
||||
<SectionHeader label="Remote" count={filteredRemoteBranches.length} />
|
||||
{filteredRemoteBranches.map((branch) => (
|
||||
<SectionHeader label="Remote" count={remoteBranches.length} />
|
||||
{remoteBranches.map((branch) => (
|
||||
<BranchRow
|
||||
key={`remote:${branch}`}
|
||||
name={branch}
|
||||
@@ -254,10 +222,10 @@ export default function BranchesView({
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredLocalBranches.length === 0 && filteredRemoteBranches.length === 0 && (
|
||||
{localBranches.length === 0 && remoteBranches.length === 0 && (
|
||||
<div className="flex h-32 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<GitBranch className="h-10 w-10 opacity-30" />
|
||||
<p className="text-sm">{normalizedQuery ? 'No branches match your search' : 'No branches found'}</p>
|
||||
<p className="text-sm">No branches found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,6 @@ type ChangesViewProps = {
|
||||
onOpenFile: (filePath: string) => Promise<void>;
|
||||
onDiscardFile: (filePath: string) => Promise<void>;
|
||||
onDeleteFile: (filePath: string) => Promise<void>;
|
||||
onStageFiles: (files: string[]) => Promise<boolean>;
|
||||
onUnstageFiles: (files: string[]) => Promise<boolean>;
|
||||
onCommitChanges: (message: string, files: string[]) => Promise<boolean>;
|
||||
onGenerateCommitMessage: (files: string[]) => Promise<string | null>;
|
||||
onRequestConfirmation: (request: ConfirmationRequest) => void;
|
||||
@@ -40,8 +38,6 @@ export default function ChangesView({
|
||||
onOpenFile,
|
||||
onDiscardFile,
|
||||
onDeleteFile,
|
||||
onStageFiles,
|
||||
onUnstageFiles,
|
||||
onCommitChanges,
|
||||
onGenerateCommitMessage,
|
||||
onRequestConfirmation,
|
||||
@@ -59,9 +55,12 @@ export default function ChangesView({
|
||||
return;
|
||||
}
|
||||
|
||||
// The Staged section mirrors the real git index reported by /status, so
|
||||
// files staged outside the app (VSCode, terminal) show up here too.
|
||||
setSelectedFiles(new Set(gitStatus.staged ?? []));
|
||||
// Remove any selected files that no longer exist in the status
|
||||
setSelectedFiles((prev) => {
|
||||
const allFiles = new Set(getAllChangedFiles(gitStatus));
|
||||
const next = new Set([...prev].filter((f) => allFiles.has(f)));
|
||||
return next;
|
||||
});
|
||||
}, [gitStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,25 +85,17 @@ export default function ChangesView({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Staging is real: every toggle runs git add / git reset through the API.
|
||||
// The set is flipped optimistically; the status refresh triggered by the
|
||||
// API call re-syncs it from the actual index afterwards.
|
||||
const toggleFileSelected = useCallback(
|
||||
(filePath: string) => {
|
||||
const isStaged = selectedFiles.has(filePath);
|
||||
setSelectedFiles((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (isStaged) {
|
||||
next.delete(filePath);
|
||||
} else {
|
||||
next.add(filePath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
void (isStaged ? onUnstageFiles([filePath]) : onStageFiles([filePath]));
|
||||
},
|
||||
[onStageFiles, onUnstageFiles, selectedFiles],
|
||||
);
|
||||
const toggleFileSelected = useCallback((filePath: string) => {
|
||||
setSelectedFiles((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(filePath)) {
|
||||
next.delete(filePath);
|
||||
} else {
|
||||
next.add(filePath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const requestFileAction = useCallback(
|
||||
(filePath: string, status: FileStatusCode) => {
|
||||
@@ -206,11 +197,7 @@ export default function ChangesView({
|
||||
</span>
|
||||
{selectedFiles.size > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const filesToUnstage = Array.from(selectedFiles);
|
||||
setSelectedFiles(new Set());
|
||||
void onUnstageFiles(filesToUnstage);
|
||||
}}
|
||||
onClick={() => setSelectedFiles(new Set())}
|
||||
className="text-xs text-primary transition-colors hover:text-primary/80"
|
||||
>
|
||||
Unstage All
|
||||
@@ -243,11 +230,7 @@ export default function ChangesView({
|
||||
</span>
|
||||
{unstagedFiles.size > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const filesToStage = Array.from(unstagedFiles);
|
||||
setSelectedFiles(new Set(changedFiles));
|
||||
void onStageFiles(filesToStage);
|
||||
}}
|
||||
onClick={() => setSelectedFiles(new Set(changedFiles))}
|
||||
className="text-xs text-primary transition-colors hover:text-primary/80"
|
||||
>
|
||||
Stage All
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { CommitGraphRow } from '../../utils/commitGraph';
|
||||
import { laneColor } from '../../utils/commitGraph';
|
||||
|
||||
// Geometry: each lane is a fixed-width column; the commit dot sits at NODE_Y
|
||||
// inside a fixed-height top zone (matching the collapsed row header), and
|
||||
// plain rails continue below it so lines stretch through expanded content.
|
||||
const LANE_WIDTH = 12;
|
||||
const NODE_ZONE_HEIGHT = 56;
|
||||
const NODE_Y = 28;
|
||||
const NODE_RADIUS = 3.5;
|
||||
const STROKE_WIDTH = 2;
|
||||
|
||||
const laneX = (lane: number) => lane * LANE_WIDTH + LANE_WIDTH / 2;
|
||||
|
||||
type CommitGraphStripProps = {
|
||||
row: CommitGraphRow;
|
||||
};
|
||||
|
||||
export default function CommitGraphStrip({ row }: CommitGraphStripProps) {
|
||||
const width = row.laneCount * LANE_WIDTH;
|
||||
const nodeX = laneX(row.nodeLane);
|
||||
const nodeColor = laneColor(row.nodeLane);
|
||||
|
||||
return (
|
||||
<div aria-hidden className="relative shrink-0 self-stretch overflow-hidden" style={{ width }}>
|
||||
<svg
|
||||
className="absolute left-0 top-0"
|
||||
width={width}
|
||||
height={NODE_ZONE_HEIGHT}
|
||||
fill="none"
|
||||
>
|
||||
{/* Lines passing straight through the row */}
|
||||
{row.passThrough.map((lane) => (
|
||||
<path
|
||||
key={`pass-${lane}`}
|
||||
d={`M ${laneX(lane)} 0 V ${NODE_ZONE_HEIGHT}`}
|
||||
stroke={laneColor(lane)}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* The node's own lane arriving from above / continuing below */}
|
||||
{row.hasTopContinuation && (
|
||||
<path d={`M ${nodeX} 0 V ${NODE_Y}`} stroke={nodeColor} strokeWidth={STROKE_WIDTH} />
|
||||
)}
|
||||
{row.hasParentContinuation && (
|
||||
<path d={`M ${nodeX} ${NODE_Y} V ${NODE_ZONE_HEIGHT}`} stroke={nodeColor} strokeWidth={STROKE_WIDTH} />
|
||||
)}
|
||||
|
||||
{/* Extra children merging into the node from the row above */}
|
||||
{row.inbound.map((lane) => (
|
||||
<path
|
||||
key={`in-${lane}`}
|
||||
d={`M ${laneX(lane)} 0 Q ${laneX(lane)} ${NODE_Y} ${nodeX} ${NODE_Y}`}
|
||||
stroke={laneColor(lane)}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Extra parents branching out of the node toward the row below */}
|
||||
{row.outbound.map((lane) => (
|
||||
<path
|
||||
key={`out-${lane}`}
|
||||
d={`M ${nodeX} ${NODE_Y} Q ${laneX(lane)} ${NODE_Y} ${laneX(lane)} ${NODE_ZONE_HEIGHT}`}
|
||||
stroke={laneColor(lane)}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Commit dot — slightly larger for merge/fork points */}
|
||||
<circle
|
||||
cx={nodeX}
|
||||
cy={NODE_Y}
|
||||
r={row.inbound.length > 0 || row.outbound.length > 0 ? NODE_RADIUS + 0.5 : NODE_RADIUS}
|
||||
fill={nodeColor}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Rails continuing below the node zone (through expanded content) */}
|
||||
{row.bottomLanes.map((lane) => (
|
||||
<div
|
||||
key={`rail-${lane}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: laneX(lane) - STROKE_WIDTH / 2,
|
||||
top: NODE_ZONE_HEIGHT,
|
||||
bottom: 0,
|
||||
width: STROKE_WIDTH,
|
||||
backgroundColor: laneColor(lane),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { ChevronDown, ChevronRight, GitBranch, Tag } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import type { GitCommitSummary } from '../../types/types';
|
||||
import type { CommitGraphRow } from '../../utils/commitGraph';
|
||||
import { laneColor } from '../../utils/commitGraph';
|
||||
import { getStatusBadgeClass, parseCommitFiles } from '../../utils/gitPanelUtils';
|
||||
import GitDiffViewer from '../shared/GitDiffViewer';
|
||||
import CommitGraphStrip from './CommitGraphStrip';
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
@@ -15,36 +12,12 @@ function formatDate(dateString: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
// One "HEAD -> main" / "origin/x" / "tag: v1" decoration pill next to the
|
||||
// commit message, tinted with the commit's graph lane color.
|
||||
function RefBadge({ refName, color }: { refName: string; color: string }) {
|
||||
const isTag = refName.startsWith('tag: ');
|
||||
const isHead = refName.startsWith('HEAD -> ');
|
||||
const label = isTag ? refName.slice(5) : isHead ? refName.slice(8) : refName;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex max-w-40 items-center gap-1 rounded-full border px-1.5 py-px text-[10px] font-medium leading-4"
|
||||
style={{
|
||||
borderColor: color,
|
||||
color,
|
||||
backgroundColor: isHead ? `${color}22` : 'transparent',
|
||||
}}
|
||||
title={refName}
|
||||
>
|
||||
{isTag ? <Tag className="h-2.5 w-2.5 shrink-0" /> : <GitBranch className="h-2.5 w-2.5 shrink-0" />}
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type CommitHistoryItemProps = {
|
||||
commit: GitCommitSummary;
|
||||
isExpanded: boolean;
|
||||
diff?: string;
|
||||
isMobile: boolean;
|
||||
wrapText: boolean;
|
||||
graphRow?: CommitGraphRow;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
@@ -54,7 +27,6 @@ export default function CommitHistoryItem({
|
||||
diff,
|
||||
isMobile,
|
||||
wrapText,
|
||||
graphRow,
|
||||
onToggle,
|
||||
}: CommitHistoryItemProps) {
|
||||
const fileSummary = useMemo(() => {
|
||||
@@ -62,12 +34,8 @@ export default function CommitHistoryItem({
|
||||
return parseCommitFiles(diff);
|
||||
}, [diff]);
|
||||
|
||||
const badgeColor = graphRow ? laneColor(graphRow.nodeLane) : 'var(--color-primary, #0ea5e9)';
|
||||
|
||||
return (
|
||||
<div className="flex border-b border-border last:border-0">
|
||||
{graphRow && <CommitGraphStrip row={graphRow} />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="border-b border-border last:border-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
@@ -80,13 +48,6 @@ export default function CommitHistoryItem({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{commit.refs && commit.refs.length > 0 && (
|
||||
<span className="mb-0.5 flex flex-wrap gap-1">
|
||||
{commit.refs.map((refName) => (
|
||||
<RefBadge key={refName} refName={refName} color={badgeColor} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<p className="truncate text-sm font-medium text-foreground">{commit.message}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{commit.author}
|
||||
@@ -184,7 +145,6 @@ export default function CommitHistoryItem({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { History, RefreshCw } from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { GitDiffMap, GitCommitSummary } from '../../types/types';
|
||||
import { computeCommitGraph } from '../../utils/commitGraph';
|
||||
import CommitHistoryItem from './CommitHistoryItem';
|
||||
|
||||
type HistoryViewProps = {
|
||||
@@ -23,15 +22,6 @@ export default function HistoryView({
|
||||
}: HistoryViewProps) {
|
||||
const [expandedCommits, setExpandedCommits] = useState<Set<string>>(new Set());
|
||||
|
||||
// Lane layout for the commit graph; rows align 1:1 with recentCommits.
|
||||
// Older API responses without `parents` degrade to plain rows (no strip).
|
||||
const graphRows = useMemo(() => {
|
||||
if (!recentCommits.some((commit) => commit.parents !== undefined)) {
|
||||
return null;
|
||||
}
|
||||
return computeCommitGraph(recentCommits);
|
||||
}, [recentCommits]);
|
||||
|
||||
const toggleCommitExpanded = useCallback(
|
||||
(commitHash: string) => {
|
||||
const isExpanding = !expandedCommits.has(commitHash);
|
||||
@@ -69,7 +59,7 @@ export default function HistoryView({
|
||||
</div>
|
||||
) : (
|
||||
<div className={isMobile ? 'pb-4' : ''}>
|
||||
{recentCommits.map((commit, index) => (
|
||||
{recentCommits.map((commit) => (
|
||||
<CommitHistoryItem
|
||||
key={commit.hash}
|
||||
commit={commit}
|
||||
@@ -77,7 +67,6 @@ export default function HistoryView({
|
||||
diff={commitDiffs[commit.hash]}
|
||||
isMobile={isMobile}
|
||||
wrapText={wrapText}
|
||||
graphRow={graphRows?.[index]}
|
||||
onToggle={() => toggleCommitExpanded(commit.hash)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { McpProject, McpProvider, McpScope, ProviderMcpServer } from '../types';
|
||||
import { IS_PLATFORM } from '../../../constants/config';
|
||||
import { ActionMenu, Badge, Button } from '../../../shared/view/ui';
|
||||
import { Badge, Button } from '../../../shared/view/ui';
|
||||
import {
|
||||
MCP_GLOBAL_SUPPORTED_SCOPES,
|
||||
MCP_GLOBAL_SUPPORTED_TRANSPORTS,
|
||||
@@ -134,40 +134,33 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Server className="mt-0.5 h-5 w-5 flex-shrink-0 text-purple-500" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ActionMenu
|
||||
label="Add MCP Server"
|
||||
icon={Plus}
|
||||
className="w-full sm:w-auto"
|
||||
triggerClassName={`w-full sm:w-auto ${MCP_PROVIDER_BUTTON_CLASSES[selectedProvider]}`}
|
||||
items={[
|
||||
{
|
||||
key: 'global',
|
||||
label: globalButtonLabel,
|
||||
description: globalAddDescription,
|
||||
icon: Globe,
|
||||
onSelect: openGlobalForm,
|
||||
},
|
||||
{
|
||||
key: 'provider',
|
||||
label: providerButtonLabel,
|
||||
description: providerAddDescription,
|
||||
icon: Server,
|
||||
onSelect: () => openForm(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={openGlobalForm}
|
||||
className={MCP_PROVIDER_BUTTON_CLASSES[selectedProvider]}
|
||||
size="sm"
|
||||
title={globalAddDescription}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{globalButtonLabel}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openForm()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title={providerAddDescription}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{providerButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-4">
|
||||
{saveStatus === 'success' && (
|
||||
<span className="animate-in fade-in text-xs text-muted-foreground">{t('saveStatus.success')}</span>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { MutableRefObject, RefObject } from 'react';
|
||||
import { ClipboardAddon, type IClipboardProvider } from '@xterm/addon-clipboard';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
@@ -20,43 +19,6 @@ import {
|
||||
import { sendSocketMessage } from '../utils/socket';
|
||||
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
||||
|
||||
// CLIs running inside the pty (e.g. `claude auth login`'s "press c to copy"
|
||||
// device-flow prompt) write to the clipboard via an OSC 52 escape sequence,
|
||||
// not a browser event — xterm.js ignores OSC 52 unless a clipboard addon is
|
||||
// loaded. Routes writes through the same fallback-aware helper the terminal's
|
||||
// own selection-copy shortcut uses, since `navigator.clipboard` is often
|
||||
// unavailable on self-hosted, non-HTTPS deployments.
|
||||
// `ClipboardSelectionType.SYSTEM` is `'c'` (vs. `'p'` for the X11 primary
|
||||
// selection) — compared as a literal since the addon ships it as a const
|
||||
// enum, which isolatedModules builds (esbuild/Vite) can't import as a value.
|
||||
const oscClipboardProvider: IClipboardProvider = {
|
||||
readText: async (selection) => {
|
||||
if (selection !== 'c') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return (await navigator.clipboard?.readText?.()) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
writeText: async (selection, text) => {
|
||||
if (selection !== 'c') {
|
||||
return;
|
||||
}
|
||||
await copyTextToClipboard(text);
|
||||
},
|
||||
};
|
||||
|
||||
// The addon's published typings declare a single `(provider?)` constructor
|
||||
// param, but the shipped runtime actually takes `(base64?, provider?)` — see
|
||||
// node_modules/@xterm/addon-clipboard/lib/addon-clipboard.js. Cast to call it
|
||||
// the way it's really implemented.
|
||||
const ClipboardAddonCtor = ClipboardAddon as unknown as new (
|
||||
base64?: unknown,
|
||||
provider?: IClipboardProvider,
|
||||
) => ClipboardAddon;
|
||||
|
||||
type UseShellTerminalOptions = {
|
||||
terminalContainerRef: RefObject<HTMLDivElement>;
|
||||
terminalRef: MutableRefObject<Terminal | null>;
|
||||
@@ -131,8 +93,6 @@ export function useShellTerminal({
|
||||
fitAddonRef.current = nextFitAddon;
|
||||
nextTerminal.loadAddon(nextFitAddon);
|
||||
|
||||
nextTerminal.loadAddon(new ClipboardAddonCtor(undefined, oscClipboardProvider));
|
||||
|
||||
// Avoid wrapped partial links in compact login flows.
|
||||
if (!minimal) {
|
||||
nextTerminal.loadAddon(new WebLinksAddon());
|
||||
|
||||
@@ -42,7 +42,6 @@ export type SidebarProps = {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
activeSessions: SessionActivityMap;
|
||||
attentionSessionIds: ReadonlySet<string>;
|
||||
onProjectSelect: (project: Project) => void;
|
||||
onSessionSelect: (session: ProjectSession) => void;
|
||||
onNewSession: (project: Project) => void;
|
||||
|
||||
@@ -26,7 +26,6 @@ function Sidebar({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
activeSessions,
|
||||
attentionSessionIds,
|
||||
onProjectSelect,
|
||||
onSessionSelect,
|
||||
onNewSession,
|
||||
@@ -164,7 +163,6 @@ function Sidebar({
|
||||
getProjectSessions,
|
||||
loadingMoreProjects,
|
||||
activeSessions,
|
||||
attentionSessionIds,
|
||||
forceExpanded: searchMode === 'running',
|
||||
isProjectStarred,
|
||||
onEditingNameChange: setEditingName,
|
||||
|
||||
@@ -45,7 +45,6 @@ type SidebarProjectItemProps = {
|
||||
) => void;
|
||||
onLoadMoreSessions: (projectId: string) => void;
|
||||
activeSessions: SessionActivityMap;
|
||||
attentionSessionIds: ReadonlySet<string>;
|
||||
onNewSession: (project: Project) => void;
|
||||
onEditingSessionNameChange: (value: string) => void;
|
||||
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||
@@ -88,7 +87,6 @@ export default function SidebarProjectItem({
|
||||
onDeleteSession,
|
||||
onLoadMoreSessions,
|
||||
activeSessions,
|
||||
attentionSessionIds,
|
||||
onNewSession,
|
||||
onEditingSessionNameChange,
|
||||
onStartEditingSession,
|
||||
@@ -401,7 +399,6 @@ export default function SidebarProjectItem({
|
||||
hasMoreSessions={Boolean(project.sessionMeta?.hasMore)}
|
||||
isLoadingMoreSessions={isLoadingMoreSessions}
|
||||
activeSessions={activeSessions}
|
||||
attentionSessionIds={attentionSessionIds}
|
||||
currentTime={currentTime}
|
||||
editingSession={editingSession}
|
||||
editingSessionName={editingSessionName}
|
||||
|
||||
@@ -29,7 +29,6 @@ export type SidebarProjectListProps = {
|
||||
onLoadMoreSessions: (projectId: string) => void;
|
||||
loadingMoreProjects: Set<string>;
|
||||
activeSessions: SessionActivityMap;
|
||||
attentionSessionIds: ReadonlySet<string>;
|
||||
forceExpanded?: boolean;
|
||||
isProjectStarred: (projectName: string) => boolean;
|
||||
onEditingNameChange: (value: string) => void;
|
||||
@@ -76,7 +75,6 @@ export default function SidebarProjectList({
|
||||
onLoadMoreSessions,
|
||||
loadingMoreProjects,
|
||||
activeSessions,
|
||||
attentionSessionIds,
|
||||
forceExpanded = false,
|
||||
isProjectStarred,
|
||||
onEditingNameChange,
|
||||
@@ -154,7 +152,6 @@ export default function SidebarProjectList({
|
||||
onDeleteSession={onDeleteSession}
|
||||
onLoadMoreSessions={onLoadMoreSessions}
|
||||
activeSessions={activeSessions}
|
||||
attentionSessionIds={attentionSessionIds}
|
||||
onNewSession={onNewSession}
|
||||
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||
onStartEditingSession={onStartEditingSession}
|
||||
|
||||
@@ -17,7 +17,6 @@ type SidebarProjectSessionsProps = {
|
||||
hasMoreSessions: boolean;
|
||||
isLoadingMoreSessions: boolean;
|
||||
activeSessions: SessionActivityMap;
|
||||
attentionSessionIds: ReadonlySet<string>;
|
||||
currentTime: Date;
|
||||
editingSession: string | null;
|
||||
editingSessionName: string;
|
||||
@@ -65,7 +64,6 @@ export default function SidebarProjectSessions({
|
||||
hasMoreSessions,
|
||||
isLoadingMoreSessions,
|
||||
activeSessions,
|
||||
attentionSessionIds,
|
||||
currentTime,
|
||||
editingSession,
|
||||
editingSessionName,
|
||||
@@ -126,7 +124,6 @@ export default function SidebarProjectSessions({
|
||||
session={session}
|
||||
selectedSession={selectedSession}
|
||||
isProcessing={activeSessions.has(session.id)}
|
||||
needsAttention={attentionSessionIds.has(session.id)}
|
||||
currentTime={currentTime}
|
||||
editingSession={editingSession}
|
||||
editingSessionName={editingSessionName}
|
||||
|
||||
@@ -14,7 +14,6 @@ type SidebarSessionItemProps = {
|
||||
session: SessionWithProvider;
|
||||
selectedSession: ProjectSession | null;
|
||||
isProcessing: boolean;
|
||||
needsAttention: boolean;
|
||||
currentTime: Date;
|
||||
editingSession: string | null;
|
||||
editingSessionName: string;
|
||||
@@ -66,7 +65,6 @@ export default function SidebarSessionItem({
|
||||
session,
|
||||
selectedSession,
|
||||
isProcessing,
|
||||
needsAttention,
|
||||
currentTime,
|
||||
editingSession,
|
||||
editingSessionName,
|
||||
@@ -84,8 +82,7 @@ export default function SidebarSessionItem({
|
||||
const isEditing = editingSession === session.id;
|
||||
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
|
||||
const editingContainerRef = useRef<HTMLDivElement>(null);
|
||||
const showAttentionIndicator = needsAttention && !isSelected;
|
||||
const showRecentIndicator = !showAttentionIndicator && !isProcessing && sessionView.isActive;
|
||||
const showRecentIndicator = !isProcessing && sessionView.isActive;
|
||||
|
||||
// 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
|
||||
@@ -123,23 +120,13 @@ export default function SidebarSessionItem({
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
{(showAttentionIndicator || showRecentIndicator) && (
|
||||
{showRecentIndicator && (
|
||||
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
|
||||
<Tooltip
|
||||
content={showAttentionIndicator
|
||||
? t('tooltips.attentionRequiredIndicator', { defaultValue: 'Session needs attention' })
|
||||
: t('tooltips.activeSessionIndicator')}
|
||||
position="right"
|
||||
>
|
||||
<Tooltip content={t('tooltips.activeSessionIndicator')} position="right">
|
||||
<div
|
||||
role="status"
|
||||
aria-label={showAttentionIndicator
|
||||
? t('tooltips.attentionRequiredIndicator', { defaultValue: 'Session needs attention' })
|
||||
: t('tooltips.activeSessionIndicator')}
|
||||
className={cn(
|
||||
'h-2 w-2 animate-pulse rounded-full',
|
||||
showAttentionIndicator ? 'bg-amber-500' : 'bg-green-500',
|
||||
)}
|
||||
aria-label={t('tooltips.activeSessionIndicator')}
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-green-500"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
FileUp,
|
||||
FolderUp,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Upload,
|
||||
@@ -19,9 +18,11 @@ import { cn } from '../../../lib/utils';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
} from '../../../shared/view/ui';
|
||||
import { useProviderSkills } from '../hooks/useProviderSkills';
|
||||
@@ -214,12 +215,9 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
||||
const [queuedFiles, setQueuedFiles] = useState<QueuedSkillFile[]>([]);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [justInstalled, setJustInstalled] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [showInstallPath, setShowInstallPath] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const providerName = PROVIDER_NAMES[selectedProvider];
|
||||
const providerPath = selectedProvider === 'opencode' ? null : PROVIDER_SKILL_PATHS[selectedProvider];
|
||||
@@ -229,19 +227,11 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
||||
setSubmitError(null);
|
||||
setIsSubmitting(false);
|
||||
setSearchQuery('');
|
||||
setIsAddDialogOpen(false);
|
||||
setShowInstallPath(false);
|
||||
setJustInstalled(false);
|
||||
}, [selectedProvider]);
|
||||
|
||||
const setFolderInputRef = useCallback((node: HTMLInputElement | null) => {
|
||||
folderInputRef.current = node;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.setAttribute('webkitdirectory', '');
|
||||
node.setAttribute('directory', '');
|
||||
useEffect(() => {
|
||||
folderInputRef.current?.setAttribute('webkitdirectory', '');
|
||||
folderInputRef.current?.setAttribute('directory', '');
|
||||
}, []);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
@@ -364,8 +354,6 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
||||
})));
|
||||
await addSkills({ entries });
|
||||
setQueuedFiles([]);
|
||||
setJustInstalled(true);
|
||||
setIsAddDialogOpen(false);
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : 'Failed to import skills');
|
||||
} finally {
|
||||
@@ -373,381 +361,294 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
||||
}
|
||||
}, [addSkills, queuedFiles]);
|
||||
|
||||
const handleAddDialogOpenChange = useCallback((open: boolean) => {
|
||||
if (open) {
|
||||
setSubmitError(null);
|
||||
setShowInstallPath(false);
|
||||
setJustInstalled(false);
|
||||
setIsAddDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setQueuedFiles([]);
|
||||
setSubmitError(null);
|
||||
setShowInstallPath(false);
|
||||
setJustInstalled(false);
|
||||
setIsAddDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const uploadPanel = (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'rounded-lg border border-dashed p-4 transition-colors sm:p-5',
|
||||
isDragActive
|
||||
? 'border-foreground/40 bg-muted/35'
|
||||
: 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".md,text/markdown"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
handleDrop(Array.from(event.target.files ?? []));
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={setFolderInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
handleFolderSelection(Array.from(event.target.files ?? []));
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center">
|
||||
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-foreground">Drop a skill folder or SKILL.md</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Folders can include scripts, references, and assets.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<FileUp className="h-4 w-4" />
|
||||
Choose Files
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<FolderUp className="h-4 w-4" />
|
||||
Choose Folder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{queuedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground">Ready to install</div>
|
||||
<div className="grid gap-2">
|
||||
{queuedFiles.map((queuedFile) => (
|
||||
<div
|
||||
key={queuedFile.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-border/70 bg-background/70 px-3 py-2"
|
||||
>
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
|
||||
{queuedFile.kind === 'folder' ? <FolderUp className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{queuedFile.kind === 'folder'
|
||||
? `${queuedFile.files.length} files`
|
||||
: 'Markdown file'}
|
||||
{' · '}
|
||||
{formatFileSize(queuedFile.size)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 flex-shrink-0 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Remove ${queuedFile.name}`}
|
||||
onClick={() => {
|
||||
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{providerPath && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => setShowInstallPath((current) => !current)}
|
||||
>
|
||||
{showInstallPath ? 'Hide install location' : 'Where will this install?'}
|
||||
</button>
|
||||
{showInstallPath && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/15 p-3">
|
||||
<code className="block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-4 overflow-x-hidden">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground">
|
||||
<FileCode2 className="h-4 w-4" strokeWidth={1.7} />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h3 className="text-lg font-medium text-foreground">{t('tabs.skills', { defaultValue: 'Skills' })}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage {providerName} skills from local files, complete folders, and project-aware locations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative min-w-0 flex-1 sm:max-w-md">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search skills..."
|
||||
aria-label="Search skills"
|
||||
className="h-9 w-full pl-9 pr-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
aria-label="Clear skill search"
|
||||
className="absolute right-1.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground">
|
||||
<FileCode2 className="h-4 w-4" strokeWidth={1.7} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => handleAddDialogOpenChange(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Skill
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void refreshSkills({ force: true })}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={isLoading || isLoadingProjectScopes}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{isLoadingProjectScopes && (
|
||||
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Scanning project skills...
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h3 className="text-lg font-medium text-foreground">{t('tabs.skills', { defaultValue: 'Skills' })}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Install global {providerName} skills from `.md` files or complete skill folders.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={handleAddDialogOpenChange}>
|
||||
<DialogContent
|
||||
wrapperClassName="z-[10000]"
|
||||
className="flex h-[calc(100vh-2rem)] max-h-[760px] w-[calc(100vw-2rem)] max-w-4xl flex-col overflow-hidden p-0 sm:h-[720px]"
|
||||
<Button
|
||||
onClick={() => void refreshSkills({ force: true })}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={isLoading || isLoadingProjectScopes}
|
||||
>
|
||||
<DialogTitle>Add {providerName} Skill</DialogTitle>
|
||||
<div className="flex-shrink-0 border-b border-border/60 px-4 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground">
|
||||
<FileUp className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-medium text-foreground">Add {providerName} Skill</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Upload a SKILL.md file or a complete skill folder.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close add skill dialog"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => handleAddDialogOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden border-border/70 bg-background shadow-sm">
|
||||
<CardHeader className="space-y-3 border-b border-border/60 bg-muted/20">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div className="text-sm font-medium text-foreground">Upload Skills</div>
|
||||
<div className="min-w-0 rounded-2xl border border-border/60 bg-background/70 p-3">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Install Path</div>
|
||||
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{uploadPanel}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 flex-col gap-3 border-t border-border/60 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
{(submitError || loadError || (justInstalled && saveStatus === 'success')) ? (
|
||||
<div className={cn(
|
||||
'max-h-24 overflow-y-auto whitespace-pre-wrap rounded-lg border px-3 py-2 text-sm',
|
||||
submitError || loadError
|
||||
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200'
|
||||
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
|
||||
)}>
|
||||
{submitError || loadError || 'Skills saved successfully.'}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
|
||||
</span>
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'rounded-3xl border border-dashed p-4 transition-colors sm:p-5',
|
||||
isDragActive
|
||||
? 'border-foreground/40 bg-muted/35'
|
||||
: 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".md,text/markdown"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
handleDrop(Array.from(event.target.files ?? []));
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
handleFolderSelection(Array.from(event.target.files ?? []));
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center sm:py-6">
|
||||
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-foreground">Drop `.md` files or skill folders here</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Upload standalone definitions or choose a full folder to include its scripts, references, and assets.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<FileUp className="h-4 w-4" />
|
||||
Choose Files
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<FolderUp className="h-4 w-4" />
|
||||
Choose Folder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center">
|
||||
|
||||
{queuedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground">Queued Files</div>
|
||||
<div className="grid gap-2">
|
||||
{queuedFiles.map((queuedFile) => (
|
||||
<div
|
||||
key={queuedFile.id}
|
||||
className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/70 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{queuedFile.kind === 'folder'
|
||||
? `${queuedFile.files.length} files`
|
||||
: 'Markdown file'}
|
||||
{' · '}
|
||||
{formatFileSize(queuedFile.size)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => handleAddDialogOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => void handleUploadInstall()}
|
||||
disabled={isSubmitting || queuedFiles.length === 0}
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skill'}
|
||||
Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skills'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{!isAddDialogOpen && (submitError || loadError) && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
|
||||
{submitError || loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{justInstalled && saveStatus === 'success' && !isAddDialogOpen && (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Skills saved successfully.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-5">
|
||||
{isLoading && skills.length === 0 && (
|
||||
<div className="flex min-h-[180px] items-center justify-center text-sm text-muted-foreground">
|
||||
Loading {providerName} skills…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && skills.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-lg border border-border/60 bg-background/80 text-muted-foreground">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="mt-4 text-sm font-medium text-foreground">No skills discovered yet</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Add a global skill above or create project-specific skill folders in your workspace.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && skills.length > 0 && filteredSkills.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
|
||||
<Search className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||
<div className="mt-3 text-sm font-medium text-foreground">No matching skills</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Try a different command, name, scope, project, or source path.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedSkills.map((group) => (
|
||||
<section key={group.scope} className="min-w-0 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={cn('rounded-full px-2.5 py-1 text-xs', SCOPE_BADGE_CLASSES[group.scope])}>
|
||||
{SCOPE_LABELS[group.scope]}
|
||||
</Badge>
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{group.skills.length} skill{group.skills.length === 1 ? '' : 's'}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-3 lg:grid-cols-2">
|
||||
{group.skills.map((skill) => (
|
||||
<div
|
||||
key={`${skill.command}:${skill.sourcePath}:${skill.projectPath || 'global'}`}
|
||||
className="min-w-0 rounded-lg border border-border bg-card/50 p-4"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="break-all font-mono text-sm font-semibold text-foreground">{skill.command}</div>
|
||||
<div className="text-sm text-muted-foreground">{skill.name}</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
{skill.description || 'No description provided in the skill front matter.'}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{skill.pluginName && (
|
||||
<Badge variant="outline" className="rounded-full bg-background/70">
|
||||
Plugin: {skill.pluginName}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.projectDisplayName && (
|
||||
<Badge variant="outline" className="rounded-full bg-background/70">
|
||||
Project: {skill.projectDisplayName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 min-w-0 rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Source</div>
|
||||
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{skill.sourcePath}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(submitError || loadError) && (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
|
||||
{submitError || loadError}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveStatus === 'success' && (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Skills saved successfully.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 border-border/70 bg-background/80 shadow-sm">
|
||||
<CardHeader className="border-b border-border/60">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<CardTitle>Visible Skills</CardTitle>
|
||||
<CardDescription>
|
||||
The list below comes from the provider skill discovery API and includes global and project-aware locations.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="relative w-full lg:w-72">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search skills..."
|
||||
aria-label="Search visible skills"
|
||||
className="h-9 w-full pl-9 pr-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
aria-label="Clear skill search"
|
||||
className="absolute right-1.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isLoadingProjectScopes && (
|
||||
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Scanning project skills…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5 p-4">
|
||||
{isLoading && skills.length === 0 && (
|
||||
<div className="flex min-h-[180px] items-center justify-center text-sm text-muted-foreground">
|
||||
Loading {providerName} skills…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && skills.length === 0 && (
|
||||
<div className="rounded-3xl border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl border border-border/60 bg-background/80 text-muted-foreground">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="mt-4 text-sm font-medium text-foreground">No skills discovered yet</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Add a global skill above or create project-specific skill folders in your workspace.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && skills.length > 0 && filteredSkills.length === 0 && (
|
||||
<div className="rounded-3xl border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
|
||||
<Search className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||
<div className="mt-3 text-sm font-medium text-foreground">No matching skills</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Try a different command, name, scope, project, or source path.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedSkills.map((group) => (
|
||||
<section key={group.scope} className="min-w-0 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={cn('rounded-full px-2.5 py-1 text-xs', SCOPE_BADGE_CLASSES[group.scope])}>
|
||||
{SCOPE_LABELS[group.scope]}
|
||||
</Badge>
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{group.skills.length} skill{group.skills.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-3 lg:grid-cols-2">
|
||||
{group.skills.map((skill) => (
|
||||
<div
|
||||
key={`${skill.command}:${skill.sourcePath}:${skill.projectPath || 'global'}`}
|
||||
className="min-w-0 rounded-3xl border border-border/70 bg-gradient-to-br from-background via-background to-muted/25 p-4 shadow-sm"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="break-all font-mono text-sm font-semibold text-foreground">{skill.command}</div>
|
||||
<div className="text-sm text-muted-foreground">{skill.name}</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
{skill.description || 'No description provided in the skill front matter.'}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{skill.pluginName && (
|
||||
<Badge variant="outline" className="rounded-full bg-background/70">
|
||||
Plugin: {skill.pluginName}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.projectDisplayName && (
|
||||
<Badge variant="outline" className="rounded-full bg-background/70">
|
||||
Project: {skill.projectDisplayName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 min-w-0 rounded-2xl border border-border/60 bg-muted/20 px-3 py-2">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Source</div>
|
||||
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{skill.sourcePath}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -352,7 +352,6 @@ export function useProjectsState({
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [selectedSession, setSelectedSession] = useState<ProjectSession | null>(null);
|
||||
const [attentionSessionIds, setAttentionSessionIds] = useState<Set<string>>(new Set());
|
||||
const [activeTab, setActiveTab] = useState<AppTab>(readPersistedTab);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -406,43 +405,6 @@ export function useProjectsState({
|
||||
const activeSessionsRef = useRef(activeSessions);
|
||||
activeSessionsRef.current = activeSessions;
|
||||
|
||||
const markSessionAttention = useCallback((targetSessionId?: string | null) => {
|
||||
if (!targetSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewedSessionId = selectedSessionRef.current?.id ?? sessionId ?? null;
|
||||
if (targetSessionId === viewedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAttentionSessionIds((previous) => {
|
||||
if (previous.has(targetSessionId)) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const next = new Set(previous);
|
||||
next.add(targetSessionId);
|
||||
return next;
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
const clearSessionAttention = useCallback((targetSessionId?: string | null) => {
|
||||
if (!targetSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAttentionSessionIds((previous) => {
|
||||
if (!previous.has(targetSessionId)) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const next = new Set(previous);
|
||||
next.delete(targetSessionId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => {
|
||||
try {
|
||||
if (showLoadingState) {
|
||||
@@ -636,25 +598,6 @@ export function useProjectsState({
|
||||
return;
|
||||
}
|
||||
|
||||
const eventSessionId = typeof event.sessionId === 'string' && event.sessionId
|
||||
? event.sessionId
|
||||
: null;
|
||||
const viewedSessionId = selectedSessionRef.current?.id ?? sessionId ?? null;
|
||||
|
||||
if (
|
||||
eventSessionId
|
||||
&& eventSessionId !== viewedSessionId
|
||||
&& event.kind !== 'chat_subscribed'
|
||||
&& event.kind !== 'loading_progress'
|
||||
&& event.kind !== 'session_upserted'
|
||||
&& event.kind !== 'status'
|
||||
&& event.kind !== 'stream_end'
|
||||
&& event.kind !== 'permission_cancelled'
|
||||
&& event.kind !== 'websocket_reconnected'
|
||||
) {
|
||||
markSessionAttention(eventSessionId);
|
||||
}
|
||||
|
||||
if (event.kind !== 'session_upserted') {
|
||||
return;
|
||||
}
|
||||
@@ -674,8 +617,6 @@ export function useProjectsState({
|
||||
&& !activeSessionsRef.current.has(upsert.sessionId)
|
||||
) {
|
||||
setExternalMessageUpdate((prev) => prev + 1);
|
||||
} else {
|
||||
markSessionAttention(upsert.sessionId);
|
||||
}
|
||||
|
||||
setProjects((previousProjects) => {
|
||||
@@ -761,7 +702,7 @@ export function useProjectsState({
|
||||
};
|
||||
|
||||
return subscribe(handleEvent);
|
||||
}, [markSessionAttention, navigate, sessionId, subscribe]);
|
||||
}, [navigate, sessionId, subscribe]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -772,10 +713,6 @@ export function useProjectsState({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
clearSessionAttention(selectedSession?.id ?? sessionId ?? null);
|
||||
}, [clearSessionAttention, selectedSession?.id, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || projects.length === 0) {
|
||||
return;
|
||||
@@ -810,19 +747,14 @@ export function useProjectsState({
|
||||
return;
|
||||
}
|
||||
|
||||
const projectForSession = selectedProject || projects.find((p) => (p.sessions?.length ?? 0) > 0);
|
||||
if (!projectForSession) {
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedProject?.projectId !== projectForSession.projectId) {
|
||||
setSelectedProject(projectForSession);
|
||||
}
|
||||
|
||||
setSelectedSession({
|
||||
id: sessionId,
|
||||
__provider: readSelectedProvider(),
|
||||
__projectId: projectForSession.projectId,
|
||||
__projectId: selectedProject.projectId,
|
||||
summary: '',
|
||||
});
|
||||
}, [sessionId, projects, selectedProject, selectedSession?.id, selectedSession?.__provider]);
|
||||
@@ -842,7 +774,6 @@ export function useProjectsState({
|
||||
|
||||
const handleSessionSelect = useCallback(
|
||||
(session: ProjectSession) => {
|
||||
clearSessionAttention(session.id);
|
||||
setSelectedSession(session);
|
||||
|
||||
if (activeTab === 'tasks' || activeTab === 'browser') {
|
||||
@@ -864,7 +795,7 @@ export function useProjectsState({
|
||||
|
||||
navigate(`/session/${session.id}`);
|
||||
},
|
||||
[activeTab, clearSessionAttention, isMobile, navigate, selectedProject?.projectId],
|
||||
[activeTab, isMobile, navigate, selectedProject?.projectId],
|
||||
);
|
||||
|
||||
const handleNewSession = useCallback(
|
||||
@@ -884,8 +815,6 @@ export function useProjectsState({
|
||||
|
||||
const handleSessionDelete = useCallback(
|
||||
(sessionIdToDelete: string) => {
|
||||
clearSessionAttention(sessionIdToDelete);
|
||||
|
||||
if (selectedSession?.id === sessionIdToDelete) {
|
||||
setSelectedSession(null);
|
||||
navigate('/');
|
||||
@@ -895,7 +824,7 @@ export function useProjectsState({
|
||||
prevProjects.map((project) => removeSessionFromProject(project, sessionIdToDelete)),
|
||||
);
|
||||
},
|
||||
[clearSessionAttention, navigate, selectedSession?.id],
|
||||
[navigate, selectedSession?.id],
|
||||
);
|
||||
|
||||
const handleSidebarRefresh = useCallback(async () => {
|
||||
@@ -1016,7 +945,6 @@ export function useProjectsState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
activeSessions,
|
||||
attentionSessionIds,
|
||||
onProjectSelect: handleProjectSelect,
|
||||
onSessionSelect: handleSessionSelect,
|
||||
onNewSession: handleNewSession,
|
||||
@@ -1033,7 +961,6 @@ export function useProjectsState({
|
||||
isMobile,
|
||||
}),
|
||||
[
|
||||
attentionSessionIds,
|
||||
handleNewSession,
|
||||
handleProjectDelete,
|
||||
handleProjectSelect,
|
||||
|
||||
@@ -140,22 +140,12 @@
|
||||
"stop": "Stop",
|
||||
"hintText": {
|
||||
"ctrlEnter": "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands",
|
||||
"enter": "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands",
|
||||
"queue": "Enter to queue your next message",
|
||||
"updateQueued": "Enter to update queued message"
|
||||
"enter": "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
|
||||
},
|
||||
"clickToChangeMode": "Click to change permission mode (or press Tab in input)",
|
||||
"showAllCommands": "Show all commands",
|
||||
"clearInput": "Clear input",
|
||||
"scrollToBottom": "Scroll to bottom",
|
||||
"queue": {
|
||||
"sendNext": "Queue next message",
|
||||
"update": "Update queued message",
|
||||
"label": "Queued",
|
||||
"willSend": "Will send when this finishes",
|
||||
"edit": "Edit queued message",
|
||||
"delete": "Delete queued message"
|
||||
}
|
||||
"scrollToBottom": "Scroll to bottom"
|
||||
},
|
||||
"providerSelection": {
|
||||
"title": "Choose Your AI Assistant",
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronDown, Loader2, type LucideIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../../lib/utils';
|
||||
|
||||
import { Button } from './Button';
|
||||
|
||||
type ButtonVariant = 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||
type ButtonSize = 'default' | 'sm' | 'lg' | 'icon';
|
||||
|
||||
export type ActionMenuItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
isDanger?: boolean;
|
||||
showDividerBefore?: boolean;
|
||||
};
|
||||
|
||||
type ActionMenuProps = {
|
||||
label: string;
|
||||
items: ActionMenuItem[];
|
||||
icon?: LucideIcon;
|
||||
ariaLabel?: string;
|
||||
align?: 'left' | 'right';
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function ActionMenu({
|
||||
label,
|
||||
items,
|
||||
icon: TriggerIcon,
|
||||
ariaLabel,
|
||||
align = 'right',
|
||||
variant = 'outline',
|
||||
size = 'sm',
|
||||
className,
|
||||
triggerClassName,
|
||||
disabled,
|
||||
}: ActionMenuProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const menuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
// Whether closing should move focus back to the trigger. Set for keyboard
|
||||
// (Escape) and item selection, but left false for outside pointer clicks so
|
||||
// focus is not stolen from wherever the user clicked.
|
||||
const restoreFocusRef = React.useRef(false);
|
||||
const wasOpenRef = React.useRef(false);
|
||||
const menuId = React.useId();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (rootRef.current && !rootRef.current.contains(target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
restoreFocusRef.current = true;
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', closeOnOutsideClick);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', closeOnOutsideClick);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Move focus into the menu on open and back to the trigger on a keyboard or
|
||||
// selection close, so keyboard and screen-reader navigation match the menu role.
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
wasOpenRef.current = true;
|
||||
const menu = menuRef.current;
|
||||
const firstItem = menu?.querySelector<HTMLButtonElement>('[role="menuitem"]:not([disabled])');
|
||||
(firstItem ?? menu)?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasOpenRef.current) {
|
||||
wasOpenRef.current = false;
|
||||
if (restoreFocusRef.current) {
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
restoreFocusRef.current = false;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const runItem = (item: ActionMenuItem) => {
|
||||
if (item.disabled || item.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
restoreFocusRef.current = true;
|
||||
setIsOpen(false);
|
||||
item.onSelect();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={cn('relative inline-flex', className)}>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={triggerClassName}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={isOpen ? menuId : undefined}
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
>
|
||||
{TriggerIcon && <TriggerIcon className="h-4 w-4" />}
|
||||
<span>{label}</span>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', isOpen && 'rotate-180')} />
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
id={menuId}
|
||||
role="menu"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'absolute top-full z-50 mt-2 min-w-[220px] rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
align === 'right' ? 'right-0' : 'left-0',
|
||||
)}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<React.Fragment key={item.key}>
|
||||
{item.showDividerBefore && <div className="mx-2 my-1 h-px bg-border" />}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled || item.loading}
|
||||
onClick={() => runItem(item)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors',
|
||||
'focus:bg-accent focus:outline-none',
|
||||
item.disabled || item.loading
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: item.isDanger
|
||||
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950'
|
||||
: 'hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
{item.loading ? (
|
||||
<Loader2 className="mt-0.5 h-4 w-4 flex-shrink-0 animate-spin" />
|
||||
) : (
|
||||
Icon && <Icon className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium leading-5">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="mt-0.5 block text-xs leading-4 text-muted-foreground">
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,13 +92,12 @@ DialogTrigger.displayName = 'DialogTrigger';
|
||||
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onEscapeKeyDown?: () => void;
|
||||
onPointerDownOutside?: () => void;
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
({ className, children, onEscapeKeyDown, onPointerDownOutside, wrapperClassName, ...props }, ref) => {
|
||||
({ className, children, onEscapeKeyDown, onPointerDownOutside, ...props }, ref) => {
|
||||
const { open, onOpenChange, triggerRef } = useDialog();
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
@@ -172,7 +171,7 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className={cn('fixed inset-0 z-50', wrapperClassName)}>
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="fixed inset-0 animate-dialog-overlay-show bg-black/50 backdrop-blur-sm"
|
||||
|
||||
@@ -202,7 +202,7 @@ export const PromptInputSubmit = React.forwardRef<HTMLButtonElement, PromptInput
|
||||
type={isActive ? 'button' : 'submit'}
|
||||
variant="default"
|
||||
size="icon"
|
||||
className={cn('h-8 w-8 shrink-0 rounded-lg', className)}
|
||||
className={cn('h-8 w-8 rounded-lg', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (isActive ? (
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
export { Alert, AlertTitle, AlertDescription, alertVariants } from './Alert';
|
||||
export { default as ActionMenu } from './ActionMenu';
|
||||
export type { ActionMenuItem } from './ActionMenu';
|
||||
export { Badge, badgeVariants } from './Badge';
|
||||
export { Button, buttonVariants } from './Button';
|
||||
export { Confirmation, ConfirmationTitle, ConfirmationRequest, ConfirmationAccepted, ConfirmationRejected, ConfirmationActions, ConfirmationAction } from './Confirmation';
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface NormalizedMessage {
|
||||
isLocalCommand?: boolean;
|
||||
isLocalCommandStdout?: boolean;
|
||||
isCompactSummary?: boolean;
|
||||
images?: Array<{ path?: string; data?: string; name?: string }>;
|
||||
images?: string[];
|
||||
toolName?: string;
|
||||
toolInput?: unknown;
|
||||
toolId?: string;
|
||||
|
||||
Reference in New Issue
Block a user