mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-02 23:07:35 +00:00
Compare commits
54 Commits
fix/turn-o
...
v1.16.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9c7321c8c | ||
|
|
88bda6e5c0 | ||
|
|
86b421c790 | ||
|
|
41ef84c283 | ||
|
|
53224e47b6 | ||
|
|
bbb51dbf99 | ||
|
|
2d06cae0ca | ||
|
|
14fb81586c | ||
|
|
4d2b592ec6 | ||
|
|
4957220a05 | ||
|
|
3debc3a249 | ||
|
|
5512e2e15b | ||
|
|
1b42dba902 | ||
|
|
ede56ad81b | ||
|
|
36094fb73f | ||
|
|
57828653bf | ||
|
|
8ef0951901 | ||
|
|
ab50c5c1a8 | ||
|
|
6726e8f44e | ||
|
|
07f89e5240 | ||
|
|
8a675a713b | ||
|
|
5724c11253 | ||
|
|
c7b9976986 | ||
|
|
f16e3e763d | ||
|
|
477bc404b0 | ||
|
|
ae5a21cd6e | ||
|
|
b2c69d6ea8 | ||
|
|
8825baf5b4 | ||
|
|
0d1a3df1f7 | ||
|
|
80732923b5 | ||
|
|
6362d35d66 | ||
|
|
10bfeed614 | ||
|
|
dab089b29f | ||
|
|
38745bdf85 | ||
|
|
9da7c1cbae | ||
|
|
844677caee | ||
|
|
e1c67fd5d0 | ||
|
|
9cd0cfc88f | ||
|
|
09f1021c59 | ||
|
|
053d94ab9d | ||
|
|
e85cc746b1 | ||
|
|
cc3368c591 | ||
|
|
5131d2ae27 | ||
|
|
394b95ae29 | ||
|
|
4948aa3d64 | ||
|
|
6e07f140e3 | ||
|
|
fea8e30725 | ||
|
|
9f534ce15b | ||
|
|
8cb34a73b5 | ||
|
|
74640a7f31 | ||
|
|
9cd1b5811a | ||
|
|
ddb26c7652 | ||
|
|
b3c6e95971 | ||
|
|
9da8e69476 |
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
|
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
|
||||||
<h1>Cloud CLI (aka Claude Code UI)</h1>
|
<h1>Cloud CLI (又名 Claude Code UI)</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
37
package-lock.json
generated
37
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.13.6",
|
"version": "1.16.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.13.6",
|
"version": "1.16.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
||||||
@@ -68,6 +68,7 @@
|
|||||||
"cloudcli": "server/cli.js"
|
"cloudcli": "server/cli.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.19.7",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
"release-it": "^19.0.5",
|
"release-it": "^19.0.5",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2906,6 +2908,16 @@
|
|||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||||
|
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/parse-path": {
|
"node_modules/@types/parse-path": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
|
||||||
@@ -11662,6 +11674,20 @@
|
|||||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/uglify-js": {
|
"node_modules/uglify-js": {
|
||||||
"version": "3.19.3",
|
"version": "3.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||||
@@ -11686,6 +11712,13 @@
|
|||||||
"node": ">=18.17"
|
"node": ">=18.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unified": {
|
"node_modules/unified": {
|
||||||
"version": "11.0.5",
|
"version": "11.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.13.6",
|
"version": "1.16.2",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"dist/",
|
"dist/",
|
||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"homepage": "https://claudecodeui.siteboon.ai",
|
"homepage": "https://cloudcli.ai",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/siteboon/claudecodeui.git"
|
"url": "git+https://github.com/siteboon/claudecodeui.git"
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"client": "vite --host",
|
"client": "vite --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"start": "npm run build && npm run server",
|
"start": "npm run build && npm run server",
|
||||||
"release": "./release.sh"
|
"release": "./release.sh"
|
||||||
},
|
},
|
||||||
@@ -96,6 +97,7 @@
|
|||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.19.7",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
@@ -107,6 +109,7 @@
|
|||||||
"release-it": "^19.0.5",
|
"release-it": "^19.0.5",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -603,7 +603,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
const transformedMessage = transformMessage(message);
|
const transformedMessage = transformMessage(message);
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'claude-response',
|
type: 'claude-response',
|
||||||
data: transformedMessage
|
data: transformedMessage,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract and send token budget updates from result messages
|
// Extract and send token budget updates from result messages
|
||||||
@@ -613,7 +614,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
console.log('Token budget from modelUsage:', tokenBudget);
|
console.log('Token budget from modelUsage:', tokenBudget);
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'token-budget',
|
type: 'token-budget',
|
||||||
data: tokenBudget
|
data: tokenBudget,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -651,7 +653,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
// Send error to WebSocket
|
// Send error to WebSocket
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'claude-error',
|
type: 'claude-error',
|
||||||
error: error.message
|
error: error.message,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
// Send system info to frontend
|
// Send system info to frontend
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'cursor-system',
|
type: 'cursor-system',
|
||||||
data: response
|
data: response,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -123,7 +124,8 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
// Forward user message
|
// Forward user message
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'cursor-user',
|
type: 'cursor-user',
|
||||||
data: response
|
data: response,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -142,7 +144,8 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
type: 'text_delta',
|
type: 'text_delta',
|
||||||
text: textContent
|
text: textContent
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -157,7 +160,8 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
type: 'claude-response',
|
type: 'claude-response',
|
||||||
data: {
|
data: {
|
||||||
type: 'content_block_stop'
|
type: 'content_block_stop'
|
||||||
}
|
},
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +178,8 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
// Forward any other message types
|
// Forward any other message types
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'cursor-response',
|
type: 'cursor-response',
|
||||||
data: response
|
data: response,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
@@ -182,7 +187,8 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
// If not JSON, send as raw text
|
// If not JSON, send as raw text
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'cursor-output',
|
type: 'cursor-output',
|
||||||
data: line
|
data: line,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,7 +199,8 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
console.error('Cursor CLI stderr:', data.toString());
|
console.error('Cursor CLI stderr:', data.toString());
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'cursor-error',
|
type: 'cursor-error',
|
||||||
error: data.toString()
|
error: data.toString(),
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,7 +236,8 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
|
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'cursor-error',
|
type: 'cursor-error',
|
||||||
error: error.message
|
error: error.message,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
|
|
||||||
reject(error);
|
reject(error);
|
||||||
@@ -264,4 +272,4 @@ export {
|
|||||||
abortCursorSession,
|
abortCursorSession,
|
||||||
isCursorSessionActive,
|
isCursorSessionActive,
|
||||||
getActiveCursorSessions
|
getActiveCursorSessions
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ import mcpUtilsRoutes from './routes/mcp-utils.js';
|
|||||||
import commandsRoutes from './routes/commands.js';
|
import commandsRoutes from './routes/commands.js';
|
||||||
import settingsRoutes from './routes/settings.js';
|
import settingsRoutes from './routes/settings.js';
|
||||||
import agentRoutes from './routes/agent.js';
|
import agentRoutes from './routes/agent.js';
|
||||||
import projectsRoutes from './routes/projects.js';
|
import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
|
||||||
import cliAuthRoutes from './routes/cli-auth.js';
|
import cliAuthRoutes from './routes/cli-auth.js';
|
||||||
import userRoutes from './routes/user.js';
|
import userRoutes from './routes/user.js';
|
||||||
import codexRoutes from './routes/codex.js';
|
import codexRoutes from './routes/codex.js';
|
||||||
@@ -455,11 +455,12 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete project endpoint (only if empty)
|
// Delete project endpoint (force=true to delete with sessions)
|
||||||
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
|
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { projectName } = req.params;
|
const { projectName } = req.params;
|
||||||
await deleteProject(projectName);
|
const force = req.query.force === 'true';
|
||||||
|
await deleteProject(projectName, force);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -483,22 +484,42 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const expandWorkspacePath = (inputPath) => {
|
||||||
|
if (!inputPath) return inputPath;
|
||||||
|
if (inputPath === '~') {
|
||||||
|
return WORKSPACES_ROOT;
|
||||||
|
}
|
||||||
|
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
|
||||||
|
return path.join(WORKSPACES_ROOT, inputPath.slice(2));
|
||||||
|
}
|
||||||
|
return inputPath;
|
||||||
|
};
|
||||||
|
|
||||||
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
|
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
|
||||||
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { path: dirPath } = req.query;
|
const { path: dirPath } = req.query;
|
||||||
|
|
||||||
|
console.log('[API] Browse filesystem request for path:', dirPath);
|
||||||
|
console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
|
||||||
// Default to home directory if no path provided
|
// Default to home directory if no path provided
|
||||||
const homeDir = os.homedir();
|
const defaultRoot = WORKSPACES_ROOT;
|
||||||
let targetPath = dirPath ? dirPath.replace('~', homeDir) : homeDir;
|
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
|
||||||
|
|
||||||
// Resolve and normalize the path
|
// Resolve and normalize the path
|
||||||
targetPath = path.resolve(targetPath);
|
targetPath = path.resolve(targetPath);
|
||||||
|
|
||||||
|
// Security check - ensure path is within allowed workspace root
|
||||||
|
const validation = await validateWorkspacePath(targetPath);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return res.status(403).json({ error: validation.error });
|
||||||
|
}
|
||||||
|
const resolvedPath = validation.resolvedPath || targetPath;
|
||||||
|
|
||||||
// Security check - ensure path is accessible
|
// Security check - ensure path is accessible
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(targetPath);
|
await fs.promises.access(resolvedPath);
|
||||||
const stats = await fs.promises.stat(targetPath);
|
const stats = await fs.promises.stat(resolvedPath);
|
||||||
|
|
||||||
if (!stats.isDirectory()) {
|
if (!stats.isDirectory()) {
|
||||||
return res.status(400).json({ error: 'Path is not a directory' });
|
return res.status(400).json({ error: 'Path is not a directory' });
|
||||||
@@ -508,7 +529,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use existing getFileTree function with shallow depth (only direct children)
|
// Use existing getFileTree function with shallow depth (only direct children)
|
||||||
const fileTree = await getFileTree(targetPath, 1, 0, false); // maxDepth=1, showHidden=false
|
const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
|
||||||
|
|
||||||
// Filter only directories and format for suggestions
|
// Filter only directories and format for suggestions
|
||||||
const directories = fileTree
|
const directories = fileTree
|
||||||
@@ -528,7 +549,13 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
// Add common directories if browsing home directory
|
// Add common directories if browsing home directory
|
||||||
const suggestions = [];
|
const suggestions = [];
|
||||||
if (targetPath === homeDir) {
|
let resolvedWorkspaceRoot = defaultRoot;
|
||||||
|
try {
|
||||||
|
resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
|
||||||
|
} catch (error) {
|
||||||
|
// Use default root as-is if realpath fails
|
||||||
|
}
|
||||||
|
if (resolvedPath === resolvedWorkspaceRoot) {
|
||||||
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
|
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
|
||||||
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
|
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
|
||||||
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
|
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
|
||||||
@@ -539,7 +566,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
path: targetPath,
|
path: resolvedPath,
|
||||||
suggestions: suggestions
|
suggestions: suggestions
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -549,6 +576,46 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/create-folder', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { path: folderPath } = req.body;
|
||||||
|
if (!folderPath) {
|
||||||
|
return res.status(400).json({ error: 'Path is required' });
|
||||||
|
}
|
||||||
|
const expandedPath = expandWorkspacePath(folderPath);
|
||||||
|
const resolvedInput = path.resolve(expandedPath);
|
||||||
|
const validation = await validateWorkspacePath(resolvedInput);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return res.status(403).json({ error: validation.error });
|
||||||
|
}
|
||||||
|
const targetPath = validation.resolvedPath || resolvedInput;
|
||||||
|
const parentDir = path.dirname(targetPath);
|
||||||
|
try {
|
||||||
|
await fs.promises.access(parentDir);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(404).json({ error: 'Parent directory does not exist' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.promises.access(targetPath);
|
||||||
|
return res.status(409).json({ error: 'Folder already exists' });
|
||||||
|
} catch (err) {
|
||||||
|
// Folder doesn't exist, which is what we want
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.promises.mkdir(targetPath, { recursive: false });
|
||||||
|
res.json({ success: true, path: targetPath });
|
||||||
|
} catch (mkdirError) {
|
||||||
|
if (mkdirError.code === 'EEXIST') {
|
||||||
|
return res.status(409).json({ error: 'Folder already exists' });
|
||||||
|
}
|
||||||
|
throw mkdirError;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating folder:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create folder' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Read file content endpoint
|
// Read file content endpoint
|
||||||
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ const authenticateToken = async (req, res, next) => {
|
|||||||
|
|
||||||
// Normal OSS JWT validation
|
// Normal OSS JWT validation
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||||
|
|
||||||
|
// Also check query param for SSE endpoints (EventSource can't set headers)
|
||||||
|
if (!token && req.query.token) {
|
||||||
|
token = req.query.token;
|
||||||
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return res.status(401).json({ error: 'Access denied. No token provided.' });
|
return res.status(401).json({ error: 'Access denied. No token provided.' });
|
||||||
|
|||||||
@@ -272,7 +272,8 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
data: {
|
data: {
|
||||||
used: totalTokens,
|
used: totalTokens,
|
||||||
total: 200000 // Default context window for Codex models
|
total: 200000 // Default context window for Codex models
|
||||||
}
|
},
|
||||||
|
sessionId: currentSessionId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1026,25 +1026,56 @@ async function isProjectEmpty(projectName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete an empty project
|
// Delete a project (force=true to delete even with sessions)
|
||||||
async function deleteProject(projectName) {
|
async function deleteProject(projectName, force = false) {
|
||||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First check if the project is empty
|
|
||||||
const isEmpty = await isProjectEmpty(projectName);
|
const isEmpty = await isProjectEmpty(projectName);
|
||||||
if (!isEmpty) {
|
if (!isEmpty && !force) {
|
||||||
throw new Error('Cannot delete project with existing sessions');
|
throw new Error('Cannot delete project with existing sessions');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the project directory
|
|
||||||
await fs.rm(projectDir, { recursive: true, force: true });
|
|
||||||
|
|
||||||
// Remove from project config
|
|
||||||
const config = await loadProjectConfig();
|
const config = await loadProjectConfig();
|
||||||
|
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
|
||||||
|
|
||||||
|
// Fallback to extractProjectDirectory if projectPath is not in config
|
||||||
|
if (!projectPath) {
|
||||||
|
projectPath = await extractProjectDirectory(projectName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the project directory (includes all Claude sessions)
|
||||||
|
await fs.rm(projectDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
// Delete all Codex sessions associated with this project
|
||||||
|
if (projectPath) {
|
||||||
|
try {
|
||||||
|
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
|
||||||
|
for (const session of codexSessions) {
|
||||||
|
try {
|
||||||
|
await deleteCodexSession(session.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to delete Codex sessions:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Cursor sessions directory if it exists
|
||||||
|
try {
|
||||||
|
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
|
||||||
|
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
|
||||||
|
await fs.rm(cursorProjectDir, { recursive: true, force: true });
|
||||||
|
} catch (err) {
|
||||||
|
// Cursor dir may not exist, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from project config
|
||||||
delete config[projectName];
|
delete config[projectName];
|
||||||
await saveProjectConfig(config);
|
await saveProjectConfig(config);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error deleting project ${projectName}:`, error);
|
console.error(`Error deleting project ${projectName}:`, error);
|
||||||
@@ -1055,17 +1086,17 @@ async function deleteProject(projectName) {
|
|||||||
// Add a project manually to the config (without creating folders)
|
// Add a project manually to the config (without creating folders)
|
||||||
async function addProjectManually(projectPath, displayName = null) {
|
async function addProjectManually(projectPath, displayName = null) {
|
||||||
const absolutePath = path.resolve(projectPath);
|
const absolutePath = path.resolve(projectPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if the path exists
|
// Check if the path exists
|
||||||
await fs.access(absolutePath);
|
await fs.access(absolutePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Path does not exist: ${absolutePath}`);
|
throw new Error(`Path does not exist: ${absolutePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate project name (encode path for use as directory name)
|
// Generate project name (encode path for use as directory name)
|
||||||
const projectName = absolutePath.replace(/\//g, '-');
|
const projectName = absolutePath.replace(/[\\/:\s~_]/g, '-');
|
||||||
|
|
||||||
// Check if project already exists in config
|
// Check if project already exists in config
|
||||||
const config = await loadProjectConfig();
|
const config = await loadProjectConfig();
|
||||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||||
@@ -1076,13 +1107,13 @@ async function addProjectManually(projectPath, displayName = null) {
|
|||||||
|
|
||||||
// Allow adding projects even if the directory exists - this enables tracking
|
// Allow adding projects even if the directory exists - this enables tracking
|
||||||
// existing Claude Code or Cursor projects in the UI
|
// existing Claude Code or Cursor projects in the UI
|
||||||
|
|
||||||
// Add to config as manually added project
|
// Add to config as manually added project
|
||||||
config[projectName] = {
|
config[projectName] = {
|
||||||
manuallyAdded: true,
|
manuallyAdded: true,
|
||||||
originalPath: absolutePath
|
originalPath: absolutePath
|
||||||
};
|
};
|
||||||
|
|
||||||
if (displayName) {
|
if (displayName) {
|
||||||
config[projectName].displayName = displayName;
|
config[projectName].displayName = displayName;
|
||||||
}
|
}
|
||||||
@@ -1214,7 +1245,8 @@ async function getCursorSessions(projectPath) {
|
|||||||
|
|
||||||
|
|
||||||
// Fetch Codex sessions for a given project path
|
// Fetch Codex sessions for a given project path
|
||||||
async function getCodexSessions(projectPath) {
|
async function getCodexSessions(projectPath, options = {}) {
|
||||||
|
const { limit = 5 } = options;
|
||||||
try {
|
try {
|
||||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||||
const sessions = [];
|
const sessions = [];
|
||||||
@@ -1279,8 +1311,8 @@ async function getCodexSessions(projectPath) {
|
|||||||
// Sort sessions by last activity (newest first)
|
// Sort sessions by last activity (newest first)
|
||||||
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
||||||
|
|
||||||
// Return only the first 5 sessions for performance
|
// Return limited sessions for performance (0 = unlimited for deletion)
|
||||||
return sessions.slice(0, 5);
|
return limit > 0 ? sessions.slice(0, limit) : sessions;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Codex sessions:', error);
|
console.error('Error fetching Codex sessions:', error);
|
||||||
|
|||||||
@@ -262,8 +262,7 @@ router.get('/mcp/config/read', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!configData) {
|
if (!configData) {
|
||||||
return res.json({ success: false, message: 'No Codex configuration file found', servers: [] });
|
return res.json({ success: true, configPath, servers: [] }); }
|
||||||
}
|
|
||||||
|
|
||||||
const servers = [];
|
const servers = [];
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ import { addProjectManually } from '../projects.js';
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
function sanitizeGitError(message, token) {
|
||||||
|
if (!message || !token) return message;
|
||||||
|
return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '***');
|
||||||
|
}
|
||||||
|
|
||||||
// Configure allowed workspace root (defaults to user's home directory)
|
// Configure allowed workspace root (defaults to user's home directory)
|
||||||
const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
|
export const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
|
||||||
|
|
||||||
// System-critical paths that should never be used as workspace directories
|
// System-critical paths that should never be used as workspace directories
|
||||||
const FORBIDDEN_PATHS = [
|
export const FORBIDDEN_PATHS = [
|
||||||
|
// Unix
|
||||||
'/',
|
'/',
|
||||||
'/etc',
|
'/etc',
|
||||||
'/bin',
|
'/bin',
|
||||||
@@ -27,7 +33,14 @@ const FORBIDDEN_PATHS = [
|
|||||||
'/lib64',
|
'/lib64',
|
||||||
'/opt',
|
'/opt',
|
||||||
'/tmp',
|
'/tmp',
|
||||||
'/run'
|
'/run',
|
||||||
|
// Windows
|
||||||
|
'C:\\Windows',
|
||||||
|
'C:\\Program Files',
|
||||||
|
'C:\\Program Files (x86)',
|
||||||
|
'C:\\ProgramData',
|
||||||
|
'C:\\System Volume Information',
|
||||||
|
'C:\\$Recycle.Bin'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +48,7 @@ const FORBIDDEN_PATHS = [
|
|||||||
* @param {string} requestedPath - The path to validate
|
* @param {string} requestedPath - The path to validate
|
||||||
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
|
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
|
||||||
*/
|
*/
|
||||||
async function validateWorkspacePath(requestedPath) {
|
export async function validateWorkspacePath(requestedPath) {
|
||||||
try {
|
try {
|
||||||
// Resolve to absolute path
|
// Resolve to absolute path
|
||||||
let absolutePath = path.resolve(requestedPath);
|
let absolutePath = path.resolve(requestedPath);
|
||||||
@@ -212,20 +225,7 @@ router.post('/create-workspace', async (req, res) => {
|
|||||||
|
|
||||||
// Handle new workspace creation
|
// Handle new workspace creation
|
||||||
if (workspaceType === 'new') {
|
if (workspaceType === 'new') {
|
||||||
// Check if path already exists
|
// Create the directory if it doesn't exist
|
||||||
try {
|
|
||||||
await fs.access(absolutePath);
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Path already exists. Please choose a different path or use "existing workspace" option.'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== 'ENOENT') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
// Path doesn't exist - good, we can create it
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the directory
|
|
||||||
await fs.mkdir(absolutePath, { recursive: true });
|
await fs.mkdir(absolutePath, { recursive: true });
|
||||||
|
|
||||||
// If GitHub URL is provided, clone the repository
|
// If GitHub URL is provided, clone the repository
|
||||||
@@ -246,30 +246,55 @@ router.post('/create-workspace', async (req, res) => {
|
|||||||
githubToken = newGithubToken;
|
githubToken = newGithubToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone the repository
|
// Extract repo name from URL for the clone destination
|
||||||
|
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
||||||
|
const repoName = normalizedUrl.split('/').pop() || 'repository';
|
||||||
|
const clonePath = path.join(absolutePath, repoName);
|
||||||
|
|
||||||
|
// Check if clone destination already exists to prevent data loss
|
||||||
try {
|
try {
|
||||||
await cloneGitHubRepository(githubUrl, absolutePath, githubToken);
|
await fs.access(clonePath);
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'Directory already exists',
|
||||||
|
details: `The destination path "${clonePath}" already exists. Please choose a different location or remove the existing directory.`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Directory doesn't exist, which is what we want
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone the repository into a subfolder
|
||||||
|
try {
|
||||||
|
await cloneGitHubRepository(githubUrl, clonePath, githubToken);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Clean up created directory on failure
|
// Only clean up if clone created partial data (check if dir exists and is empty or partial)
|
||||||
try {
|
try {
|
||||||
await fs.rm(absolutePath, { recursive: true, force: true });
|
const stats = await fs.stat(clonePath);
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
await fs.rm(clonePath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
console.error('Failed to clean up directory after clone failure:', cleanupError);
|
// Directory doesn't exist or cleanup failed - ignore
|
||||||
// Continue to throw original error
|
|
||||||
}
|
}
|
||||||
throw new Error(`Failed to clone repository: ${error.message}`);
|
throw new Error(`Failed to clone repository: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the cloned repo path to the project list
|
||||||
|
const project = await addProjectManually(clonePath);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
project,
|
||||||
|
message: 'New workspace created and repository cloned successfully'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the new workspace to the project list
|
// Add the new workspace to the project list (no clone)
|
||||||
const project = await addProjectManually(absolutePath);
|
const project = await addProjectManually(absolutePath);
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
project,
|
project,
|
||||||
message: githubUrl
|
message: 'New workspace created successfully'
|
||||||
? 'New workspace created and repository cloned successfully'
|
|
||||||
: 'New workspace created successfully'
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,31 +330,179 @@ async function getGithubTokenById(tokenId, userId) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone repository with progress streaming (SSE)
|
||||||
|
* GET /api/projects/clone-progress
|
||||||
|
*/
|
||||||
|
router.get('/clone-progress', async (req, res) => {
|
||||||
|
const { path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.query;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.flushHeaders();
|
||||||
|
|
||||||
|
const sendEvent = (type, data) => {
|
||||||
|
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!workspacePath || !githubUrl) {
|
||||||
|
sendEvent('error', { message: 'workspacePath and githubUrl are required' });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = await validateWorkspacePath(workspacePath);
|
||||||
|
if (!validation.valid) {
|
||||||
|
sendEvent('error', { message: validation.error });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = validation.resolvedPath;
|
||||||
|
|
||||||
|
await fs.mkdir(absolutePath, { recursive: true });
|
||||||
|
|
||||||
|
let githubToken = null;
|
||||||
|
if (githubTokenId) {
|
||||||
|
const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);
|
||||||
|
if (!token) {
|
||||||
|
await fs.rm(absolutePath, { recursive: true, force: true });
|
||||||
|
sendEvent('error', { message: 'GitHub token not found' });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
githubToken = token.github_token;
|
||||||
|
} else if (newGithubToken) {
|
||||||
|
githubToken = newGithubToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
||||||
|
const repoName = normalizedUrl.split('/').pop() || 'repository';
|
||||||
|
const clonePath = path.join(absolutePath, repoName);
|
||||||
|
|
||||||
|
// Check if clone destination already exists to prevent data loss
|
||||||
|
try {
|
||||||
|
await fs.access(clonePath);
|
||||||
|
sendEvent('error', { message: `Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.` });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
// Directory doesn't exist, which is what we want
|
||||||
|
}
|
||||||
|
|
||||||
|
let cloneUrl = githubUrl;
|
||||||
|
if (githubToken) {
|
||||||
|
try {
|
||||||
|
const url = new URL(githubUrl);
|
||||||
|
url.username = githubToken;
|
||||||
|
url.password = '';
|
||||||
|
cloneUrl = url.toString();
|
||||||
|
} catch (error) {
|
||||||
|
// SSH URL or invalid - use as-is
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEvent('progress', { message: `Cloning into '${repoName}'...` });
|
||||||
|
|
||||||
|
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, clonePath], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_TERMINAL_PROMPT: '0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastError = '';
|
||||||
|
|
||||||
|
gitProcess.stdout.on('data', (data) => {
|
||||||
|
const message = data.toString().trim();
|
||||||
|
if (message) {
|
||||||
|
sendEvent('progress', { message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gitProcess.stderr.on('data', (data) => {
|
||||||
|
const message = data.toString().trim();
|
||||||
|
lastError = message;
|
||||||
|
if (message) {
|
||||||
|
sendEvent('progress', { message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gitProcess.on('close', async (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
try {
|
||||||
|
const project = await addProjectManually(clonePath);
|
||||||
|
sendEvent('complete', { project, message: 'Repository cloned successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sanitizedError = sanitizeGitError(lastError, githubToken);
|
||||||
|
let errorMessage = 'Git clone failed';
|
||||||
|
if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {
|
||||||
|
errorMessage = 'Authentication failed. Please check your credentials.';
|
||||||
|
} else if (lastError.includes('Repository not found')) {
|
||||||
|
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
|
||||||
|
} else if (lastError.includes('already exists')) {
|
||||||
|
errorMessage = 'Directory already exists';
|
||||||
|
} else if (sanitizedError) {
|
||||||
|
errorMessage = sanitizedError;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.rm(clonePath, { recursive: true, force: true });
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error('Failed to clean up after clone failure:', sanitizeGitError(cleanupError.message, githubToken));
|
||||||
|
}
|
||||||
|
sendEvent('error', { message: errorMessage });
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
gitProcess.on('error', (error) => {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
sendEvent('error', { message: 'Git is not installed or not in PATH' });
|
||||||
|
} else {
|
||||||
|
sendEvent('error', { message: error.message });
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
gitProcess.kill();
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
sendEvent('error', { message: error.message });
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to clone a GitHub repository
|
* Helper function to clone a GitHub repository
|
||||||
*/
|
*/
|
||||||
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
|
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Parse GitHub URL and inject token if provided
|
|
||||||
let cloneUrl = githubUrl;
|
let cloneUrl = githubUrl;
|
||||||
|
|
||||||
if (githubToken) {
|
if (githubToken) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(githubUrl);
|
const url = new URL(githubUrl);
|
||||||
// Format: https://TOKEN@github.com/user/repo.git
|
|
||||||
url.username = githubToken;
|
url.username = githubToken;
|
||||||
url.password = '';
|
url.password = '';
|
||||||
cloneUrl = url.toString();
|
cloneUrl = url.toString();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reject(new Error('Invalid GitHub URL format'));
|
// SSH URL - use as-is
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], {
|
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
GIT_TERMINAL_PROMPT: '0' // Disable git password prompts
|
GIT_TERMINAL_PROMPT: '0'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -348,7 +521,6 @@ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
|
|||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve({ stdout, stderr });
|
resolve({ stdout, stderr });
|
||||||
} else {
|
} else {
|
||||||
// Parse git error messages to provide helpful feedback
|
|
||||||
let errorMessage = 'Git clone failed';
|
let errorMessage = 'Git clone failed';
|
||||||
|
|
||||||
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {
|
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {
|
||||||
|
|||||||
48
src/App.jsx
48
src/App.jsx
@@ -36,7 +36,7 @@ import ProtectedRoute from './components/ProtectedRoute';
|
|||||||
import { useVersionCheck } from './hooks/useVersionCheck';
|
import { useVersionCheck } from './hooks/useVersionCheck';
|
||||||
import useLocalStorage from './hooks/useLocalStorage';
|
import useLocalStorage from './hooks/useLocalStorage';
|
||||||
import { api, authenticatedFetch } from './utils/api';
|
import { api, authenticatedFetch } from './utils/api';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider, useTranslation } from 'react-i18next';
|
||||||
import i18n from './i18n/config.js';
|
import i18n from './i18n/config.js';
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ import i18n from './i18n/config.js';
|
|||||||
function AppContent() {
|
function AppContent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { sessionId } = useParams();
|
const { sessionId } = useParams();
|
||||||
|
const { t } = useTranslation('common');
|
||||||
|
|
||||||
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
|
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
|
||||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||||
@@ -579,6 +580,7 @@ function AppContent() {
|
|||||||
|
|
||||||
// Version Upgrade Modal Component
|
// Version Upgrade Modal Component
|
||||||
const VersionUpgradeModal = () => {
|
const VersionUpgradeModal = () => {
|
||||||
|
const { t } = useTranslation('common');
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [updateOutput, setUpdateOutput] = useState('');
|
const [updateOutput, setUpdateOutput] = useState('');
|
||||||
const [updateError, setUpdateError] = useState('');
|
const [updateError, setUpdateError] = useState('');
|
||||||
@@ -639,7 +641,7 @@ function AppContent() {
|
|||||||
<button
|
<button
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
onClick={() => setShowVersionModal(false)}
|
onClick={() => setShowVersionModal(false)}
|
||||||
aria-label="Close version upgrade modal"
|
aria-label={t('versionUpdate.ariaLabels.closeModal')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
@@ -653,9 +655,9 @@ function AppContent() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Update Available</h2>
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('versionUpdate.title')}</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{releaseInfo?.title || 'A new version is ready'}
|
{releaseInfo?.title || t('versionUpdate.newVersionReady')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -672,11 +674,11 @@ function AppContent() {
|
|||||||
{/* Version Info */}
|
{/* Version Info */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
<div className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Current Version</span>
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('versionUpdate.currentVersion')}</span>
|
||||||
<span className="text-sm text-gray-900 dark:text-white font-mono">{currentVersion}</span>
|
<span className="text-sm text-gray-900 dark:text-white font-mono">{currentVersion}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
|
<div className="flex justify-between items-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
|
||||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Latest Version</span>
|
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">{t('versionUpdate.latestVersion')}</span>
|
||||||
<span className="text-sm text-blue-900 dark:text-blue-100 font-mono">{latestVersion}</span>
|
<span className="text-sm text-blue-900 dark:text-blue-100 font-mono">{latestVersion}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -685,7 +687,7 @@ function AppContent() {
|
|||||||
{releaseInfo?.body && (
|
{releaseInfo?.body && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">What's New:</h3>
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.whatsNew')}</h3>
|
||||||
{releaseInfo?.htmlUrl && (
|
{releaseInfo?.htmlUrl && (
|
||||||
<a
|
<a
|
||||||
href={releaseInfo.htmlUrl}
|
href={releaseInfo.htmlUrl}
|
||||||
@@ -693,7 +695,7 @@ function AppContent() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline flex items-center gap-1"
|
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline flex items-center gap-1"
|
||||||
>
|
>
|
||||||
View full release
|
{t('versionUpdate.viewFullRelease')}
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -711,7 +713,7 @@ function AppContent() {
|
|||||||
{/* Update Output */}
|
{/* Update Output */}
|
||||||
{updateOutput && (
|
{updateOutput && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Update Progress:</h3>
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.updateProgress')}</h3>
|
||||||
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 border border-gray-700 max-h-48 overflow-y-auto">
|
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 border border-gray-700 max-h-48 overflow-y-auto">
|
||||||
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">{updateOutput}</pre>
|
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">{updateOutput}</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -721,14 +723,14 @@ function AppContent() {
|
|||||||
{/* Upgrade Instructions */}
|
{/* Upgrade Instructions */}
|
||||||
{!isUpdating && !updateOutput && (
|
{!isUpdating && !updateOutput && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Manual upgrade:</h3>
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.manualUpgrade')}</h3>
|
||||||
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
|
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
|
||||||
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
|
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
|
||||||
git checkout main && git pull && npm install
|
git checkout main && git pull && npm install
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
Or click "Update Now" to run the update automatically.
|
{t('versionUpdate.manualUpgradeHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -739,7 +741,7 @@ function AppContent() {
|
|||||||
onClick={() => setShowVersionModal(false)}
|
onClick={() => setShowVersionModal(false)}
|
||||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
|
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
{updateOutput ? 'Close' : 'Later'}
|
{updateOutput ? t('versionUpdate.buttons.close') : t('versionUpdate.buttons.later')}
|
||||||
</button>
|
</button>
|
||||||
{!updateOutput && (
|
{!updateOutput && (
|
||||||
<>
|
<>
|
||||||
@@ -749,7 +751,7 @@ function AppContent() {
|
|||||||
}}
|
}}
|
||||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
|
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
Copy Command
|
{t('versionUpdate.buttons.copyCommand')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleUpdateNow}
|
onClick={handleUpdateNow}
|
||||||
@@ -759,10 +761,10 @@ function AppContent() {
|
|||||||
{isUpdating ? (
|
{isUpdating ? (
|
||||||
<>
|
<>
|
||||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
Updating...
|
{t('versionUpdate.buttons.updating')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Update Now'
|
t('versionUpdate.buttons.updateNow')
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -813,8 +815,8 @@ function AppContent() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setSidebarVisible(true)}
|
onClick={() => setSidebarVisible(true)}
|
||||||
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
|
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
|
||||||
aria-label="Show sidebar"
|
aria-label={t('versionUpdate.ariaLabels.showSidebar')}
|
||||||
title="Show sidebar"
|
title={t('versionUpdate.ariaLabels.showSidebar')}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform"
|
className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform"
|
||||||
@@ -830,8 +832,8 @@ function AppContent() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowSettings(true)}
|
onClick={() => setShowSettings(true)}
|
||||||
className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
|
className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
|
||||||
aria-label="Settings"
|
aria-label={t('versionUpdate.ariaLabels.settings')}
|
||||||
title="Settings"
|
title={t('versionUpdate.ariaLabels.settings')}
|
||||||
>
|
>
|
||||||
<SettingsIcon className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" />
|
<SettingsIcon className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
@@ -841,8 +843,8 @@ function AppContent() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowVersionModal(true)}
|
onClick={() => setShowVersionModal(true)}
|
||||||
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
|
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
|
||||||
aria-label="Update available"
|
aria-label={t('versionUpdate.ariaLabels.updateAvailable')}
|
||||||
title="Update available"
|
title={t('versionUpdate.ariaLabels.updateAvailable')}
|
||||||
>
|
>
|
||||||
<Sparkles className="w-5 h-5 text-blue-500" />
|
<Sparkles className="w-5 h-5 text-blue-500" />
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
<span className="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||||
@@ -870,7 +872,7 @@ function AppContent() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
aria-label="Close sidebar"
|
aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border transform transition-transform duration-150 ease-out ${
|
className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border transform transition-transform duration-150 ease-out ${
|
||||||
@@ -988,7 +990,7 @@ function App() {
|
|||||||
<TasksSettingsProvider>
|
<TasksSettingsProvider>
|
||||||
<TaskMasterProvider>
|
<TaskMasterProvider>
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Router>
|
<Router basename={window.__ROUTER_BASENAME__ || ''}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<AppContent />} />
|
<Route path="/" element={<AppContent />} />
|
||||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ function unescapeWithMathProtection(text) {
|
|||||||
return processedText;
|
return processedText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
// Small wrapper to keep markdown behavior consistent in one place
|
// Small wrapper to keep markdown behavior consistent in one place
|
||||||
const Markdown = ({ children, className }) => {
|
const Markdown = ({ children, className }) => {
|
||||||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
const content = normalizeInlineCodeFences(String(children ?? ''));
|
||||||
@@ -1894,6 +1898,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
const inputContainerRef = useRef(null);
|
const inputContainerRef = useRef(null);
|
||||||
|
const inputHighlightRef = useRef(null);
|
||||||
const scrollContainerRef = useRef(null);
|
const scrollContainerRef = useRef(null);
|
||||||
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
|
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
|
||||||
const isLoadingMoreRef = useRef(false);
|
const isLoadingMoreRef = useRef(false);
|
||||||
@@ -1902,10 +1907,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
// Streaming throttle buffers
|
// Streaming throttle buffers
|
||||||
const streamBufferRef = useRef('');
|
const streamBufferRef = useRef('');
|
||||||
const streamTimerRef = useRef(null);
|
const streamTimerRef = useRef(null);
|
||||||
|
// Track the session that this view expects when starting a brand‑new chat
|
||||||
|
// (prevents background sessions from streaming into a different view).
|
||||||
|
const pendingViewSessionRef = useRef(null);
|
||||||
const commandQueryTimerRef = useRef(null);
|
const commandQueryTimerRef = useRef(null);
|
||||||
const [debouncedInput, setDebouncedInput] = useState('');
|
const [debouncedInput, setDebouncedInput] = useState('');
|
||||||
const [showFileDropdown, setShowFileDropdown] = useState(false);
|
const [showFileDropdown, setShowFileDropdown] = useState(false);
|
||||||
const [fileList, setFileList] = useState([]);
|
const [fileList, setFileList] = useState([]);
|
||||||
|
const [fileMentions, setFileMentions] = useState([]);
|
||||||
const [filteredFiles, setFilteredFiles] = useState([]);
|
const [filteredFiles, setFilteredFiles] = useState([]);
|
||||||
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
|
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
|
||||||
const [cursorPosition, setCursorPosition] = useState(0);
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
@@ -1939,6 +1948,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
// Track provider transitions so we only clear approvals when provider truly changes.
|
// Track provider transitions so we only clear approvals when provider truly changes.
|
||||||
// This does not sync with the backend; it just prevents UI prompts from disappearing.
|
// This does not sync with the backend; it just prevents UI prompts from disappearing.
|
||||||
const lastProviderRef = useRef(provider);
|
const lastProviderRef = useRef(provider);
|
||||||
|
|
||||||
|
const resetStreamingState = useCallback(() => {
|
||||||
|
if (streamTimerRef.current) {
|
||||||
|
clearTimeout(streamTimerRef.current);
|
||||||
|
streamTimerRef.current = null;
|
||||||
|
}
|
||||||
|
streamBufferRef.current = '';
|
||||||
|
}, []);
|
||||||
// Load permission mode for the current session
|
// Load permission mode for the current session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSession?.id) {
|
if (selectedSession?.id) {
|
||||||
@@ -3013,6 +3030,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
||||||
|
|
||||||
if (sessionChanged) {
|
if (sessionChanged) {
|
||||||
|
if (!isSystemSessionChange) {
|
||||||
|
// Clear any streaming leftovers from the previous session
|
||||||
|
resetStreamingState();
|
||||||
|
pendingViewSessionRef.current = null;
|
||||||
|
setChatMessages([]);
|
||||||
|
setSessionMessages([]);
|
||||||
|
setClaudeStatus(null);
|
||||||
|
setCanAbortSession(false);
|
||||||
|
}
|
||||||
// Reset pagination state when switching sessions
|
// Reset pagination state when switching sessions
|
||||||
setMessagesOffset(0);
|
setMessagesOffset(0);
|
||||||
setHasMoreMessages(false);
|
setHasMoreMessages(false);
|
||||||
@@ -3082,17 +3108,22 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Only clear messages if this is NOT a system-initiated session change AND we're not loading
|
// New session view (no selected session) - always reset UI state
|
||||||
// During system session changes or while loading, preserve the chat messages
|
if (!isSystemSessionChange) {
|
||||||
if (!isSystemSessionChange && !isLoading) {
|
resetStreamingState();
|
||||||
|
pendingViewSessionRef.current = null;
|
||||||
setChatMessages([]);
|
setChatMessages([]);
|
||||||
setSessionMessages([]);
|
setSessionMessages([]);
|
||||||
|
setClaudeStatus(null);
|
||||||
|
setCanAbortSession(false);
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
sessionStorage.removeItem('cursorSessionId');
|
sessionStorage.removeItem('cursorSessionId');
|
||||||
setMessagesOffset(0);
|
setMessagesOffset(0);
|
||||||
setHasMoreMessages(false);
|
setHasMoreMessages(false);
|
||||||
setTotalMessages(0);
|
setTotalMessages(0);
|
||||||
|
setTokenBudget(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark loading as complete after messages are set
|
// Mark loading as complete after messages are set
|
||||||
@@ -3103,7 +3134,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadMessages();
|
loadMessages();
|
||||||
}, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]);
|
}, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange, resetStreamingState]);
|
||||||
|
|
||||||
// External Message Update Handler: Reload messages when external CLI modifies current session
|
// External Message Update Handler: Reload messages when external CLI modifies current session
|
||||||
// This triggers when App.jsx detects a JSONL file change for the currently-viewed session
|
// This triggers when App.jsx detects a JSONL file change for the currently-viewed session
|
||||||
@@ -3142,6 +3173,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}
|
}
|
||||||
}, [externalMessageUpdate, selectedSession, selectedProject, loadCursorSessionMessages, loadSessionMessages, isNearBottom, autoScrollToBottom, scrollToBottom]);
|
}, [externalMessageUpdate, selectedSession, selectedProject, loadCursorSessionMessages, loadSessionMessages, isNearBottom, autoScrollToBottom, scrollToBottom]);
|
||||||
|
|
||||||
|
// When the user navigates to a specific session, clear any pending "new session" marker.
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedSession?.id) {
|
||||||
|
pendingViewSessionRef.current = null;
|
||||||
|
}
|
||||||
|
}, [selectedSession?.id]);
|
||||||
|
|
||||||
// Update chatMessages when convertedMessages changes
|
// Update chatMessages when convertedMessages changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionMessages.length > 0) {
|
if (sessionMessages.length > 0) {
|
||||||
@@ -3206,17 +3244,77 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
// Handle WebSocket messages
|
// Handle WebSocket messages
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0) {
|
||||||
const latestMessage = messages[messages.length - 1];
|
const latestMessage = messages[messages.length - 1];
|
||||||
|
const messageData = latestMessage.data?.message || latestMessage.data;
|
||||||
|
|
||||||
// Filter messages by session ID to prevent cross-session interference
|
// Filter messages by session ID to prevent cross-session interference
|
||||||
// Skip filtering for global messages that apply to all sessions
|
// Skip filtering for global messages that apply to all sessions
|
||||||
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'claude-complete', 'codex-complete'];
|
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
|
||||||
const isGlobalMessage = globalMessageTypes.includes(latestMessage.type);
|
const isGlobalMessage = globalMessageTypes.includes(latestMessage.type);
|
||||||
|
const lifecycleMessageTypes = new Set([
|
||||||
|
'claude-complete',
|
||||||
|
'codex-complete',
|
||||||
|
'cursor-result',
|
||||||
|
'session-aborted',
|
||||||
|
'claude-error',
|
||||||
|
'cursor-error',
|
||||||
|
'codex-error'
|
||||||
|
]);
|
||||||
|
|
||||||
// For new sessions (currentSessionId is null), allow messages through
|
const isClaudeSystemInit = latestMessage.type === 'claude-response' &&
|
||||||
if (!isGlobalMessage && latestMessage.sessionId && currentSessionId && latestMessage.sessionId !== currentSessionId) {
|
messageData &&
|
||||||
// Message is for a different session, ignore it
|
messageData.type === 'system' &&
|
||||||
console.log('⏭️ Skipping message for different session:', latestMessage.sessionId, 'current:', currentSessionId);
|
messageData.subtype === 'init';
|
||||||
return;
|
const isCursorSystemInit = latestMessage.type === 'cursor-system' &&
|
||||||
|
latestMessage.data &&
|
||||||
|
latestMessage.data.type === 'system' &&
|
||||||
|
latestMessage.data.subtype === 'init';
|
||||||
|
|
||||||
|
const systemInitSessionId = isClaudeSystemInit
|
||||||
|
? messageData?.session_id
|
||||||
|
: isCursorSystemInit
|
||||||
|
? latestMessage.data?.session_id
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const activeViewSessionId = selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
|
||||||
|
const isSystemInitForView = systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId);
|
||||||
|
const shouldBypassSessionFilter = isGlobalMessage || isSystemInitForView;
|
||||||
|
const isUnscopedError = !latestMessage.sessionId &&
|
||||||
|
pendingViewSessionRef.current &&
|
||||||
|
!pendingViewSessionRef.current.sessionId &&
|
||||||
|
(latestMessage.type === 'claude-error' || latestMessage.type === 'cursor-error' || latestMessage.type === 'codex-error');
|
||||||
|
|
||||||
|
const handleBackgroundLifecycle = (sessionId) => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
if (onSessionInactive) {
|
||||||
|
onSessionInactive(sessionId);
|
||||||
|
}
|
||||||
|
if (onSessionNotProcessing) {
|
||||||
|
onSessionNotProcessing(sessionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!shouldBypassSessionFilter) {
|
||||||
|
if (!activeViewSessionId) {
|
||||||
|
// No session in view; ignore session-scoped traffic.
|
||||||
|
if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) {
|
||||||
|
handleBackgroundLifecycle(latestMessage.sessionId);
|
||||||
|
}
|
||||||
|
if (!isUnscopedError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!latestMessage.sessionId && !isUnscopedError) {
|
||||||
|
// Drop unscoped messages to prevent cross-session bleed.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (latestMessage.sessionId !== activeViewSessionId) {
|
||||||
|
if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) {
|
||||||
|
handleBackgroundLifecycle(latestMessage.sessionId);
|
||||||
|
}
|
||||||
|
// Message is for a different session, ignore it
|
||||||
|
console.log('??-?,? Skipping message for different session:', latestMessage.sessionId, 'current:', activeViewSessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (latestMessage.type) {
|
switch (latestMessage.type) {
|
||||||
@@ -3225,6 +3323,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
// Store it temporarily until conversation completes (prevents premature session association)
|
// Store it temporarily until conversation completes (prevents premature session association)
|
||||||
if (latestMessage.sessionId && !currentSessionId) {
|
if (latestMessage.sessionId && !currentSessionId) {
|
||||||
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
|
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
|
||||||
|
if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {
|
||||||
|
pendingViewSessionRef.current.sessionId = latestMessage.sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
// Mark as system change to prevent clearing messages when session ID updates
|
// Mark as system change to prevent clearing messages when session ID updates
|
||||||
setIsSystemSessionChange(true);
|
setIsSystemSessionChange(true);
|
||||||
@@ -3253,7 +3354,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'claude-response':
|
case 'claude-response':
|
||||||
const messageData = latestMessage.data.message || latestMessage.data;
|
|
||||||
|
|
||||||
// Handle Cursor streaming format (content_block_delta / content_block_stop)
|
// Handle Cursor streaming format (content_block_delta / content_block_stop)
|
||||||
if (messageData && typeof messageData === 'object' && messageData.type) {
|
if (messageData && typeof messageData === 'object' && messageData.type) {
|
||||||
@@ -3322,7 +3422,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
latestMessage.data.subtype === 'init' &&
|
latestMessage.data.subtype === 'init' &&
|
||||||
latestMessage.data.session_id &&
|
latestMessage.data.session_id &&
|
||||||
currentSessionId &&
|
currentSessionId &&
|
||||||
latestMessage.data.session_id !== currentSessionId) {
|
latestMessage.data.session_id !== currentSessionId &&
|
||||||
|
isSystemInitForView) {
|
||||||
|
|
||||||
console.log('🔄 Claude CLI session duplication detected:', {
|
console.log('🔄 Claude CLI session duplication detected:', {
|
||||||
originalSession: currentSessionId,
|
originalSession: currentSessionId,
|
||||||
@@ -3345,7 +3446,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
if (latestMessage.data.type === 'system' &&
|
if (latestMessage.data.type === 'system' &&
|
||||||
latestMessage.data.subtype === 'init' &&
|
latestMessage.data.subtype === 'init' &&
|
||||||
latestMessage.data.session_id &&
|
latestMessage.data.session_id &&
|
||||||
!currentSessionId) {
|
!currentSessionId &&
|
||||||
|
isSystemInitForView) {
|
||||||
|
|
||||||
console.log('🔄 New session init detected:', {
|
console.log('🔄 New session init detected:', {
|
||||||
newSession: latestMessage.data.session_id
|
newSession: latestMessage.data.session_id
|
||||||
@@ -3366,7 +3468,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
latestMessage.data.subtype === 'init' &&
|
latestMessage.data.subtype === 'init' &&
|
||||||
latestMessage.data.session_id &&
|
latestMessage.data.session_id &&
|
||||||
currentSessionId &&
|
currentSessionId &&
|
||||||
latestMessage.data.session_id === currentSessionId) {
|
latestMessage.data.session_id === currentSessionId &&
|
||||||
|
isSystemInitForView) {
|
||||||
console.log('🔄 System init message for current session, ignoring');
|
console.log('🔄 System init message for current session, ignoring');
|
||||||
return; // Don't process the message further
|
return; // Don't process the message further
|
||||||
}
|
}
|
||||||
@@ -3534,6 +3637,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
try {
|
try {
|
||||||
const cdata = latestMessage.data;
|
const cdata = latestMessage.data;
|
||||||
if (cdata && cdata.type === 'system' && cdata.subtype === 'init' && cdata.session_id) {
|
if (cdata && cdata.type === 'system' && cdata.subtype === 'init' && cdata.session_id) {
|
||||||
|
if (!isSystemInitForView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// If we already have a session and this differs, switch (duplication/redirect)
|
// If we already have a session and this differs, switch (duplication/redirect)
|
||||||
if (currentSessionId && cdata.session_id !== currentSessionId) {
|
if (currentSessionId && cdata.session_id !== currentSessionId) {
|
||||||
console.log('🔄 Cursor session switch detected:', { originalSession: currentSessionId, newSession: cdata.session_id });
|
console.log('🔄 Cursor session switch detected:', { originalSession: currentSessionId, newSession: cdata.session_id });
|
||||||
@@ -4002,6 +4108,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Handle @ symbol detection and file filtering
|
// Handle @ symbol detection and file filtering
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const textBeforeCursor = input.slice(0, cursorPosition);
|
const textBeforeCursor = input.slice(0, cursorPosition);
|
||||||
@@ -4032,6 +4139,43 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}
|
}
|
||||||
}, [input, cursorPosition, fileList]);
|
}, [input, cursorPosition, fileList]);
|
||||||
|
|
||||||
|
const activeFileMentions = useMemo(() => {
|
||||||
|
if (!input || fileMentions.length === 0) return [];
|
||||||
|
return fileMentions.filter(path => input.includes(path));
|
||||||
|
}, [fileMentions, input]);
|
||||||
|
|
||||||
|
const sortedFileMentions = useMemo(() => {
|
||||||
|
if (activeFileMentions.length === 0) return [];
|
||||||
|
const unique = Array.from(new Set(activeFileMentions));
|
||||||
|
return unique.sort((a, b) => b.length - a.length);
|
||||||
|
}, [activeFileMentions]);
|
||||||
|
|
||||||
|
const fileMentionRegex = useMemo(() => {
|
||||||
|
if (sortedFileMentions.length === 0) return null;
|
||||||
|
const pattern = sortedFileMentions.map(escapeRegExp).join('|');
|
||||||
|
return new RegExp(`(${pattern})`, 'g');
|
||||||
|
}, [sortedFileMentions]);
|
||||||
|
|
||||||
|
const fileMentionSet = useMemo(() => new Set(sortedFileMentions), [sortedFileMentions]);
|
||||||
|
|
||||||
|
const renderInputWithMentions = useCallback((text) => {
|
||||||
|
if (!text) return '';
|
||||||
|
if (!fileMentionRegex) return text;
|
||||||
|
const parts = text.split(fileMentionRegex);
|
||||||
|
return parts.map((part, index) => (
|
||||||
|
fileMentionSet.has(part) ? (
|
||||||
|
<span
|
||||||
|
key={`mention-${index}`}
|
||||||
|
className="bg-blue-200/70 -ml-0.5 dark:bg-blue-300/40 px-0.5 rounded-md box-decoration-clone text-transparent"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span key={`text-${index}`}>{part}</span>
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}, [fileMentionRegex, fileMentionSet]);
|
||||||
|
|
||||||
// Debounced input handling
|
// Debounced input handling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -4332,6 +4476,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
// Session Protection: Mark session as active to prevent automatic project updates during conversation
|
// Session Protection: Mark session as active to prevent automatic project updates during conversation
|
||||||
// Use existing session if available; otherwise a temporary placeholder until backend provides real ID
|
// Use existing session if available; otherwise a temporary placeholder until backend provides real ID
|
||||||
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
|
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
|
||||||
|
if (!effectiveSessionId && !selectedSession?.id) {
|
||||||
|
// We are starting a brand-new session in this view. Track it so we only
|
||||||
|
// accept streaming updates for this run.
|
||||||
|
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
|
||||||
|
}
|
||||||
if (onSessionActive) {
|
if (onSessionActive) {
|
||||||
onSessionActive(sessionToActivate);
|
onSessionActive(sessionToActivate);
|
||||||
}
|
}
|
||||||
@@ -4614,8 +4763,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
const spaceIndex = textAfterAtQuery.indexOf(' ');
|
const spaceIndex = textAfterAtQuery.indexOf(' ');
|
||||||
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
|
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
|
||||||
|
|
||||||
const newInput = textBeforeAt + '@' + file.path + ' ' + textAfterQuery;
|
const newInput = textBeforeAt + file.path + ' ' + textAfterQuery;
|
||||||
const newCursorPos = textBeforeAt.length + 1 + file.path.length + 1;
|
const newCursorPos = textBeforeAt.length + file.path.length + 1;
|
||||||
|
|
||||||
// Immediately ensure focus is maintained
|
// Immediately ensure focus is maintained
|
||||||
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
|
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
|
||||||
@@ -4625,6 +4774,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
// Update input and cursor position
|
// Update input and cursor position
|
||||||
setInput(newInput);
|
setInput(newInput);
|
||||||
setCursorPosition(newCursorPos);
|
setCursorPosition(newCursorPos);
|
||||||
|
setFileMentions(prev => (prev.includes(file.path) ? prev : [...prev, file.path]));
|
||||||
|
|
||||||
// Hide dropdown
|
// Hide dropdown
|
||||||
setShowFileDropdown(false);
|
setShowFileDropdown(false);
|
||||||
@@ -4718,6 +4868,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const syncInputOverlayScroll = useCallback((target) => {
|
||||||
|
if (!inputHighlightRef.current || !target) return;
|
||||||
|
inputHighlightRef.current.scrollTop = target.scrollTop;
|
||||||
|
inputHighlightRef.current.scrollLeft = target.scrollLeft;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleTextareaClick = (e) => {
|
const handleTextareaClick = (e) => {
|
||||||
setCursorPosition(e.target.selectionStart);
|
setCursorPosition(e.target.selectionStart);
|
||||||
};
|
};
|
||||||
@@ -4789,16 +4945,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
<div className="text-center text-gray-500 dark:text-gray-400 mt-8">
|
<div className="text-center text-gray-500 dark:text-gray-400 mt-8">
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<div className="flex items-center justify-center space-x-2">
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400"></div>
|
||||||
<p>Loading session messages...</p>
|
<p>{t('session.loading.sessionMessages')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : chatMessages.length === 0 ? (
|
) : chatMessages.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
{!selectedSession && !currentSessionId && (
|
{!selectedSession && !currentSessionId && (
|
||||||
<div className="text-center px-6 sm:px-4 py-8">
|
<div className="text-center px-6 sm:px-4 py-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">Choose Your AI Assistant</h2>
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">{t('providerSelection.title')}</h2>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||||
Select a provider to start a new conversation
|
{t('providerSelection.description')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-8">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-8">
|
||||||
@@ -4819,8 +4975,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||||
<ClaudeLogo className="w-10 h-10" />
|
<ClaudeLogo className="w-10 h-10" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900 dark:text-white">Claude</p>
|
<p className="font-semibold text-gray-900 dark:text-white">Claude Code</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">by Anthropic</p>
|
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.anthropic')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{provider === 'claude' && (
|
{provider === 'claude' && (
|
||||||
@@ -4852,7 +5008,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
<CursorLogo className="w-10 h-10" />
|
<CursorLogo className="w-10 h-10" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900 dark:text-white">Cursor</p>
|
<p className="font-semibold text-gray-900 dark:text-white">Cursor</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">AI Code Editor</p>
|
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.cursorEditor')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{provider === 'cursor' && (
|
{provider === 'cursor' && (
|
||||||
@@ -4884,7 +5040,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
<CodexLogo className="w-10 h-10" />
|
<CodexLogo className="w-10 h-10" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900 dark:text-white">Codex</p>
|
<p className="font-semibold text-gray-900 dark:text-white">Codex</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">by OpenAI</p>
|
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.openai')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{provider === 'codex' && (
|
{provider === 'codex' && (
|
||||||
@@ -4902,7 +5058,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
{/* Model Selection - Always reserve space to prevent jumping */}
|
{/* Model Selection - Always reserve space to prevent jumping */}
|
||||||
<div className={`mb-6 transition-opacity duration-200 ${provider ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
|
<div className={`mb-6 transition-opacity duration-200 ${provider ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Select Model
|
{t('providerSelection.selectModel')}
|
||||||
</label>
|
</label>
|
||||||
{provider === 'claude' ? (
|
{provider === 'claude' ? (
|
||||||
<select
|
<select
|
||||||
@@ -4952,12 +5108,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{provider === 'claude'
|
{provider === 'claude'
|
||||||
? `Ready to use Claude with ${claudeModel}. Start typing your message below.`
|
? t('providerSelection.readyPrompt.claude', { model: claudeModel })
|
||||||
: provider === 'cursor'
|
: provider === 'cursor'
|
||||||
? `Ready to use Cursor with ${cursorModel}. Start typing your message below.`
|
? t('providerSelection.readyPrompt.cursor', { model: cursorModel })
|
||||||
: provider === 'codex'
|
: provider === 'codex'
|
||||||
? `Ready to use Codex with ${codexModel}. Start typing your message below.`
|
? t('providerSelection.readyPrompt.codex', { model: codexModel })
|
||||||
: 'Select a provider above to begin'
|
: t('providerSelection.readyPrompt.default')
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -4974,9 +5130,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
)}
|
)}
|
||||||
{selectedSession && (
|
{selectedSession && (
|
||||||
<div className="text-center text-gray-500 dark:text-gray-400 px-6 sm:px-4">
|
<div className="text-center text-gray-500 dark:text-gray-400 px-6 sm:px-4">
|
||||||
<p className="font-bold text-lg sm:text-xl mb-3">Continue your conversation</p>
|
<p className="font-bold text-lg sm:text-xl mb-3">{t('session.continue.title')}</p>
|
||||||
<p className="text-sm sm:text-base leading-relaxed">
|
<p className="text-sm sm:text-base leading-relaxed">
|
||||||
Ask questions about your code, request changes, or get help with development tasks
|
{t('session.continue.description')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Show NextTaskBanner for existing sessions too, only if TaskMaster is installed */}
|
{/* Show NextTaskBanner for existing sessions too, only if TaskMaster is installed */}
|
||||||
@@ -4998,7 +5154,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
<div className="text-center text-gray-500 dark:text-gray-400 py-3">
|
<div className="text-center text-gray-500 dark:text-gray-400 py-3">
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<div className="flex items-center justify-center space-x-2">
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400"></div>
|
||||||
<p className="text-sm">Loading older messages...</p>
|
<p className="text-sm">{t('session.loading.olderMessages')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -5008,8 +5164,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
|
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
{totalMessages > 0 && (
|
{totalMessages > 0 && (
|
||||||
<span>
|
<span>
|
||||||
Showing {sessionMessages.length} of {totalMessages} messages •
|
{t('session.messages.showingOf', { shown: sessionMessages.length, total: totalMessages })} •
|
||||||
<span className="text-xs">Scroll up to load more</span>
|
<span className="text-xs">{t('session.messages.scrollToLoad')}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -5018,12 +5174,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
{/* Legacy message count indicator (for non-paginated view) */}
|
{/* Legacy message count indicator (for non-paginated view) */}
|
||||||
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
|
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
|
||||||
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
|
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
Showing last {visibleMessageCount} messages ({chatMessages.length} total) •
|
{t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} •
|
||||||
<button
|
<button
|
||||||
className="ml-1 text-blue-600 hover:text-blue-700 underline"
|
className="ml-1 text-blue-600 hover:text-blue-700 underline"
|
||||||
onClick={loadEarlierMessages}
|
onClick={loadEarlierMessages}
|
||||||
>
|
>
|
||||||
Load earlier messages
|
{t('session.messages.loadEarlier')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -5429,6 +5585,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
|
|
||||||
<div {...getRootProps()} className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 overflow-hidden ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
|
<div {...getRootProps()} className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 overflow-hidden ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
|
<div
|
||||||
|
ref={inputHighlightRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 pointer-events-none overflow-hidden rounded-2xl"
|
||||||
|
>
|
||||||
|
<div className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 text-transparent text-base leading-6 whitespace-pre-wrap break-words">
|
||||||
|
{renderInputWithMentions(input)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
@@ -5436,6 +5602,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
onClick={handleTextareaClick}
|
onClick={handleTextareaClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
|
onScroll={(e) => syncInputOverlayScroll(e.target)}
|
||||||
onFocus={() => setIsInputFocused(true)}
|
onFocus={() => setIsInputFocused(true)}
|
||||||
onBlur={() => setIsInputFocused(false)}
|
onBlur={() => setIsInputFocused(false)}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
@@ -5443,6 +5610,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
e.target.style.height = 'auto';
|
e.target.style.height = 'auto';
|
||||||
e.target.style.height = e.target.scrollHeight + 'px';
|
e.target.style.height = e.target.scrollHeight + 'px';
|
||||||
setCursorPosition(e.target.selectionStart);
|
setCursorPosition(e.target.selectionStart);
|
||||||
|
syncInputOverlayScroll(e.target);
|
||||||
|
|
||||||
// Check if textarea is expanded (more than 2 lines worth of height)
|
// Check if textarea is expanded (more than 2 lines worth of height)
|
||||||
const lineHeight = parseInt(window.getComputedStyle(e.target).lineHeight);
|
const lineHeight = parseInt(window.getComputedStyle(e.target).lineHeight);
|
||||||
@@ -5451,7 +5619,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}}
|
}}
|
||||||
placeholder={t('input.placeholder', { provider: provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude') })}
|
placeholder={t('input.placeholder', { provider: provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude') })}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base leading-[21px] sm:leading-6 transition-all duration-200"
|
className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-base leading-6 transition-all duration-200"
|
||||||
style={{ height: '50px' }}
|
style={{ height: '50px' }}
|
||||||
/>
|
/>
|
||||||
{/* Image upload button */}
|
{/* Image upload button */}
|
||||||
@@ -5511,6 +5679,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
? t('input.hintText.ctrlEnter')
|
? t('input.hintText.ctrlEnter')
|
||||||
: t('input.hintText.enter')}
|
: t('input.hintText.enter')}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import { unifiedMergeView, getChunks } from '@codemirror/merge';
|
|||||||
import { showMinimap } from '@replit/codemirror-minimap';
|
import { showMinimap } from '@replit/codemirror-minimap';
|
||||||
import { X, Save, Download, Maximize2, Minimize2 } from 'lucide-react';
|
import { X, Save, Download, Maximize2, Minimize2 } from 'lucide-react';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null }) {
|
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null }) {
|
||||||
|
const { t } = useTranslation('codeEditor');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -125,13 +127,13 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
|||||||
toolbarHTML += '<div style="display: flex; align-items: center; gap: 8px;">';
|
toolbarHTML += '<div style="display: flex; align-items: center; gap: 8px;">';
|
||||||
if (hasDiff) {
|
if (hasDiff) {
|
||||||
toolbarHTML += `
|
toolbarHTML += `
|
||||||
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} changes</span>
|
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${t('toolbar.changes')}</span>
|
||||||
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="Previous change" ${chunkCount === 0 ? 'disabled' : ''}>
|
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="${t('toolbar.previousChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="cm-diff-nav-btn cm-diff-nav-next" title="Next change" ${chunkCount === 0 ? 'disabled' : ''}>
|
<button class="cm-diff-nav-btn cm-diff-nav-next" title="${t('toolbar.nextChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -146,7 +148,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
|||||||
// Show/hide diff button (only if there's diff info)
|
// Show/hide diff button (only if there's diff info)
|
||||||
if (file.diffInfo) {
|
if (file.diffInfo) {
|
||||||
toolbarHTML += `
|
toolbarHTML += `
|
||||||
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? 'Hide diff highlighting' : 'Show diff highlighting'}">
|
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? t('toolbar.hideDiff') : t('toolbar.showDiff')}">
|
||||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
${showDiff ?
|
${showDiff ?
|
||||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />' :
|
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />' :
|
||||||
@@ -159,7 +161,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
|||||||
|
|
||||||
// Settings button
|
// Settings button
|
||||||
toolbarHTML += `
|
toolbarHTML += `
|
||||||
<button class="cm-toolbar-btn cm-settings-btn" title="Editor Settings">
|
<button class="cm-toolbar-btn cm-settings-btn" title="${t('toolbar.settings')}">
|
||||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -169,7 +171,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
|||||||
// Expand button (only in sidebar mode)
|
// Expand button (only in sidebar mode)
|
||||||
if (isSidebar && onToggleExpand) {
|
if (isSidebar && onToggleExpand) {
|
||||||
toolbarHTML += `
|
toolbarHTML += `
|
||||||
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? 'Collapse editor' : 'Expand editor to full width'}">
|
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? t('toolbar.collapse') : t('toolbar.expand')}">
|
||||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
${isExpanded ?
|
${isExpanded ?
|
||||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />' :
|
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />' :
|
||||||
@@ -463,7 +465,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
|||||||
<div className="w-full h-full flex items-center justify-center bg-background">
|
<div className="w-full h-full flex items-center justify-center bg-background">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||||
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
|
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -471,7 +473,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
|||||||
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
|
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||||
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
|
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -574,7 +576,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
|||||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
|
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
|
||||||
{file.diffInfo && (
|
{file.diffInfo && (
|
||||||
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-2 py-1 rounded whitespace-nowrap">
|
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-2 py-1 rounded whitespace-nowrap">
|
||||||
Showing changes
|
{t('header.showingChanges')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -586,7 +588,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
|||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||||
title="Download file"
|
title={t('actions.download')}
|
||||||
>
|
>
|
||||||
<Download className="w-5 h-5 md:w-4 md:h-4" />
|
<Download className="w-5 h-5 md:w-4 md:h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -605,12 +607,12 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
|||||||
<svg className="w-5 h-5 md:w-4 md:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 md:w-4 md:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="hidden sm:inline">Saved!</span>
|
<span className="hidden sm:inline">{t('actions.saved')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="w-5 h-5 md:w-4 md:h-4" />
|
<Save className="w-5 h-5 md:w-4 md:h-4" />
|
||||||
<span className="hidden sm:inline">{saving ? 'Saving...' : 'Save'}</span>
|
<span className="hidden sm:inline">{saving ? t('actions.saving') : t('actions.save')}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -619,7 +621,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
|||||||
<button
|
<button
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
||||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
title={isFullscreen ? t('actions.exitFullscreen') : t('actions.fullscreen')}
|
||||||
>
|
>
|
||||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -628,7 +630,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||||
title="Close"
|
title={t('actions.close')}
|
||||||
>
|
>
|
||||||
<X className="w-6 h-6 md:w-4 md:h-4" />
|
<X className="w-6 h-6 md:w-4 md:h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -686,12 +688,12 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between p-3 border-t border-border bg-muted flex-shrink-0">
|
<div className="flex items-center justify-between p-3 border-t border-border bg-muted flex-shrink-0">
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<span>Lines: {content.split('\n').length}</span>
|
<span>{t('footer.lines')} {content.split('\n').length}</span>
|
||||||
<span>Characters: {content.length}</span>
|
<span>{t('footer.characters')} {content.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Press Ctrl+S to save • Esc to close
|
{t('footer.shortcuts')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import StandaloneShell from './StandaloneShell';
|
|||||||
* @param {Object} props.project - Project object containing name and path information
|
* @param {Object} props.project - Project object containing name and path information
|
||||||
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
|
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
|
||||||
* @param {string} props.customCommand - Optional custom command to override defaults
|
* @param {string} props.customCommand - Optional custom command to override defaults
|
||||||
|
* @param {boolean} props.isAuthenticated - Whether user is already authenticated (for re-auth flow)
|
||||||
*/
|
*/
|
||||||
function LoginModal({
|
function LoginModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -18,7 +19,8 @@ function LoginModal({
|
|||||||
provider = 'claude',
|
provider = 'claude',
|
||||||
project,
|
project,
|
||||||
onComplete,
|
onComplete,
|
||||||
customCommand
|
customCommand,
|
||||||
|
isAuthenticated = false
|
||||||
}) {
|
}) {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@@ -29,13 +31,13 @@ function LoginModal({
|
|||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case 'claude':
|
case 'claude':
|
||||||
return 'claude setup-token --dangerously-skip-permissions';
|
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /exit --dangerously-skip-permissions --no-session-persistence';
|
||||||
case 'cursor':
|
case 'cursor':
|
||||||
return 'cursor-agent login';
|
return 'cursor-agent login';
|
||||||
case 'codex':
|
case 'codex':
|
||||||
return isPlatform ? 'codex login --device-auth' : 'codex login';
|
return isPlatform ? 'codex login --device-auth' : 'codex login';
|
||||||
default:
|
default:
|
||||||
return 'claude setup-token --dangerously-skip-permissions';
|
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /exit --dangerously-skip-permissions --no-session-persistence';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ const Onboarding = ({ onComplete }) => {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const [activeLoginProvider, setActiveLoginProvider] = useState(null);
|
const [activeLoginProvider, setActiveLoginProvider] = useState(null);
|
||||||
const [selectedProject] = useState({ name: 'default', fullPath: '' });
|
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||||
|
const [selectedProject] = useState({ name: 'default', fullPath: isPlatform ? '/workspace' : '' });
|
||||||
|
|
||||||
const [claudeAuthStatus, setClaudeAuthStatus] = useState({
|
const [claudeAuthStatus, setClaudeAuthStatus] = useState({
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff } from 'lucide-react';
|
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff, Plus } from 'lucide-react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
@@ -30,6 +30,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
const [browserFolders, setBrowserFolders] = useState([]);
|
const [browserFolders, setBrowserFolders] = useState([]);
|
||||||
const [loadingFolders, setLoadingFolders] = useState(false);
|
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||||
const [showHiddenFolders, setShowHiddenFolders] = useState(false);
|
const [showHiddenFolders, setShowHiddenFolders] = useState(false);
|
||||||
|
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
|
||||||
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
|
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||||
|
const [cloneProgress, setCloneProgress] = useState('');
|
||||||
|
|
||||||
// Load available GitHub tokens when needed
|
// Load available GitHub tokens when needed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -78,9 +82,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.suggestions) {
|
if (data.suggestions) {
|
||||||
// Filter suggestions based on the input
|
// Filter suggestions based on the input, excluding exact match
|
||||||
const filtered = data.suggestions.filter(s =>
|
const filtered = data.suggestions.filter(s =>
|
||||||
s.path.toLowerCase().startsWith(inputPath.toLowerCase())
|
s.path.toLowerCase().startsWith(inputPath.toLowerCase()) &&
|
||||||
|
s.path.toLowerCase() !== inputPath.toLowerCase()
|
||||||
);
|
);
|
||||||
setPathSuggestions(filtered.slice(0, 5));
|
setPathSuggestions(filtered.slice(0, 5));
|
||||||
setShowPathDropdown(filtered.length > 0);
|
setShowPathDropdown(filtered.length > 0);
|
||||||
@@ -118,32 +123,69 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setCloneProgress('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (workspaceType === 'new' && githubUrl) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
path: workspacePath.trim(),
|
||||||
|
githubUrl: githubUrl.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tokenMode === 'stored' && selectedGithubToken) {
|
||||||
|
params.append('githubTokenId', selectedGithubToken);
|
||||||
|
} else if (tokenMode === 'new' && newGithubToken) {
|
||||||
|
params.append('newGithubToken', newGithubToken.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
const url = `/api/projects/clone-progress?${params}${token ? `&token=${token}` : ''}`;
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const eventSource = new EventSource(url);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type === 'progress') {
|
||||||
|
setCloneProgress(data.message);
|
||||||
|
} else if (data.type === 'complete') {
|
||||||
|
eventSource.close();
|
||||||
|
if (onProjectCreated) {
|
||||||
|
onProjectCreated(data.project);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
resolve();
|
||||||
|
} else if (data.type === 'error') {
|
||||||
|
eventSource.close();
|
||||||
|
reject(new Error(data.message));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing SSE event:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
eventSource.close();
|
||||||
|
reject(new Error('Connection lost during clone'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
workspaceType,
|
workspaceType,
|
||||||
path: workspacePath.trim(),
|
path: workspacePath.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add GitHub info if creating new workspace with GitHub URL
|
|
||||||
if (workspaceType === 'new' && githubUrl) {
|
|
||||||
payload.githubUrl = githubUrl.trim();
|
|
||||||
|
|
||||||
if (tokenMode === 'stored' && selectedGithubToken) {
|
|
||||||
payload.githubTokenId = parseInt(selectedGithubToken);
|
|
||||||
} else if (tokenMode === 'new' && newGithubToken) {
|
|
||||||
payload.newGithubToken = newGithubToken.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await api.createWorkspace(payload);
|
const response = await api.createWorkspace(payload);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || t('projectWizard.errors.failedToCreate'));
|
throw new Error(data.details || data.error || t('projectWizard.errors.failedToCreate'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success!
|
|
||||||
if (onProjectCreated) {
|
if (onProjectCreated) {
|
||||||
onProjectCreated(data.project);
|
onProjectCreated(data.project);
|
||||||
}
|
}
|
||||||
@@ -170,9 +212,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
const loadBrowserFolders = async (path) => {
|
const loadBrowserFolders = async (path) => {
|
||||||
try {
|
try {
|
||||||
setLoadingFolders(true);
|
setLoadingFolders(true);
|
||||||
setBrowserCurrentPath(path);
|
|
||||||
const response = await api.browseFilesystem(path);
|
const response = await api.browseFilesystem(path);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
setBrowserCurrentPath(data.path || path);
|
||||||
setBrowserFolders(data.suggestions || []);
|
setBrowserFolders(data.suggestions || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading folders:', error);
|
console.error('Error loading folders:', error);
|
||||||
@@ -193,6 +235,29 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
await loadBrowserFolders(folderPath);
|
await loadBrowserFolders(folderPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createNewFolder = async () => {
|
||||||
|
if (!newFolderName.trim()) return;
|
||||||
|
setCreatingFolder(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const separator = browserCurrentPath.includes('\\') ? '\\' : '/';
|
||||||
|
const folderPath = `${browserCurrentPath}${separator}${newFolderName.trim()}`;
|
||||||
|
const response = await api.createFolder(folderPath);
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
|
||||||
|
}
|
||||||
|
setNewFolderName('');
|
||||||
|
setShowNewFolderInput(false);
|
||||||
|
await loadBrowserFolders(data.path || folderPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating folder:', error);
|
||||||
|
setError(error.message || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
|
||||||
|
} finally {
|
||||||
|
setCreatingFolder(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
|
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
|
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
|
||||||
@@ -388,8 +453,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* GitHub Token (only if GitHub URL is provided) */}
|
{/* GitHub Token (only for HTTPS URLs - SSH uses SSH keys) */}
|
||||||
{githubUrl && (
|
{githubUrl && !githubUrl.startsWith('git@') && !githubUrl.startsWith('ssh://') && (
|
||||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-start gap-3 mb-4">
|
<div className="flex items-start gap-3 mb-4">
|
||||||
<Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
<Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||||
@@ -551,6 +616,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
|
? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
|
||||||
: tokenMode === 'new' && newGithubToken
|
: tokenMode === 'new' && newGithubToken
|
||||||
? t('projectWizard.step3.usingProvidedToken')
|
? t('projectWizard.step3.usingProvidedToken')
|
||||||
|
: (githubUrl.startsWith('git@') || githubUrl.startsWith('ssh://'))
|
||||||
|
? t('projectWizard.step3.sshKey', 'SSH Key')
|
||||||
: t('projectWizard.step3.noAuthentication')}
|
: t('projectWizard.step3.noAuthentication')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,13 +627,22 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
{isCreating && cloneProgress ? (
|
||||||
{workspaceType === 'existing'
|
<div className="space-y-2">
|
||||||
? t('projectWizard.step3.existingInfo')
|
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">{t('projectWizard.step3.cloningRepository', 'Cloning repository...')}</p>
|
||||||
: githubUrl
|
<code className="block text-xs font-mono text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-all">
|
||||||
? t('projectWizard.step3.newWithClone')
|
{cloneProgress}
|
||||||
: t('projectWizard.step3.newEmpty')}
|
</code>
|
||||||
</p>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
{workspaceType === 'existing'
|
||||||
|
? t('projectWizard.step3.existingInfo')
|
||||||
|
: githubUrl
|
||||||
|
? t('projectWizard.step3.newWithClone')
|
||||||
|
: t('projectWizard.step3.newEmpty')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -596,7 +672,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
{isCreating ? (
|
{isCreating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
{t('projectWizard.buttons.creating')}
|
{githubUrl ? t('projectWizard.buttons.cloning', 'Cloning...') : t('projectWizard.buttons.creating')}
|
||||||
</>
|
</>
|
||||||
) : step === 3 ? (
|
) : step === 3 ? (
|
||||||
<>
|
<>
|
||||||
@@ -639,6 +715,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
>
|
>
|
||||||
{showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
|
{showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
|
||||||
|
className={`p-2 rounded-md transition-colors ${
|
||||||
|
showNewFolderInput
|
||||||
|
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
|
||||||
|
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
title="Create new folder"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFolderBrowser(false)}
|
onClick={() => setShowFolderBrowser(false)}
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
@@ -648,23 +735,67 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* New Folder Input */}
|
||||||
|
{showNewFolderInput && (
|
||||||
|
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={newFolderName}
|
||||||
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
|
placeholder="New folder name"
|
||||||
|
className="flex-1"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') createNewFolder();
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setShowNewFolderInput(false);
|
||||||
|
setNewFolderName('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={createNewFolder}
|
||||||
|
disabled={!newFolderName.trim() || creatingFolder}
|
||||||
|
>
|
||||||
|
{creatingFolder ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Create'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewFolderInput(false);
|
||||||
|
setNewFolderName('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Folder List */}
|
{/* Folder List */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
{loadingFolders ? (
|
{loadingFolders ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
) : browserFolders.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
No folders found
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{/* Parent Directory */}
|
{/* Parent Directory - check for Windows root (e.g., C:\) and Unix root */}
|
||||||
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && (
|
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && !/^[A-Za-z]:\\?$/.test(browserCurrentPath) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const parentPath = browserCurrentPath.substring(0, browserCurrentPath.lastIndexOf('/')) || '/';
|
const lastSlash = Math.max(browserCurrentPath.lastIndexOf('/'), browserCurrentPath.lastIndexOf('\\'));
|
||||||
|
let parentPath;
|
||||||
|
if (lastSlash <= 0) {
|
||||||
|
parentPath = '/';
|
||||||
|
} else if (lastSlash === 2 && /^[A-Za-z]:/.test(browserCurrentPath)) {
|
||||||
|
parentPath = browserCurrentPath.substring(0, 3);
|
||||||
|
} else {
|
||||||
|
parentPath = browserCurrentPath.substring(0, lastSlash);
|
||||||
|
}
|
||||||
navigateToFolder(parentPath);
|
navigateToFolder(parentPath);
|
||||||
}}
|
}}
|
||||||
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
|
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
|
||||||
@@ -675,28 +806,34 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Folders */}
|
{/* Folders */}
|
||||||
{browserFolders
|
{browserFolders.length === 0 ? (
|
||||||
.filter(folder => showHiddenFolders || !folder.name.startsWith('.'))
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
|
No subfolders found
|
||||||
.map((folder, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => navigateToFolder(folder.path)}
|
|
||||||
className="flex-1 px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<FolderPlus className="w-5 h-5 text-blue-500" />
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white">{folder.name}</span>
|
|
||||||
</button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => selectFolder(folder.path, true)}
|
|
||||||
className="text-xs px-3"
|
|
||||||
>
|
|
||||||
Select
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : (
|
||||||
|
browserFolders
|
||||||
|
.filter(folder => showHiddenFolders || !folder.name.startsWith('.'))
|
||||||
|
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
|
||||||
|
.map((folder, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => navigateToFolder(folder.path)}
|
||||||
|
className="flex-1 px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<FolderPlus className="w-5 h-5 text-blue-500" />
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">{folder.name}</span>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => selectFolder(folder.path, workspaceType === 'existing')}
|
||||||
|
className="text-xs px-3"
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -712,13 +849,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
<div className="flex items-center justify-end gap-2 p-4">
|
<div className="flex items-center justify-end gap-2 p-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowFolderBrowser(false)}
|
onClick={() => {
|
||||||
|
setShowFolderBrowser(false);
|
||||||
|
setShowNewFolderInput(false);
|
||||||
|
setNewFolderName('');
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => selectFolder(browserCurrentPath, true)}
|
onClick={() => selectFolder(browserCurrentPath, workspaceType === 'existing')}
|
||||||
>
|
>
|
||||||
Use this folder
|
Use this folder
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -233,8 +233,8 @@ const QuickSettingsPanel = ({
|
|||||||
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
|
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
|
||||||
} touch-none`}
|
} touch-none`}
|
||||||
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
|
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
|
||||||
aria-label={isDragging ? 'Dragging handle' : localIsOpen ? 'Close settings panel' : 'Open settings panel'}
|
aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : localIsOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
|
||||||
title={isDragging ? 'Dragging...' : 'Click to toggle, drag to move'}
|
title={isDragging ? t('quickSettings.dragHandle.draggingStatus') : t('quickSettings.dragHandle.toggleAndMove')}
|
||||||
>
|
>
|
||||||
{isDragging ? (
|
{isDragging ? (
|
||||||
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
|
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
|
||||||
@@ -383,10 +383,10 @@ const QuickSettingsPanel = ({
|
|||||||
<div className="ml-3 flex-1">
|
<div className="ml-3 flex-1">
|
||||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<Mic className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
<Mic className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||||
Default Mode
|
{t('quickSettings.whisper.modes.default')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Direct transcription of your speech
|
{t('quickSettings.whisper.modes.defaultDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -407,10 +407,10 @@ const QuickSettingsPanel = ({
|
|||||||
<div className="ml-3 flex-1">
|
<div className="ml-3 flex-1">
|
||||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<Sparkles className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
<Sparkles className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||||
Prompt Enhancement
|
{t('quickSettings.whisper.modes.prompt')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Transform rough ideas into clear, detailed AI prompts
|
{t('quickSettings.whisper.modes.promptDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -431,10 +431,10 @@ const QuickSettingsPanel = ({
|
|||||||
<div className="ml-3 flex-1">
|
<div className="ml-3 flex-1">
|
||||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||||
Vibe Mode
|
{t('quickSettings.whisper.modes.vibe')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Format ideas as clear agent instructions with details
|
{t('quickSettings.whisper.modes.vibeDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1966,6 +1966,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
|
|||||||
provider={loginProvider}
|
provider={loginProvider}
|
||||||
project={selectedProject}
|
project={selectedProject}
|
||||||
onComplete={handleLoginComplete}
|
onComplete={handleLoginComplete}
|
||||||
|
isAuthenticated={
|
||||||
|
loginProvider === 'claude' ? claudeAuthStatus.authenticated :
|
||||||
|
loginProvider === 'cursor' ? cursorAuthStatus.authenticated :
|
||||||
|
loginProvider === 'codex' ? codexAuthStatus.authenticated :
|
||||||
|
false
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FitAddon } from '@xterm/addon-fit';
|
|||||||
import { WebglAddon } from '@xterm/addon-webgl';
|
import { WebglAddon } from '@xterm/addon-webgl';
|
||||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const xtermStyles = `
|
const xtermStyles = `
|
||||||
.xterm .xterm-screen {
|
.xterm .xterm-screen {
|
||||||
@@ -25,6 +26,7 @@ if (typeof document !== 'undefined') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
|
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
const terminalRef = useRef(null);
|
const terminalRef = useRef(null);
|
||||||
const terminal = useRef(null);
|
const terminal = useRef(null);
|
||||||
const fitAddon = useRef(null);
|
const fitAddon = useRef(null);
|
||||||
@@ -232,7 +234,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
|||||||
tabStopWidth: 4,
|
tabStopWidth: 4,
|
||||||
windowsMode: false,
|
windowsMode: false,
|
||||||
macOptionIsMeta: true,
|
macOptionIsMeta: true,
|
||||||
macOptionClickForcesSelection: false,
|
macOptionClickForcesSelection: true,
|
||||||
theme: {
|
theme: {
|
||||||
background: '#1e1e1e',
|
background: '#1e1e1e',
|
||||||
foreground: '#d4d4d4',
|
foreground: '#d4d4d4',
|
||||||
@@ -373,8 +375,8 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">Select a Project</h3>
|
<h3 className="text-lg font-semibold mb-2">{t('shell.selectProject.title')}</h3>
|
||||||
<p>Choose a project to open an interactive shell in that directory</p>
|
<p>{t('shell.selectProject.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -400,13 +402,13 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!selectedSession && (
|
{!selectedSession && (
|
||||||
<span className="text-xs text-gray-400">(New Session)</span>
|
<span className="text-xs text-gray-400">{t('shell.status.newSession')}</span>
|
||||||
)}
|
)}
|
||||||
{!isInitialized && (
|
{!isInitialized && (
|
||||||
<span className="text-xs text-yellow-400">(Initializing...)</span>
|
<span className="text-xs text-yellow-400">{t('shell.status.initializing')}</span>
|
||||||
)}
|
)}
|
||||||
{isRestarting && (
|
{isRestarting && (
|
||||||
<span className="text-xs text-blue-400">(Restarting...)</span>
|
<span className="text-xs text-blue-400">{t('shell.status.restarting')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
@@ -414,12 +416,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
|||||||
<button
|
<button
|
||||||
onClick={disconnectFromShell}
|
onClick={disconnectFromShell}
|
||||||
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
|
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
|
||||||
title="Disconnect from shell"
|
title={t('shell.actions.disconnectTitle')}
|
||||||
>
|
>
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Disconnect</span>
|
<span>{t('shell.actions.disconnect')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -427,12 +429,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
|||||||
onClick={restartShell}
|
onClick={restartShell}
|
||||||
disabled={isRestarting || isConnected}
|
disabled={isRestarting || isConnected}
|
||||||
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
|
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
|
||||||
title="Restart Shell (disconnect first)"
|
title={t('shell.actions.restartTitle')}
|
||||||
>
|
>
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Restart</span>
|
<span>{t('shell.actions.restart')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -443,7 +445,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
|||||||
|
|
||||||
{!isInitialized && (
|
{!isInitialized && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
|
||||||
<div className="text-white">Loading terminal...</div>
|
<div className="text-white">{t('shell.loading')}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -453,19 +455,19 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
|||||||
<button
|
<button
|
||||||
onClick={connectToShell}
|
onClick={connectToShell}
|
||||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center space-x-2 text-base font-medium w-full sm:w-auto"
|
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center space-x-2 text-base font-medium w-full sm:w-auto"
|
||||||
title="Connect to shell"
|
title={t('shell.actions.connectTitle')}
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Continue in Shell</span>
|
<span>{t('shell.actions.connect')}</span>
|
||||||
</button>
|
</button>
|
||||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||||
{isPlainShell ?
|
{isPlainShell ?
|
||||||
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
|
||||||
selectedSession ?
|
selectedSession ?
|
||||||
`Resume session: ${sessionDisplayNameLong}...` :
|
t('shell.resumeSession', { displayName: sessionDisplayNameLong }) :
|
||||||
'Start a new Claude session'
|
t('shell.startSession')
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -477,12 +479,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
|||||||
<div className="text-center max-w-sm w-full">
|
<div className="text-center max-w-sm w-full">
|
||||||
<div className="flex items-center justify-center space-x-3 text-yellow-400">
|
<div className="flex items-center justify-center space-x-3 text-yellow-400">
|
||||||
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
|
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
|
||||||
<span className="text-base font-medium">Connecting to shell...</span>
|
<span className="text-base font-medium">{t('shell.connecting')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||||
{isPlainShell ?
|
{isPlainShell ?
|
||||||
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
|
||||||
`Starting Claude CLI in ${selectedProject.displayName}`
|
t('shell.startCli', { projectName: selectedProject.displayName })
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Badge } from './ui/badge';
|
|||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search } from 'lucide-react';
|
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search, AlertTriangle } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import ClaudeLogo from './ClaudeLogo';
|
import ClaudeLogo from './ClaudeLogo';
|
||||||
import CursorLogo from './CursorLogo.jsx';
|
import CursorLogo from './CursorLogo.jsx';
|
||||||
@@ -80,6 +80,9 @@ function Sidebar({
|
|||||||
const [editingSessionName, setEditingSessionName] = useState('');
|
const [editingSessionName, setEditingSessionName] = useState('');
|
||||||
const [generatingSummary, setGeneratingSummary] = useState({});
|
const [generatingSummary, setGeneratingSummary] = useState({});
|
||||||
const [searchFilter, setSearchFilter] = useState('');
|
const [searchFilter, setSearchFilter] = useState('');
|
||||||
|
const [deletingProjects, setDeletingProjects] = useState(new Set());
|
||||||
|
const [deleteConfirmation, setDeleteConfirmation] = useState(null); // { project, sessionCount }
|
||||||
|
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState(null); // { projectName, sessionId, sessionTitle, provider }
|
||||||
|
|
||||||
// TaskMaster context
|
// TaskMaster context
|
||||||
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
|
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
|
||||||
@@ -306,10 +309,15 @@ function Sidebar({
|
|||||||
setEditingName('');
|
setEditingName('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteSession = async (projectName, sessionId, provider = 'claude') => {
|
const showDeleteSessionConfirmation = (projectName, sessionId, sessionTitle, provider = 'claude') => {
|
||||||
if (!confirm(t('messages.deleteSessionConfirm'))) {
|
setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
|
const confirmDeleteSession = async () => {
|
||||||
|
if (!sessionDeleteConfirmation) return;
|
||||||
|
|
||||||
|
const { projectName, sessionId, provider } = sessionDeleteConfirmation;
|
||||||
|
setSessionDeleteConfirmation(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[Sidebar] Deleting session:', { projectName, sessionId, provider });
|
console.log('[Sidebar] Deleting session:', { projectName, sessionId, provider });
|
||||||
@@ -343,18 +351,26 @@ function Sidebar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteProject = async (projectName) => {
|
const deleteProject = (project) => {
|
||||||
if (!confirm(t('messages.deleteProjectConfirm'))) {
|
const sessionCount = getAllSessions(project).length;
|
||||||
return;
|
setDeleteConfirmation({ project, sessionCount });
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const confirmDeleteProject = async () => {
|
||||||
|
if (!deleteConfirmation) return;
|
||||||
|
|
||||||
|
const { project, sessionCount } = deleteConfirmation;
|
||||||
|
const isEmpty = sessionCount === 0;
|
||||||
|
|
||||||
|
setDeleteConfirmation(null);
|
||||||
|
setDeletingProjects(prev => new Set([...prev, project.name]));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.deleteProject(projectName);
|
const response = await api.deleteProject(project.name, !isEmpty);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Call parent callback if provided
|
|
||||||
if (onProjectDelete) {
|
if (onProjectDelete) {
|
||||||
onProjectDelete(projectName);
|
onProjectDelete(project.name);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
@@ -364,6 +380,12 @@ function Sidebar({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting project:', error);
|
console.error('Error deleting project:', error);
|
||||||
alert(t('messages.deleteProjectError'));
|
alert(t('messages.deleteProjectError'));
|
||||||
|
} finally {
|
||||||
|
setDeletingProjects(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(project.name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -488,6 +510,110 @@ function Sidebar({
|
|||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{deleteConfirmation && ReactDOM.createPortal(
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{t('deleteConfirmation.deleteProject')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">
|
||||||
|
{t('deleteConfirmation.confirmDelete')}{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{deleteConfirmation.project.displayName || deleteConfirmation.project.name}
|
||||||
|
</span>?
|
||||||
|
</p>
|
||||||
|
{deleteConfirmation.sessionCount > 0 && (
|
||||||
|
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300 font-medium">
|
||||||
|
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||||
|
{t('deleteConfirmation.allConversationsDeleted')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-3">
|
||||||
|
{t('deleteConfirmation.cannotUndo')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 p-4 bg-muted/30 border-t border-border">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setDeleteConfirmation(null)}
|
||||||
|
>
|
||||||
|
{t('actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
onClick={confirmDeleteProject}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
{t('actions.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Session Delete Confirmation Modal */}
|
||||||
|
{sessionDeleteConfirmation && ReactDOM.createPortal(
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{t('deleteConfirmation.deleteSession')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">
|
||||||
|
{t('deleteConfirmation.confirmDelete')}{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{sessionDeleteConfirmation.sessionTitle || t('sessions.unnamed')}
|
||||||
|
</span>?
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-3">
|
||||||
|
{t('deleteConfirmation.cannotUndo')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 p-4 bg-muted/30 border-t border-border">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setSessionDeleteConfirmation(null)}
|
||||||
|
>
|
||||||
|
{t('actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
onClick={confirmDeleteSession}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
{t('actions.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="h-full flex flex-col bg-card md:select-none"
|
className="h-full flex flex-col bg-card md:select-none"
|
||||||
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
||||||
@@ -669,7 +795,7 @@ function Sidebar({
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('projects.fetchingProjects')}
|
{t('projects.fetchingProjects')}
|
||||||
</p>
|
</p>
|
||||||
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">Loading projects...</h3>
|
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">{t('projects.loadingProjects')}</h3>
|
||||||
{loadingProgress && loadingProgress.total > 0 ? (
|
{loadingProgress && loadingProgress.total > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
|
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||||
@@ -679,7 +805,7 @@ function Sidebar({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{loadingProgress.current}/{loadingProgress.total} projects
|
{loadingProgress.current}/{loadingProgress.total} {t('projects.projects')}
|
||||||
</p>
|
</p>
|
||||||
{loadingProgress.currentProject && (
|
{loadingProgress.currentProject && (
|
||||||
<p className="text-xs text-muted-foreground/70 truncate max-w-[200px] mx-auto" title={loadingProgress.currentProject}>
|
<p className="text-xs text-muted-foreground/70 truncate max-w-[200px] mx-auto" title={loadingProgress.currentProject}>
|
||||||
@@ -689,7 +815,7 @@ function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Fetching your Claude projects and sessions
|
{t('projects.fetchingProjects')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -718,9 +844,10 @@ function Sidebar({
|
|||||||
const isExpanded = expandedProjects.has(project.name);
|
const isExpanded = expandedProjects.has(project.name);
|
||||||
const isSelected = selectedProject?.name === project.name;
|
const isSelected = selectedProject?.name === project.name;
|
||||||
const isStarred = isProjectStarred(project.name);
|
const isStarred = isProjectStarred(project.name);
|
||||||
|
const isDeleting = deletingProjects.has(project.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={project.name} className="md:space-y-1">
|
<div key={project.name} className={cn("md:space-y-1", isDeleting && "opacity-50 pointer-events-none")}>
|
||||||
{/* Project Header */}
|
{/* Project Header */}
|
||||||
<div className="group md:group">
|
<div className="group md:group">
|
||||||
{/* Mobile Project Item */}
|
{/* Mobile Project Item */}
|
||||||
@@ -849,18 +976,16 @@ function Sidebar({
|
|||||||
: "text-gray-600 dark:text-gray-400"
|
: "text-gray-600 dark:text-gray-400"
|
||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
{getAllSessions(project).length === 0 && (
|
<button
|
||||||
<button
|
|
||||||
className="w-8 h-8 rounded-lg bg-red-500/10 dark:bg-red-900/30 flex items-center justify-center active:scale-90 border border-red-200 dark:border-red-800"
|
className="w-8 h-8 rounded-lg bg-red-500/10 dark:bg-red-900/30 flex items-center justify-center active:scale-90 border border-red-200 dark:border-red-800"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deleteProject(project.name);
|
deleteProject(project);
|
||||||
}}
|
}}
|
||||||
onTouchEnd={handleTouchClick(() => deleteProject(project.name))}
|
onTouchEnd={handleTouchClick(() => deleteProject(project))}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className="w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/20 flex items-center justify-center active:scale-90 border border-primary/20 dark:border-primary/30"
|
className="w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/20 flex items-center justify-center active:scale-90 border border-primary/20 dark:border-primary/30"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -1009,18 +1134,16 @@ function Sidebar({
|
|||||||
>
|
>
|
||||||
<Edit3 className="w-3 h-3" />
|
<Edit3 className="w-3 h-3" />
|
||||||
</div>
|
</div>
|
||||||
{getAllSessions(project).length === 0 && (
|
<div
|
||||||
<div
|
|
||||||
className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center rounded cursor-pointer touch:opacity-100"
|
className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center rounded cursor-pointer touch:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deleteProject(project.name);
|
deleteProject(project);
|
||||||
}}
|
}}
|
||||||
title={t('tooltips.deleteProject')}
|
title={t('tooltips.deleteProject')}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
|
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
<ChevronDown className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||||
) : (
|
) : (
|
||||||
@@ -1152,9 +1275,9 @@ function Sidebar({
|
|||||||
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
|
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deleteSession(project.name, session.id, session.__provider);
|
showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider);
|
||||||
}}
|
}}
|
||||||
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id, session.__provider))}
|
onTouchEnd={handleTouchClick(() => showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider))}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1271,7 +1394,7 @@ function Sidebar({
|
|||||||
className="w-6 h-6 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center"
|
className="w-6 h-6 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deleteSession(project.name, session.id, session.__provider);
|
showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider);
|
||||||
}}
|
}}
|
||||||
title={t('tooltips.deleteSession')}
|
title={t('tooltips.deleteSession')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ import enSettings from './locales/en/settings.json';
|
|||||||
import enAuth from './locales/en/auth.json';
|
import enAuth from './locales/en/auth.json';
|
||||||
import enSidebar from './locales/en/sidebar.json';
|
import enSidebar from './locales/en/sidebar.json';
|
||||||
import enChat from './locales/en/chat.json';
|
import enChat from './locales/en/chat.json';
|
||||||
|
import enCodeEditor from './locales/en/codeEditor.json';
|
||||||
|
|
||||||
import zhCommon from './locales/zh-CN/common.json';
|
import zhCommon from './locales/zh-CN/common.json';
|
||||||
import zhSettings from './locales/zh-CN/settings.json';
|
import zhSettings from './locales/zh-CN/settings.json';
|
||||||
import zhAuth from './locales/zh-CN/auth.json';
|
import zhAuth from './locales/zh-CN/auth.json';
|
||||||
import zhSidebar from './locales/zh-CN/sidebar.json';
|
import zhSidebar from './locales/zh-CN/sidebar.json';
|
||||||
import zhChat from './locales/zh-CN/chat.json';
|
import zhChat from './locales/zh-CN/chat.json';
|
||||||
|
import zhCodeEditor from './locales/zh-CN/codeEditor.json';
|
||||||
|
|
||||||
// Import supported languages configuration
|
// Import supported languages configuration
|
||||||
import { languages } from './languages.js';
|
import { languages } from './languages.js';
|
||||||
@@ -56,6 +58,7 @@ i18n
|
|||||||
auth: enAuth,
|
auth: enAuth,
|
||||||
sidebar: enSidebar,
|
sidebar: enSidebar,
|
||||||
chat: enChat,
|
chat: enChat,
|
||||||
|
codeEditor: enCodeEditor,
|
||||||
},
|
},
|
||||||
'zh-CN': {
|
'zh-CN': {
|
||||||
common: zhCommon,
|
common: zhCommon,
|
||||||
@@ -63,6 +66,7 @@ i18n
|
|||||||
auth: zhAuth,
|
auth: zhAuth,
|
||||||
sidebar: zhSidebar,
|
sidebar: zhSidebar,
|
||||||
chat: zhChat,
|
chat: zhChat,
|
||||||
|
codeEditor: zhCodeEditor,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -76,7 +80,7 @@ i18n
|
|||||||
debug: import.meta.env.DEV,
|
debug: import.meta.env.DEV,
|
||||||
|
|
||||||
// Namespaces - load only what's needed
|
// Namespaces - load only what's needed
|
||||||
ns: ['common', 'settings', 'auth', 'sidebar', 'chat'],
|
ns: ['common', 'settings', 'auth', 'sidebar', 'chat', 'codeEditor'],
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
|
|
||||||
// Key separator for nested keys (default: '.')
|
// Key separator for nested keys (default: '.')
|
||||||
|
|||||||
@@ -143,5 +143,63 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttonTitle": "Thinking mode: {{mode}}"
|
"buttonTitle": "Thinking mode: {{mode}}"
|
||||||
|
},
|
||||||
|
"providerSelection": {
|
||||||
|
"title": "Choose Your AI Assistant",
|
||||||
|
"description": "Select a provider to start a new conversation",
|
||||||
|
"selectModel": "Select Model",
|
||||||
|
"providerInfo": {
|
||||||
|
"anthropic": "by Anthropic",
|
||||||
|
"openai": "by OpenAI",
|
||||||
|
"cursorEditor": "AI Code Editor"
|
||||||
|
},
|
||||||
|
"readyPrompt": {
|
||||||
|
"claude": "Ready to use Claude with {{model}}. Start typing your message below.",
|
||||||
|
"cursor": "Ready to use Cursor with {{model}}. Start typing your message below.",
|
||||||
|
"codex": "Ready to use Codex with {{model}}. Start typing your message below.",
|
||||||
|
"default": "Select a provider above to begin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"continue": {
|
||||||
|
"title": "Continue your conversation",
|
||||||
|
"description": "Ask questions about your code, request changes, or get help with development tasks"
|
||||||
|
},
|
||||||
|
"loading": {
|
||||||
|
"olderMessages": "Loading older messages...",
|
||||||
|
"sessionMessages": "Loading session messages..."
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"showingOf": "Showing {{shown}} of {{total}} messages",
|
||||||
|
"scrollToLoad": "Scroll up to load more",
|
||||||
|
"showingLast": "Showing last {{count}} messages ({{total}} total)",
|
||||||
|
"loadEarlier": "Load earlier messages"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shell": {
|
||||||
|
"selectProject": {
|
||||||
|
"title": "Select a Project",
|
||||||
|
"description": "Choose a project to open an interactive shell in that directory"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"newSession": "New Session",
|
||||||
|
"initializing": "Initializing...",
|
||||||
|
"restarting": "Restarting..."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"disconnect": "Disconnect",
|
||||||
|
"disconnectTitle": "Disconnect from shell",
|
||||||
|
"restart": "Restart",
|
||||||
|
"restartTitle": "Restart Shell (disconnect first)",
|
||||||
|
"connect": "Continue in Shell",
|
||||||
|
"connectTitle": "Connect to shell"
|
||||||
|
},
|
||||||
|
"loading": "Loading terminal...",
|
||||||
|
"connecting": "Connecting to shell...",
|
||||||
|
"startSession": "Start a new Claude session",
|
||||||
|
"resumeSession": "Resume session: {{displayName}}...",
|
||||||
|
"runCommand": "Run {{command}} in {{projectName}}",
|
||||||
|
"startCli": "Starting Claude CLI in {{projectName}}",
|
||||||
|
"defaultCommand": "command"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/i18n/locales/en/codeEditor.json
Normal file
30
src/i18n/locales/en/codeEditor.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"toolbar": {
|
||||||
|
"changes": "changes",
|
||||||
|
"previousChange": "Previous change",
|
||||||
|
"nextChange": "Next change",
|
||||||
|
"hideDiff": "Hide diff highlighting",
|
||||||
|
"showDiff": "Show diff highlighting",
|
||||||
|
"settings": "Editor Settings",
|
||||||
|
"collapse": "Collapse editor",
|
||||||
|
"expand": "Expand editor to full width"
|
||||||
|
},
|
||||||
|
"loading": "Loading {{fileName}}...",
|
||||||
|
"header": {
|
||||||
|
"showingChanges": "Showing changes"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"download": "Download file",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"saved": "Saved!",
|
||||||
|
"exitFullscreen": "Exit fullscreen",
|
||||||
|
"fullscreen": "Fullscreen",
|
||||||
|
"close": "Close"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"lines": "Lines:",
|
||||||
|
"characters": "Characters:",
|
||||||
|
"shortcuts": "Press Ctrl+S to save • Esc to close"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,14 +136,14 @@
|
|||||||
},
|
},
|
||||||
"step2": {
|
"step2": {
|
||||||
"existingPath": "Workspace Path",
|
"existingPath": "Workspace Path",
|
||||||
"newPath": "Where should the workspace be created?",
|
"newPath": "Workspace Path",
|
||||||
"existingPlaceholder": "/path/to/existing/workspace",
|
"existingPlaceholder": "/path/to/existing/workspace",
|
||||||
"newPlaceholder": "/path/to/new/workspace",
|
"newPlaceholder": "/path/to/new/workspace",
|
||||||
"existingHelp": "Full path to your existing workspace directory",
|
"existingHelp": "Full path to your existing workspace directory",
|
||||||
"newHelp": "Full path where the new workspace will be created",
|
"newHelp": "Full path to your workspace directory",
|
||||||
"githubUrl": "GitHub URL (Optional)",
|
"githubUrl": "GitHub URL (Optional)",
|
||||||
"githubPlaceholder": "https://github.com/username/repository",
|
"githubPlaceholder": "https://github.com/username/repository",
|
||||||
"githubHelp": "Leave empty to create an empty workspace, or provide a GitHub URL to clone",
|
"githubHelp": "Optional: provide a GitHub URL to clone a repository",
|
||||||
"githubAuth": "GitHub Authentication (Optional)",
|
"githubAuth": "GitHub Authentication (Optional)",
|
||||||
"githubAuthHelp": "Only required for private repositories. Public repos can be cloned without authentication.",
|
"githubAuthHelp": "Only required for private repositories. Public repos can be cloned without authentication.",
|
||||||
"loadingTokens": "Loading stored tokens...",
|
"loadingTokens": "Loading stored tokens...",
|
||||||
@@ -170,21 +170,53 @@
|
|||||||
"usingStoredToken": "Using stored token:",
|
"usingStoredToken": "Using stored token:",
|
||||||
"usingProvidedToken": "Using provided token",
|
"usingProvidedToken": "Using provided token",
|
||||||
"noAuthentication": "No authentication",
|
"noAuthentication": "No authentication",
|
||||||
|
"sshKey": "SSH Key",
|
||||||
"existingInfo": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
|
"existingInfo": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
|
||||||
"newWithClone": "A new workspace will be created and the repository will be cloned from GitHub.",
|
"newWithClone": "The repository will be cloned from this folder.",
|
||||||
"newEmpty": "An empty workspace directory will be created at the specified path."
|
"newEmpty": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
|
||||||
|
"cloningRepository": "Cloning repository..."
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"createProject": "Create Project",
|
"createProject": "Create Project",
|
||||||
"creating": "Creating..."
|
"creating": "Creating...",
|
||||||
|
"cloning": "Cloning..."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"selectType": "Please select whether you have an existing workspace or want to create a new one",
|
"selectType": "Please select whether you have an existing workspace or want to create a new one",
|
||||||
"providePath": "Please provide a workspace path",
|
"providePath": "Please provide a workspace path",
|
||||||
"failedToCreate": "Failed to create workspace"
|
"failedToCreate": "Failed to create workspace",
|
||||||
|
"failedToCreateFolder": "Failed to create folder"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"versionUpdate": {
|
||||||
|
"title": "Update Available",
|
||||||
|
"newVersionReady": "A new version is ready",
|
||||||
|
"currentVersion": "Current Version",
|
||||||
|
"latestVersion": "Latest Version",
|
||||||
|
"whatsNew": "What's New:",
|
||||||
|
"viewFullRelease": "View full release",
|
||||||
|
"updateProgress": "Update Progress:",
|
||||||
|
"manualUpgrade": "Manual upgrade:",
|
||||||
|
"manualUpgradeHint": "Or click \"Update Now\" to run the update automatically.",
|
||||||
|
"updateCompleted": "Update completed successfully!",
|
||||||
|
"restartServer": "Please restart the server to apply changes.",
|
||||||
|
"updateFailed": "Update failed",
|
||||||
|
"buttons": {
|
||||||
|
"close": "Close",
|
||||||
|
"later": "Later",
|
||||||
|
"copyCommand": "Copy Command",
|
||||||
|
"updateNow": "Update Now",
|
||||||
|
"updating": "Updating..."
|
||||||
|
},
|
||||||
|
"ariaLabels": {
|
||||||
|
"closeModal": "Close version upgrade modal",
|
||||||
|
"showSidebar": "Show sidebar",
|
||||||
|
"settings": "Settings",
|
||||||
|
"updateAvailable": "Update available",
|
||||||
|
"closeSidebar": "Close sidebar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,24 @@
|
|||||||
"showThinking": "Show thinking",
|
"showThinking": "Show thinking",
|
||||||
"autoScrollToBottom": "Auto-scroll to bottom",
|
"autoScrollToBottom": "Auto-scroll to bottom",
|
||||||
"sendByCtrlEnter": "Send by Ctrl+Enter",
|
"sendByCtrlEnter": "Send by Ctrl+Enter",
|
||||||
"sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends."
|
"sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.",
|
||||||
|
"dragHandle": {
|
||||||
|
"dragging": "Dragging handle",
|
||||||
|
"closePanel": "Close settings panel",
|
||||||
|
"openPanel": "Open settings panel",
|
||||||
|
"draggingStatus": "Dragging...",
|
||||||
|
"toggleAndMove": "Click to toggle, drag to move"
|
||||||
|
},
|
||||||
|
"whisper": {
|
||||||
|
"modes": {
|
||||||
|
"default": "Default Mode",
|
||||||
|
"defaultDescription": "Direct transcription of your speech",
|
||||||
|
"prompt": "Prompt Enhancement",
|
||||||
|
"promptDescription": "Transform rough ideas into clear, detailed AI prompts",
|
||||||
|
"vibe": "Vibe Mode",
|
||||||
|
"vibeDescription": "Format ideas as clear agent instructions with details"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"mainTabs": {
|
"mainTabs": {
|
||||||
"agents": "Agents",
|
"agents": "Agents",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"newSession": "New Session",
|
"newSession": "New Session",
|
||||||
"codexSession": "Codex Session",
|
"codexSession": "Codex Session",
|
||||||
"fetchingProjects": "Fetching your Claude projects and sessions",
|
"fetchingProjects": "Fetching your Claude projects and sessions",
|
||||||
|
"projects": "projects",
|
||||||
"noMatchingProjects": "No matching projects",
|
"noMatchingProjects": "No matching projects",
|
||||||
"tryDifferentSearch": "Try adjusting your search term",
|
"tryDifferentSearch": "Try adjusting your search term",
|
||||||
"runClaudeCli": "Run Claude CLI in a project directory to get started"
|
"runClaudeCli": "Run Claude CLI in a project directory to get started"
|
||||||
@@ -98,5 +99,14 @@
|
|||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"updateAvailable": "Update available"
|
"updateAvailable": "Update available"
|
||||||
|
},
|
||||||
|
"deleteConfirmation": {
|
||||||
|
"deleteProject": "Delete Project",
|
||||||
|
"deleteSession": "Delete Session",
|
||||||
|
"confirmDelete": "Are you sure you want to delete",
|
||||||
|
"sessionCount_one": "This project contains {{count}} conversation.",
|
||||||
|
"sessionCount_other": "This project contains {{count}} conversations.",
|
||||||
|
"allConversationsDeleted": "All conversations will be permanently deleted.",
|
||||||
|
"cannotUndo": "This action cannot be undone."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,5 +143,63 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"buttonTitle": "思考模式:{{mode}}"
|
"buttonTitle": "思考模式:{{mode}}"
|
||||||
|
},
|
||||||
|
"providerSelection": {
|
||||||
|
"title": "选择您的 AI 助手",
|
||||||
|
"description": "选择一个供应商以开始新对话",
|
||||||
|
"selectModel": "选择模型",
|
||||||
|
"providerInfo": {
|
||||||
|
"anthropic": "Anthropic",
|
||||||
|
"openai": "OpenAI",
|
||||||
|
"cursorEditor": "AI 代码编辑器"
|
||||||
|
},
|
||||||
|
"readyPrompt": {
|
||||||
|
"claude": "已准备好使用 Claude {{model}}。在下方输入您的消息。",
|
||||||
|
"cursor": "已准备好使用 Cursor {{model}}。在下方输入您的消息。",
|
||||||
|
"codex": "已准备好使用 Codex {{model}}。在下方输入您的消息。",
|
||||||
|
"default": "请在上方选择一个供应商以开始"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"continue": {
|
||||||
|
"title": "继续您的对话",
|
||||||
|
"description": "询问有关代码的问题、请求更改或获取开发任务的帮助"
|
||||||
|
},
|
||||||
|
"loading": {
|
||||||
|
"olderMessages": "正在加载更早的消息...",
|
||||||
|
"sessionMessages": "正在加载会话消息..."
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"showingOf": "显示 {{shown}} / {{total}} 条消息",
|
||||||
|
"scrollToLoad": "向上滚动以加载更多",
|
||||||
|
"showingLast": "显示最近 {{count}} 条消息(共 {{total}} 条)",
|
||||||
|
"loadEarlier": "加载更早的消息"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shell": {
|
||||||
|
"selectProject": {
|
||||||
|
"title": "选择项目",
|
||||||
|
"description": "选择一个项目以在该目录中打开交互式 Shell"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"newSession": "新会话",
|
||||||
|
"initializing": "初始化中...",
|
||||||
|
"restarting": "重启中..."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"disconnect": "断开连接",
|
||||||
|
"disconnectTitle": "断开 Shell 连接",
|
||||||
|
"restart": "重启",
|
||||||
|
"restartTitle": "重启 Shell(请先断开连接)",
|
||||||
|
"connect": "在 Shell 中继续",
|
||||||
|
"connectTitle": "连接到 Shell"
|
||||||
|
},
|
||||||
|
"loading": "正在加载终端...",
|
||||||
|
"connecting": "正在连接到 Shell...",
|
||||||
|
"startSession": "启动新的 Claude 会话",
|
||||||
|
"resumeSession": "恢复会话:{{displayName}}...",
|
||||||
|
"runCommand": "在 {{projectName}} 中运行 {{command}}",
|
||||||
|
"startCli": "在 {{projectName}} 中启动 Claude CLI",
|
||||||
|
"defaultCommand": "命令"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/i18n/locales/zh-CN/codeEditor.json
Normal file
30
src/i18n/locales/zh-CN/codeEditor.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"toolbar": {
|
||||||
|
"changes": "个更改",
|
||||||
|
"previousChange": "上一个更改",
|
||||||
|
"nextChange": "下一个更改",
|
||||||
|
"hideDiff": "隐藏差异高亮",
|
||||||
|
"showDiff": "显示差异高亮",
|
||||||
|
"settings": "编辑器设置",
|
||||||
|
"collapse": "折叠编辑器",
|
||||||
|
"expand": "展开编辑器到全宽"
|
||||||
|
},
|
||||||
|
"loading": "正在加载 {{fileName}}...",
|
||||||
|
"header": {
|
||||||
|
"showingChanges": "显示更改"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"download": "下载文件",
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"saved": "已保存!",
|
||||||
|
"exitFullscreen": "退出全屏",
|
||||||
|
"fullscreen": "全屏",
|
||||||
|
"close": "关闭"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"lines": "行数:",
|
||||||
|
"characters": "字符数:",
|
||||||
|
"shortcuts": "按 Ctrl+S 保存 • Esc 关闭"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,14 +136,14 @@
|
|||||||
},
|
},
|
||||||
"step2": {
|
"step2": {
|
||||||
"existingPath": "工作区路径",
|
"existingPath": "工作区路径",
|
||||||
"newPath": "应该在哪里创建工作区?",
|
"newPath": "工作区路径",
|
||||||
"existingPlaceholder": "/path/to/existing/workspace",
|
"existingPlaceholder": "/path/to/existing/workspace",
|
||||||
"newPlaceholder": "/path/to/new/workspace",
|
"newPlaceholder": "/path/to/new/workspace",
|
||||||
"existingHelp": "您现有工作区目录的完整路径",
|
"existingHelp": "您现有工作区目录的完整路径",
|
||||||
"newHelp": "将创建新工作区的完整路径",
|
"newHelp": "工作区目录的完整路径",
|
||||||
"githubUrl": "GitHub URL(可选)",
|
"githubUrl": "GitHub URL(可选)",
|
||||||
"githubPlaceholder": "https://github.com/username/repository",
|
"githubPlaceholder": "https://github.com/username/repository",
|
||||||
"githubHelp": "留空以创建空工作区,或提供 GitHub URL 以克隆",
|
"githubHelp": "可选:提供 GitHub URL 以克隆仓库",
|
||||||
"githubAuth": "GitHub 身份验证(可选)",
|
"githubAuth": "GitHub 身份验证(可选)",
|
||||||
"githubAuthHelp": "仅私有仓库需要。公共仓库无需身份验证即可克隆。",
|
"githubAuthHelp": "仅私有仓库需要。公共仓库无需身份验证即可克隆。",
|
||||||
"loadingTokens": "正在加载已保存的令牌...",
|
"loadingTokens": "正在加载已保存的令牌...",
|
||||||
@@ -170,21 +170,53 @@
|
|||||||
"usingStoredToken": "使用已保存的令牌:",
|
"usingStoredToken": "使用已保存的令牌:",
|
||||||
"usingProvidedToken": "使用提供的令牌",
|
"usingProvidedToken": "使用提供的令牌",
|
||||||
"noAuthentication": "无身份验证",
|
"noAuthentication": "无身份验证",
|
||||||
|
"sshKey": "SSH 密钥",
|
||||||
"existingInfo": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。",
|
"existingInfo": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。",
|
||||||
"newWithClone": "将创建新工作区,并从 GitHub 克隆仓库。",
|
"newWithClone": "仓库将从此文件夹克隆。",
|
||||||
"newEmpty": "将在指定路径创建一个空的工作区目录。"
|
"newEmpty": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。",
|
||||||
|
"cloningRepository": "正在克隆仓库..."
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
"next": "下一步",
|
"next": "下一步",
|
||||||
"createProject": "创建项目",
|
"createProject": "创建项目",
|
||||||
"creating": "创建中..."
|
"creating": "创建中...",
|
||||||
|
"cloning": "正在克隆..."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"selectType": "请选择您已有现有工作区还是想创建新工作区",
|
"selectType": "请选择您已有现有工作区还是想创建新工作区",
|
||||||
"providePath": "请提供工作区路径",
|
"providePath": "请提供工作区路径",
|
||||||
"failedToCreate": "创建工作区失败"
|
"failedToCreate": "创建工作区失败",
|
||||||
|
"failedToCreateFolder": "创建文件夹失败"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"versionUpdate": {
|
||||||
|
"title": "有可用更新",
|
||||||
|
"newVersionReady": "新版本已准备就绪",
|
||||||
|
"currentVersion": "当前版本",
|
||||||
|
"latestVersion": "最新版本",
|
||||||
|
"whatsNew": "新内容:",
|
||||||
|
"viewFullRelease": "查看完整发布",
|
||||||
|
"updateProgress": "更新进度:",
|
||||||
|
"manualUpgrade": "手动升级:",
|
||||||
|
"manualUpgradeHint": "或点击'立即更新'以自动运行更新。",
|
||||||
|
"updateCompleted": "更新成功完成!",
|
||||||
|
"restartServer": "请重启服务器以应用更改。",
|
||||||
|
"updateFailed": "更新失败",
|
||||||
|
"buttons": {
|
||||||
|
"close": "关闭",
|
||||||
|
"later": "稍后",
|
||||||
|
"copyCommand": "复制命令",
|
||||||
|
"updateNow": "立即更新",
|
||||||
|
"updating": "更新中..."
|
||||||
|
},
|
||||||
|
"ariaLabels": {
|
||||||
|
"closeModal": "关闭版本升级模态框",
|
||||||
|
"showSidebar": "显示侧边栏",
|
||||||
|
"settings": "设置",
|
||||||
|
"updateAvailable": "有可用更新",
|
||||||
|
"closeSidebar": "关闭侧边栏"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,24 @@
|
|||||||
"showThinking": "显示思考过程",
|
"showThinking": "显示思考过程",
|
||||||
"autoScrollToBottom": "自动滚动到底部",
|
"autoScrollToBottom": "自动滚动到底部",
|
||||||
"sendByCtrlEnter": "使用 Ctrl+Enter 发送",
|
"sendByCtrlEnter": "使用 Ctrl+Enter 发送",
|
||||||
"sendByCtrlEnterDescription": "启用后,按 Ctrl+Enter 发送消息,而不是仅按 Enter。这对于使用输入法的用户可以避免意外发送。"
|
"sendByCtrlEnterDescription": "启用后,按 Ctrl+Enter 发送消息,而不是仅按 Enter。这对于使用输入法的用户可以避免意外发送。",
|
||||||
|
"dragHandle": {
|
||||||
|
"dragging": "正在拖拽手柄",
|
||||||
|
"closePanel": "关闭设置面板",
|
||||||
|
"openPanel": "打开设置面板",
|
||||||
|
"draggingStatus": "正在拖拽...",
|
||||||
|
"toggleAndMove": "点击切换,拖拽移动"
|
||||||
|
},
|
||||||
|
"whisper": {
|
||||||
|
"modes": {
|
||||||
|
"default": "默认模式",
|
||||||
|
"defaultDescription": "直接转录您的语音",
|
||||||
|
"prompt": "提示词增强",
|
||||||
|
"promptDescription": "将粗略的想法转化为清晰、详细的 AI 提示词",
|
||||||
|
"vibe": "Vibe 模式",
|
||||||
|
"vibeDescription": "将想法格式化为带有详细说明的清晰智能体指令"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"mainTabs": {
|
"mainTabs": {
|
||||||
"agents": "智能体",
|
"agents": "智能体",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"newSession": "新会话",
|
"newSession": "新会话",
|
||||||
"codexSession": "Codex 会话",
|
"codexSession": "Codex 会话",
|
||||||
"fetchingProjects": "正在获取您的 Claude 项目和会话",
|
"fetchingProjects": "正在获取您的 Claude 项目和会话",
|
||||||
|
"projects": "项目",
|
||||||
"noMatchingProjects": "未找到匹配的项目",
|
"noMatchingProjects": "未找到匹配的项目",
|
||||||
"tryDifferentSearch": "尝试调整您的搜索词",
|
"tryDifferentSearch": "尝试调整您的搜索词",
|
||||||
"runClaudeCli": "在项目目录中运行 Claude CLI 以开始使用"
|
"runClaudeCli": "在项目目录中运行 Claude CLI 以开始使用"
|
||||||
@@ -98,5 +99,14 @@
|
|||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"updateAvailable": "有可用更新"
|
"updateAvailable": "有可用更新"
|
||||||
|
},
|
||||||
|
"deleteConfirmation": {
|
||||||
|
"deleteProject": "删除项目",
|
||||||
|
"deleteSession": "删除会话",
|
||||||
|
"confirmDelete": "您确定要删除",
|
||||||
|
"sessionCount_one": "此项目包含 {{count}} 个对话。",
|
||||||
|
"sessionCount_other": "此项目包含 {{count}} 个对话。",
|
||||||
|
"allConversationsDeleted": "所有对话将被永久删除。",
|
||||||
|
"cannotUndo": "此操作无法撤销。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ export const api = {
|
|||||||
authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
|
authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
deleteProject: (projectName) =>
|
deleteProject: (projectName, force = false) =>
|
||||||
authenticatedFetch(`/api/projects/${projectName}`, {
|
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
createProject: (path) =>
|
createProject: (path) =>
|
||||||
@@ -158,6 +158,12 @@ export const api = {
|
|||||||
return authenticatedFetch(`/api/browse-filesystem?${params}`);
|
return authenticatedFetch(`/api/browse-filesystem?${params}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createFolder: (folderPath) =>
|
||||||
|
authenticatedFetch('/api/create-folder', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path: folderPath }),
|
||||||
|
}),
|
||||||
|
|
||||||
// User endpoints
|
// User endpoints
|
||||||
user: {
|
user: {
|
||||||
gitConfig: () => authenticatedFetch('/api/user/git-config'),
|
gitConfig: () => authenticatedFetch('/api/user/git-config'),
|
||||||
|
|||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"allowJs": true,
|
||||||
|
// "checkJs": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "shared", "vite.config.js"]
|
||||||
|
}
|
||||||
@@ -45,4 +45,4 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user