mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-01-28 12:27:35 +00:00
Compare commits
48 Commits
fix/turn-o
...
v1.15.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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">
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.13.6",
|
||||
"version": "1.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.13.6",
|
||||
"version": "1.15.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.13.6",
|
||||
"version": "1.15.0",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
|
||||
@@ -603,7 +603,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
const transformedMessage = transformMessage(message);
|
||||
ws.send({
|
||||
type: 'claude-response',
|
||||
data: transformedMessage
|
||||
data: transformedMessage,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
|
||||
// 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);
|
||||
ws.send({
|
||||
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
|
||||
ws.send({
|
||||
type: 'claude-error',
|
||||
error: error.message
|
||||
error: error.message,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
|
||||
throw error;
|
||||
|
||||
@@ -114,7 +114,8 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
// Send system info to frontend
|
||||
ws.send({
|
||||
type: 'cursor-system',
|
||||
data: response
|
||||
data: response,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -123,7 +124,8 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
// Forward user message
|
||||
ws.send({
|
||||
type: 'cursor-user',
|
||||
data: response
|
||||
data: response,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -142,7 +144,8 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
type: 'text_delta',
|
||||
text: textContent
|
||||
}
|
||||
}
|
||||
},
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -157,7 +160,8 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
type: 'claude-response',
|
||||
data: {
|
||||
type: 'content_block_stop'
|
||||
}
|
||||
},
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,7 +178,8 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
// Forward any other message types
|
||||
ws.send({
|
||||
type: 'cursor-response',
|
||||
data: response
|
||||
data: response,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
@@ -182,7 +187,8 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
// If not JSON, send as raw text
|
||||
ws.send({
|
||||
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());
|
||||
ws.send({
|
||||
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({
|
||||
type: 'cursor-error',
|
||||
error: error.message
|
||||
error: error.message,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
|
||||
reject(error);
|
||||
@@ -264,4 +272,4 @@ export {
|
||||
abortCursorSession,
|
||||
isCursorSessionActive,
|
||||
getActiveCursorSessions
|
||||
};
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ import mcpUtilsRoutes from './routes/mcp-utils.js';
|
||||
import commandsRoutes from './routes/commands.js';
|
||||
import settingsRoutes from './routes/settings.js';
|
||||
import agentRoutes from './routes/agent.js';
|
||||
import projectsRoutes from './routes/projects.js';
|
||||
import projectsRoutes, { FORBIDDEN_PATHS } from './routes/projects.js';
|
||||
import cliAuthRoutes from './routes/cli-auth.js';
|
||||
import userRoutes from './routes/user.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) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
await deleteProject(projectName);
|
||||
const force = req.query.force === 'true';
|
||||
await deleteProject(projectName, force);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -549,6 +550,55 @@ 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 homeDir = os.homedir();
|
||||
const targetPath = path.resolve(folderPath.replace('~', homeDir));
|
||||
const normalizedPath = path.normalize(targetPath);
|
||||
const comparePath = normalizedPath.toLowerCase();
|
||||
const forbiddenLower = FORBIDDEN_PATHS.map(p => p.toLowerCase());
|
||||
if (forbiddenLower.includes(comparePath) || comparePath === '/') {
|
||||
return res.status(403).json({ error: 'Cannot create folders in system directories' });
|
||||
}
|
||||
for (const forbidden of forbiddenLower) {
|
||||
if (comparePath.startsWith(forbidden + path.sep)) {
|
||||
if (forbidden === '/var' && (comparePath.startsWith('/var/tmp') || comparePath.startsWith('/var/folders'))) {
|
||||
continue;
|
||||
}
|
||||
return res.status(403).json({ error: `Cannot create folders in system directory: ${forbidden}` });
|
||||
}
|
||||
}
|
||||
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
|
||||
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -37,7 +37,12 @@ const authenticateToken = async (req, res, next) => {
|
||||
|
||||
// Normal OSS JWT validation
|
||||
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) {
|
||||
return res.status(401).json({ error: 'Access denied. No token provided.' });
|
||||
|
||||
@@ -272,7 +272,8 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
data: {
|
||||
used: totalTokens,
|
||||
total: 200000 // Default context window for Codex models
|
||||
}
|
||||
},
|
||||
sessionId: currentSessionId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1026,25 +1026,56 @@ async function isProjectEmpty(projectName) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete an empty project
|
||||
async function deleteProject(projectName) {
|
||||
// Delete a project (force=true to delete even with sessions)
|
||||
async function deleteProject(projectName, force = false) {
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
|
||||
try {
|
||||
// First check if the project is empty
|
||||
const isEmpty = await isProjectEmpty(projectName);
|
||||
if (!isEmpty) {
|
||||
if (!isEmpty && !force) {
|
||||
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();
|
||||
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];
|
||||
await saveProjectConfig(config);
|
||||
|
||||
|
||||
return true;
|
||||
} catch (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)
|
||||
async function addProjectManually(projectPath, displayName = null) {
|
||||
const absolutePath = path.resolve(projectPath);
|
||||
|
||||
|
||||
try {
|
||||
// Check if the path exists
|
||||
await fs.access(absolutePath);
|
||||
} catch (error) {
|
||||
throw new Error(`Path does not exist: ${absolutePath}`);
|
||||
}
|
||||
|
||||
|
||||
// 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
|
||||
const config = await loadProjectConfig();
|
||||
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
|
||||
// existing Claude Code or Cursor projects in the UI
|
||||
|
||||
|
||||
// Add to config as manually added project
|
||||
config[projectName] = {
|
||||
manuallyAdded: true,
|
||||
originalPath: absolutePath
|
||||
};
|
||||
|
||||
|
||||
if (displayName) {
|
||||
config[projectName].displayName = displayName;
|
||||
}
|
||||
@@ -1214,7 +1245,8 @@ async function getCursorSessions(projectPath) {
|
||||
|
||||
|
||||
// Fetch Codex sessions for a given project path
|
||||
async function getCodexSessions(projectPath) {
|
||||
async function getCodexSessions(projectPath, options = {}) {
|
||||
const { limit = 5 } = options;
|
||||
try {
|
||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||
const sessions = [];
|
||||
@@ -1279,8 +1311,8 @@ async function getCodexSessions(projectPath) {
|
||||
// Sort sessions by last activity (newest first)
|
||||
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
||||
|
||||
// Return only the first 5 sessions for performance
|
||||
return sessions.slice(0, 5);
|
||||
// Return limited sessions for performance (0 = unlimited for deletion)
|
||||
return limit > 0 ? sessions.slice(0, limit) : sessions;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex sessions:', error);
|
||||
|
||||
@@ -262,8 +262,7 @@ router.get('/mcp/config/read', async (req, res) => {
|
||||
}
|
||||
|
||||
if (!configData) {
|
||||
return res.json({ success: false, message: 'No Codex configuration file found', servers: [] });
|
||||
}
|
||||
return res.json({ success: true, configPath, servers: [] }); }
|
||||
|
||||
const servers = [];
|
||||
|
||||
|
||||
@@ -7,11 +7,17 @@ import { addProjectManually } from '../projects.js';
|
||||
|
||||
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)
|
||||
const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
|
||||
|
||||
// System-critical paths that should never be used as workspace directories
|
||||
const FORBIDDEN_PATHS = [
|
||||
export const FORBIDDEN_PATHS = [
|
||||
// Unix
|
||||
'/',
|
||||
'/etc',
|
||||
'/bin',
|
||||
@@ -27,7 +33,14 @@ const FORBIDDEN_PATHS = [
|
||||
'/lib64',
|
||||
'/opt',
|
||||
'/tmp',
|
||||
'/run'
|
||||
'/run',
|
||||
// Windows
|
||||
'C:\\Windows',
|
||||
'C:\\Program Files',
|
||||
'C:\\Program Files (x86)',
|
||||
'C:\\ProgramData',
|
||||
'C:\\System Volume Information',
|
||||
'C:\\$Recycle.Bin'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -212,20 +225,7 @@ router.post('/create-workspace', async (req, res) => {
|
||||
|
||||
// Handle new workspace creation
|
||||
if (workspaceType === 'new') {
|
||||
// Check if path already exists
|
||||
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
|
||||
// Create the directory if it doesn't exist
|
||||
await fs.mkdir(absolutePath, { recursive: true });
|
||||
|
||||
// If GitHub URL is provided, clone the repository
|
||||
@@ -246,30 +246,55 @@ router.post('/create-workspace', async (req, res) => {
|
||||
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 {
|
||||
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) {
|
||||
// Clean up created directory on failure
|
||||
// Only clean up if clone created partial data (check if dir exists and is empty or partial)
|
||||
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) {
|
||||
console.error('Failed to clean up directory after clone failure:', cleanupError);
|
||||
// Continue to throw original error
|
||||
// Directory doesn't exist or cleanup failed - ignore
|
||||
}
|
||||
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);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
project,
|
||||
message: githubUrl
|
||||
? 'New workspace created and repository cloned successfully'
|
||||
: 'New workspace created successfully'
|
||||
message: 'New workspace created successfully'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -305,31 +330,179 @@ async function getGithubTokenById(tokenId, userId) {
|
||||
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
|
||||
*/
|
||||
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Parse GitHub URL and inject token if provided
|
||||
let cloneUrl = githubUrl;
|
||||
|
||||
if (githubToken) {
|
||||
try {
|
||||
const url = new URL(githubUrl);
|
||||
// Format: https://TOKEN@github.com/user/repo.git
|
||||
url.username = githubToken;
|
||||
url.password = '';
|
||||
cloneUrl = url.toString();
|
||||
} 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'],
|
||||
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) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
// Parse git error messages to provide helpful feedback
|
||||
let errorMessage = 'Git clone failed';
|
||||
|
||||
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 useLocalStorage from './hooks/useLocalStorage';
|
||||
import { api, authenticatedFetch } from './utils/api';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { I18nextProvider, useTranslation } from 'react-i18next';
|
||||
import i18n from './i18n/config.js';
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import i18n from './i18n/config.js';
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const { sessionId } = useParams();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
|
||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
@@ -579,6 +580,7 @@ function AppContent() {
|
||||
|
||||
// Version Upgrade Modal Component
|
||||
const VersionUpgradeModal = () => {
|
||||
const { t } = useTranslation('common');
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateOutput, setUpdateOutput] = useState('');
|
||||
const [updateError, setUpdateError] = useState('');
|
||||
@@ -639,7 +641,7 @@ function AppContent() {
|
||||
<button
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setShowVersionModal(false)}
|
||||
aria-label="Close version upgrade modal"
|
||||
aria-label={t('versionUpdate.ariaLabels.closeModal')}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
@@ -653,9 +655,9 @@ function AppContent() {
|
||||
</svg>
|
||||
</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">
|
||||
{releaseInfo?.title || 'A new version is ready'}
|
||||
{releaseInfo?.title || t('versionUpdate.newVersionReady')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -672,11 +674,11 @@ function AppContent() {
|
||||
{/* Version Info */}
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -685,7 +687,7 @@ function AppContent() {
|
||||
{releaseInfo?.body && (
|
||||
<div className="space-y-3">
|
||||
<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 && (
|
||||
<a
|
||||
href={releaseInfo.htmlUrl}
|
||||
@@ -693,7 +695,7 @@ function AppContent() {
|
||||
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"
|
||||
>
|
||||
View full release
|
||||
{t('versionUpdate.viewFullRelease')}
|
||||
<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" />
|
||||
</svg>
|
||||
@@ -711,7 +713,7 @@ function AppContent() {
|
||||
{/* Update Output */}
|
||||
{updateOutput && (
|
||||
<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">
|
||||
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">{updateOutput}</pre>
|
||||
</div>
|
||||
@@ -721,14 +723,14 @@ function AppContent() {
|
||||
{/* Upgrade Instructions */}
|
||||
{!isUpdating && !updateOutput && (
|
||||
<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">
|
||||
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
|
||||
git checkout main && git pull && npm install
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Or click "Update Now" to run the update automatically.
|
||||
{t('versionUpdate.manualUpgradeHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -739,7 +741,7 @@ function AppContent() {
|
||||
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"
|
||||
>
|
||||
{updateOutput ? 'Close' : 'Later'}
|
||||
{updateOutput ? t('versionUpdate.buttons.close') : t('versionUpdate.buttons.later')}
|
||||
</button>
|
||||
{!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"
|
||||
>
|
||||
Copy Command
|
||||
{t('versionUpdate.buttons.copyCommand')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpdateNow}
|
||||
@@ -759,10 +761,10 @@ function AppContent() {
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
@@ -813,8 +815,8 @@ function AppContent() {
|
||||
<button
|
||||
onClick={() => setSidebarVisible(true)}
|
||||
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
|
||||
aria-label="Show sidebar"
|
||||
title="Show sidebar"
|
||||
aria-label={t('versionUpdate.ariaLabels.showSidebar')}
|
||||
title={t('versionUpdate.ariaLabels.showSidebar')}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform"
|
||||
@@ -830,8 +832,8 @@ function AppContent() {
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
aria-label={t('versionUpdate.ariaLabels.settings')}
|
||||
title={t('versionUpdate.ariaLabels.settings')}
|
||||
>
|
||||
<SettingsIcon className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
</button>
|
||||
@@ -841,8 +843,8 @@ function AppContent() {
|
||||
<button
|
||||
onClick={() => setShowVersionModal(true)}
|
||||
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
|
||||
aria-label="Update available"
|
||||
title="Update available"
|
||||
aria-label={t('versionUpdate.ariaLabels.updateAvailable')}
|
||||
title={t('versionUpdate.ariaLabels.updateAvailable')}
|
||||
>
|
||||
<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" />
|
||||
@@ -870,7 +872,7 @@ function AppContent() {
|
||||
e.stopPropagation();
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
aria-label="Close sidebar"
|
||||
aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
|
||||
/>
|
||||
<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 ${
|
||||
@@ -988,7 +990,7 @@ function App() {
|
||||
<TasksSettingsProvider>
|
||||
<TaskMasterProvider>
|
||||
<ProtectedRoute>
|
||||
<Router>
|
||||
<Router basename={window.__ROUTER_BASENAME__ || ''}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppContent />} />
|
||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||
|
||||
@@ -97,6 +97,10 @@ function unescapeWithMathProtection(text) {
|
||||
return processedText;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Small wrapper to keep markdown behavior consistent in one place
|
||||
const Markdown = ({ children, className }) => {
|
||||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
||||
@@ -1894,6 +1898,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const messagesEndRef = useRef(null);
|
||||
const textareaRef = useRef(null);
|
||||
const inputContainerRef = useRef(null);
|
||||
const inputHighlightRef = useRef(null);
|
||||
const scrollContainerRef = useRef(null);
|
||||
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
@@ -1902,10 +1907,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Streaming throttle buffers
|
||||
const streamBufferRef = useRef('');
|
||||
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 [debouncedInput, setDebouncedInput] = useState('');
|
||||
const [showFileDropdown, setShowFileDropdown] = useState(false);
|
||||
const [fileList, setFileList] = useState([]);
|
||||
const [fileMentions, setFileMentions] = useState([]);
|
||||
const [filteredFiles, setFilteredFiles] = useState([]);
|
||||
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
|
||||
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.
|
||||
// This does not sync with the backend; it just prevents UI prompts from disappearing.
|
||||
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
|
||||
useEffect(() => {
|
||||
if (selectedSession?.id) {
|
||||
@@ -3013,6 +3030,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
||||
|
||||
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
|
||||
setMessagesOffset(0);
|
||||
setHasMoreMessages(false);
|
||||
@@ -3082,17 +3108,22 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Only clear messages if this is NOT a system-initiated session change AND we're not loading
|
||||
// During system session changes or while loading, preserve the chat messages
|
||||
if (!isSystemSessionChange && !isLoading) {
|
||||
// New session view (no selected session) - always reset UI state
|
||||
if (!isSystemSessionChange) {
|
||||
resetStreamingState();
|
||||
pendingViewSessionRef.current = null;
|
||||
setChatMessages([]);
|
||||
setSessionMessages([]);
|
||||
setClaudeStatus(null);
|
||||
setCanAbortSession(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
setCurrentSessionId(null);
|
||||
sessionStorage.removeItem('cursorSessionId');
|
||||
setMessagesOffset(0);
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
setTokenBudget(null);
|
||||
}
|
||||
|
||||
// Mark loading as complete after messages are set
|
||||
@@ -3103,7 +3134,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
};
|
||||
|
||||
loadMessages();
|
||||
}, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]);
|
||||
}, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange, resetStreamingState]);
|
||||
|
||||
// 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
|
||||
@@ -3142,6 +3173,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (sessionMessages.length > 0) {
|
||||
@@ -3206,17 +3244,77 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Handle WebSocket messages
|
||||
if (messages.length > 0) {
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
const messageData = latestMessage.data?.message || latestMessage.data;
|
||||
|
||||
// Filter messages by session ID to prevent cross-session interference
|
||||
// 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 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
|
||||
if (!isGlobalMessage && latestMessage.sessionId && currentSessionId && latestMessage.sessionId !== currentSessionId) {
|
||||
// Message is for a different session, ignore it
|
||||
console.log('⏭️ Skipping message for different session:', latestMessage.sessionId, 'current:', currentSessionId);
|
||||
return;
|
||||
const isClaudeSystemInit = latestMessage.type === 'claude-response' &&
|
||||
messageData &&
|
||||
messageData.type === 'system' &&
|
||||
messageData.subtype === 'init';
|
||||
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) {
|
||||
@@ -3225,6 +3323,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Store it temporarily until conversation completes (prevents premature session association)
|
||||
if (latestMessage.sessionId && !currentSessionId) {
|
||||
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
|
||||
setIsSystemSessionChange(true);
|
||||
@@ -3253,7 +3354,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
break;
|
||||
|
||||
case 'claude-response':
|
||||
const messageData = latestMessage.data.message || latestMessage.data;
|
||||
|
||||
// Handle Cursor streaming format (content_block_delta / content_block_stop)
|
||||
if (messageData && typeof messageData === 'object' && messageData.type) {
|
||||
@@ -3322,7 +3422,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
latestMessage.data.subtype === 'init' &&
|
||||
latestMessage.data.session_id &&
|
||||
currentSessionId &&
|
||||
latestMessage.data.session_id !== currentSessionId) {
|
||||
latestMessage.data.session_id !== currentSessionId &&
|
||||
isSystemInitForView) {
|
||||
|
||||
console.log('🔄 Claude CLI session duplication detected:', {
|
||||
originalSession: currentSessionId,
|
||||
@@ -3345,7 +3446,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
if (latestMessage.data.type === 'system' &&
|
||||
latestMessage.data.subtype === 'init' &&
|
||||
latestMessage.data.session_id &&
|
||||
!currentSessionId) {
|
||||
!currentSessionId &&
|
||||
isSystemInitForView) {
|
||||
|
||||
console.log('🔄 New session init detected:', {
|
||||
newSession: latestMessage.data.session_id
|
||||
@@ -3366,7 +3468,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
latestMessage.data.subtype === 'init' &&
|
||||
latestMessage.data.session_id &&
|
||||
currentSessionId &&
|
||||
latestMessage.data.session_id === currentSessionId) {
|
||||
latestMessage.data.session_id === currentSessionId &&
|
||||
isSystemInitForView) {
|
||||
console.log('🔄 System init message for current session, ignoring');
|
||||
return; // Don't process the message further
|
||||
}
|
||||
@@ -3534,6 +3637,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
try {
|
||||
const cdata = latestMessage.data;
|
||||
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 (currentSessionId && cdata.session_id !== currentSessionId) {
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
// Handle @ symbol detection and file filtering
|
||||
useEffect(() => {
|
||||
const textBeforeCursor = input.slice(0, cursorPosition);
|
||||
@@ -4032,6 +4139,43 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
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
|
||||
// Use existing session if available; otherwise a temporary placeholder until backend provides real ID
|
||||
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) {
|
||||
onSessionActive(sessionToActivate);
|
||||
}
|
||||
@@ -4614,8 +4763,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const spaceIndex = textAfterAtQuery.indexOf(' ');
|
||||
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
|
||||
|
||||
const newInput = textBeforeAt + '@' + file.path + ' ' + textAfterQuery;
|
||||
const newCursorPos = textBeforeAt.length + 1 + file.path.length + 1;
|
||||
const newInput = textBeforeAt + file.path + ' ' + textAfterQuery;
|
||||
const newCursorPos = textBeforeAt.length + file.path.length + 1;
|
||||
|
||||
// Immediately ensure focus is maintained
|
||||
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
|
||||
@@ -4625,6 +4774,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Update input and cursor position
|
||||
setInput(newInput);
|
||||
setCursorPosition(newCursorPos);
|
||||
setFileMentions(prev => (prev.includes(file.path) ? prev : [...prev, file.path]));
|
||||
|
||||
// Hide dropdown
|
||||
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) => {
|
||||
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="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>
|
||||
<p>Loading session messages...</p>
|
||||
<p>{t('session.loading.sessionMessages')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : chatMessages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
{!selectedSession && !currentSessionId && (
|
||||
<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">
|
||||
Select a provider to start a new conversation
|
||||
{t('providerSelection.description')}
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<ClaudeLogo className="w-10 h-10" />
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">Claude</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">by Anthropic</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">{t('providerSelection.providerInfo.anthropic')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{provider === 'claude' && (
|
||||
@@ -4852,7 +5008,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
<CursorLogo className="w-10 h-10" />
|
||||
<div>
|
||||
<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>
|
||||
{provider === 'cursor' && (
|
||||
@@ -4884,7 +5040,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
<CodexLogo className="w-10 h-10" />
|
||||
<div>
|
||||
<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>
|
||||
{provider === 'codex' && (
|
||||
@@ -4902,7 +5058,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
{/* Model Selection - Always reserve space to prevent jumping */}
|
||||
<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">
|
||||
Select Model
|
||||
{t('providerSelection.selectModel')}
|
||||
</label>
|
||||
{provider === 'claude' ? (
|
||||
<select
|
||||
@@ -4952,12 +5108,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{provider === 'claude'
|
||||
? `Ready to use Claude with ${claudeModel}. Start typing your message below.`
|
||||
? t('providerSelection.readyPrompt.claude', { model: claudeModel })
|
||||
: provider === 'cursor'
|
||||
? `Ready to use Cursor with ${cursorModel}. Start typing your message below.`
|
||||
? t('providerSelection.readyPrompt.cursor', { model: cursorModel })
|
||||
: provider === 'codex'
|
||||
? `Ready to use Codex with ${codexModel}. Start typing your message below.`
|
||||
: 'Select a provider above to begin'
|
||||
? t('providerSelection.readyPrompt.codex', { model: codexModel })
|
||||
: t('providerSelection.readyPrompt.default')
|
||||
}
|
||||
</p>
|
||||
|
||||
@@ -4974,9 +5130,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
)}
|
||||
{selectedSession && (
|
||||
<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">
|
||||
Ask questions about your code, request changes, or get help with development tasks
|
||||
{t('session.continue.description')}
|
||||
</p>
|
||||
|
||||
{/* 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="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>
|
||||
<p className="text-sm">Loading older messages...</p>
|
||||
<p className="text-sm">{t('session.loading.olderMessages')}</p>
|
||||
</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">
|
||||
{totalMessages > 0 && (
|
||||
<span>
|
||||
Showing {sessionMessages.length} of {totalMessages} messages •
|
||||
<span className="text-xs">Scroll up to load more</span>
|
||||
{t('session.messages.showingOf', { shown: sessionMessages.length, total: totalMessages })} •
|
||||
<span className="text-xs">{t('session.messages.scrollToLoad')}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -5018,12 +5174,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
{/* Legacy message count indicator (for non-paginated view) */}
|
||||
{!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">
|
||||
Showing last {visibleMessageCount} messages ({chatMessages.length} total) •
|
||||
<button
|
||||
{t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} •
|
||||
<button
|
||||
className="ml-1 text-blue-600 hover:text-blue-700 underline"
|
||||
onClick={loadEarlierMessages}
|
||||
>
|
||||
Load earlier messages
|
||||
{t('session.messages.loadEarlier')}
|
||||
</button>
|
||||
</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' : ''}`}>
|
||||
<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
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
@@ -5436,6 +5602,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
onClick={handleTextareaClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onScroll={(e) => syncInputOverlayScroll(e.target)}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
onInput={(e) => {
|
||||
@@ -5443,6 +5610,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = e.target.scrollHeight + 'px';
|
||||
setCursorPosition(e.target.selectionStart);
|
||||
syncInputOverlayScroll(e.target);
|
||||
|
||||
// Check if textarea is expanded (more than 2 lines worth of height)
|
||||
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') })}
|
||||
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' }}
|
||||
/>
|
||||
{/* Image upload button */}
|
||||
@@ -5511,6 +5679,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
? t('input.hintText.ctrlEnter')
|
||||
: t('input.hintText.enter')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,10 @@ import { unifiedMergeView, getChunks } from '@codemirror/merge';
|
||||
import { showMinimap } from '@replit/codemirror-minimap';
|
||||
import { X, Save, Download, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { api } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null }) {
|
||||
const { t } = useTranslation('codeEditor');
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
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;">';
|
||||
if (hasDiff) {
|
||||
toolbarHTML += `
|
||||
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} changes</span>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="Previous change" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<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="${t('toolbar.previousChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<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" />
|
||||
</svg>
|
||||
</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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
@@ -146,7 +148,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
// Show/hide diff button (only if there's diff info)
|
||||
if (file.diffInfo) {
|
||||
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">
|
||||
${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" />' :
|
||||
@@ -159,7 +161,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
|
||||
// Settings button
|
||||
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">
|
||||
<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>
|
||||
@@ -169,7 +171,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
// Expand button (only in sidebar mode)
|
||||
if (isSidebar && onToggleExpand) {
|
||||
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">
|
||||
${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" />' :
|
||||
@@ -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="flex items-center gap-3">
|
||||
<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>
|
||||
) : (
|
||||
@@ -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="flex items-center gap-3">
|
||||
<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>
|
||||
@@ -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>
|
||||
{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">
|
||||
Showing changes
|
||||
{t('header.showingChanges')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -586,7 +588,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
<button
|
||||
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"
|
||||
title="Download file"
|
||||
title={t('actions.download')}
|
||||
>
|
||||
<Download className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</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" />
|
||||
<span className="hidden sm:inline">{saving ? 'Saving...' : 'Save'}</span>
|
||||
<span className="hidden sm:inline">{saving ? t('actions.saving') : t('actions.save')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -619,7 +621,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
<button
|
||||
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"
|
||||
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" />}
|
||||
</button>
|
||||
@@ -628,7 +630,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
<button
|
||||
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"
|
||||
title="Close"
|
||||
title={t('actions.close')}
|
||||
>
|
||||
<X className="w-6 h-6 md:w-4 md:h-4" />
|
||||
</button>
|
||||
@@ -686,12 +688,12 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
{/* Footer */}
|
||||
<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">
|
||||
<span>Lines: {content.split('\n').length}</span>
|
||||
<span>Characters: {content.length}</span>
|
||||
<span>{t('footer.lines')} {content.split('\n').length}</span>
|
||||
<span>{t('footer.characters')} {content.length}</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -11,6 +11,7 @@ import StandaloneShell from './StandaloneShell';
|
||||
* @param {Object} props.project - Project object containing name and path information
|
||||
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
|
||||
* @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({
|
||||
isOpen,
|
||||
@@ -18,7 +19,8 @@ function LoginModal({
|
||||
provider = 'claude',
|
||||
project,
|
||||
onComplete,
|
||||
customCommand
|
||||
customCommand,
|
||||
isAuthenticated = false
|
||||
}) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -29,13 +31,13 @@ function LoginModal({
|
||||
|
||||
switch (provider) {
|
||||
case 'claude':
|
||||
return 'claude setup-token --dangerously-skip-permissions';
|
||||
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
|
||||
case 'cursor':
|
||||
return 'cursor-agent login';
|
||||
case 'codex':
|
||||
return isPlatform ? 'codex login --device-auth' : 'codex login';
|
||||
default:
|
||||
return 'claude setup-token --dangerously-skip-permissions';
|
||||
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Input } from './ui/input';
|
||||
import { api } from '../utils/api';
|
||||
@@ -30,6 +30,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
const [browserFolders, setBrowserFolders] = useState([]);
|
||||
const [loadingFolders, setLoadingFolders] = 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
|
||||
useEffect(() => {
|
||||
@@ -78,9 +82,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.suggestions) {
|
||||
// Filter suggestions based on the input
|
||||
// Filter suggestions based on the input, excluding exact match
|
||||
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));
|
||||
setShowPathDropdown(filtered.length > 0);
|
||||
@@ -118,24 +123,62 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
const handleCreate = async () => {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
setCloneProgress('');
|
||||
|
||||
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 = {
|
||||
workspaceType,
|
||||
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 data = await response.json();
|
||||
|
||||
@@ -143,7 +186,6 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
throw new Error(data.error || t('projectWizard.errors.failedToCreate'));
|
||||
}
|
||||
|
||||
// Success!
|
||||
if (onProjectCreated) {
|
||||
onProjectCreated(data.project);
|
||||
}
|
||||
@@ -170,9 +212,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
const loadBrowserFolders = async (path) => {
|
||||
try {
|
||||
setLoadingFolders(true);
|
||||
setBrowserCurrentPath(path);
|
||||
const response = await api.browseFilesystem(path);
|
||||
const data = await response.json();
|
||||
setBrowserCurrentPath(data.path || path);
|
||||
setBrowserFolders(data.suggestions || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading folders:', error);
|
||||
@@ -193,6 +235,29 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
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 (
|
||||
<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">
|
||||
@@ -388,8 +453,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* GitHub Token (only if GitHub URL is provided) */}
|
||||
{githubUrl && (
|
||||
{/* GitHub Token (only for HTTPS URLs - SSH uses SSH keys) */}
|
||||
{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="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" />
|
||||
@@ -551,6 +616,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
|
||||
: tokenMode === 'new' && newGithubToken
|
||||
? t('projectWizard.step3.usingProvidedToken')
|
||||
: (githubUrl.startsWith('git@') || githubUrl.startsWith('ssh://'))
|
||||
? t('projectWizard.step3.sshKey', 'SSH Key')
|
||||
: t('projectWizard.step3.noAuthentication')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -560,13 +627,22 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{workspaceType === 'existing'
|
||||
? t('projectWizard.step3.existingInfo')
|
||||
: githubUrl
|
||||
? t('projectWizard.step3.newWithClone')
|
||||
: t('projectWizard.step3.newEmpty')}
|
||||
</p>
|
||||
{isCreating && cloneProgress ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">{t('projectWizard.step3.cloningRepository', 'Cloning repository...')}</p>
|
||||
<code className="block text-xs font-mono text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-all">
|
||||
{cloneProgress}
|
||||
</code>
|
||||
</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>
|
||||
)}
|
||||
@@ -596,7 +672,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
{isCreating ? (
|
||||
<>
|
||||
<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 ? (
|
||||
<>
|
||||
@@ -639,6 +715,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
>
|
||||
{showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
|
||||
</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
|
||||
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"
|
||||
@@ -648,23 +735,67 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
</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 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loadingFolders ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</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">
|
||||
{/* Parent Directory */}
|
||||
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && (
|
||||
{/* Parent Directory - check for Windows root (e.g., C:\) and Unix root */}
|
||||
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && !/^[A-Za-z]:\\?$/.test(browserCurrentPath) && (
|
||||
<button
|
||||
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);
|
||||
}}
|
||||
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 */}
|
||||
{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, true)}
|
||||
className="text-xs px-3"
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
{browserFolders.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No subfolders found
|
||||
</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>
|
||||
@@ -712,13 +849,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
<div className="flex items-center justify-end gap-2 p-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowFolderBrowser(false)}
|
||||
onClick={() => {
|
||||
setShowFolderBrowser(false);
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => selectFolder(browserCurrentPath, true)}
|
||||
onClick={() => selectFolder(browserCurrentPath, workspaceType === 'existing')}
|
||||
>
|
||||
Use this folder
|
||||
</Button>
|
||||
|
||||
@@ -233,8 +233,8 @@ const QuickSettingsPanel = ({
|
||||
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
|
||||
} touch-none`}
|
||||
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
|
||||
aria-label={isDragging ? 'Dragging handle' : localIsOpen ? 'Close settings panel' : 'Open settings panel'}
|
||||
title={isDragging ? 'Dragging...' : 'Click to toggle, drag to move'}
|
||||
aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : localIsOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
|
||||
title={isDragging ? t('quickSettings.dragHandle.draggingStatus') : t('quickSettings.dragHandle.toggleAndMove')}
|
||||
>
|
||||
{isDragging ? (
|
||||
<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">
|
||||
<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" />
|
||||
Default Mode
|
||||
{t('quickSettings.whisper.modes.default')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Direct transcription of your speech
|
||||
{t('quickSettings.whisper.modes.defaultDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -407,10 +407,10 @@ const QuickSettingsPanel = ({
|
||||
<div className="ml-3 flex-1">
|
||||
<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" />
|
||||
Prompt Enhancement
|
||||
{t('quickSettings.whisper.modes.prompt')}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</label>
|
||||
@@ -431,10 +431,10 @@ const QuickSettingsPanel = ({
|
||||
<div className="ml-3 flex-1">
|
||||
<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" />
|
||||
Vibe Mode
|
||||
{t('quickSettings.whisper.modes.vibe')}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -1966,6 +1966,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
|
||||
provider={loginProvider}
|
||||
project={selectedProject}
|
||||
onComplete={handleLoginComplete}
|
||||
isAuthenticated={
|
||||
loginProvider === 'claude' ? claudeAuthStatus.authenticated :
|
||||
loginProvider === 'cursor' ? cursorAuthStatus.authenticated :
|
||||
loginProvider === 'codex' ? codexAuthStatus.authenticated :
|
||||
false
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const xtermStyles = `
|
||||
.xterm .xterm-screen {
|
||||
@@ -25,6 +26,7 @@ if (typeof document !== 'undefined') {
|
||||
}
|
||||
|
||||
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const terminalRef = useRef(null);
|
||||
const terminal = useRef(null);
|
||||
const fitAddon = useRef(null);
|
||||
@@ -232,7 +234,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
tabStopWidth: 4,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: true,
|
||||
macOptionClickForcesSelection: false,
|
||||
macOptionClickForcesSelection: true,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Select a Project</h3>
|
||||
<p>Choose a project to open an interactive shell in that directory</p>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('shell.selectProject.title')}</h3>
|
||||
<p>{t('shell.selectProject.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -400,13 +402,13 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
</span>
|
||||
)}
|
||||
{!selectedSession && (
|
||||
<span className="text-xs text-gray-400">(New Session)</span>
|
||||
<span className="text-xs text-gray-400">{t('shell.status.newSession')}</span>
|
||||
)}
|
||||
{!isInitialized && (
|
||||
<span className="text-xs text-yellow-400">(Initializing...)</span>
|
||||
<span className="text-xs text-yellow-400">{t('shell.status.initializing')}</span>
|
||||
)}
|
||||
{isRestarting && (
|
||||
<span className="text-xs text-blue-400">(Restarting...)</span>
|
||||
<span className="text-xs text-blue-400">{t('shell.status.restarting')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -414,12 +416,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
<button
|
||||
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"
|
||||
title="Disconnect from shell"
|
||||
title={t('shell.actions.disconnectTitle')}
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
<span>Disconnect</span>
|
||||
<span>{t('shell.actions.disconnect')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -427,12 +429,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
onClick={restartShell}
|
||||
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"
|
||||
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">
|
||||
<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>
|
||||
<span>Restart</span>
|
||||
<span>{t('shell.actions.restart')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -443,7 +445,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
|
||||
{!isInitialized && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -453,19 +455,19 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
<button
|
||||
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"
|
||||
title="Connect to shell"
|
||||
title={t('shell.actions.connectTitle')}
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
<span>Continue in Shell</span>
|
||||
<span>{t('shell.actions.connect')}</span>
|
||||
</button>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{isPlainShell ?
|
||||
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
||||
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
|
||||
selectedSession ?
|
||||
`Resume session: ${sessionDisplayNameLong}...` :
|
||||
'Start a new Claude session'
|
||||
t('shell.resumeSession', { displayName: sessionDisplayNameLong }) :
|
||||
t('shell.startSession')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@@ -477,12 +479,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
<div className="text-center max-w-sm w-full">
|
||||
<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>
|
||||
<span className="text-base font-medium">Connecting to shell...</span>
|
||||
<span className="text-base font-medium">{t('shell.connecting')}</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{isPlainShell ?
|
||||
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
||||
`Starting Claude CLI in ${selectedProject.displayName}`
|
||||
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
|
||||
t('shell.startCli', { projectName: selectedProject.displayName })
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
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 ClaudeLogo from './ClaudeLogo';
|
||||
import CursorLogo from './CursorLogo.jsx';
|
||||
@@ -80,6 +80,9 @@ function Sidebar({
|
||||
const [editingSessionName, setEditingSessionName] = useState('');
|
||||
const [generatingSummary, setGeneratingSummary] = 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
|
||||
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
|
||||
@@ -306,10 +309,15 @@ function Sidebar({
|
||||
setEditingName('');
|
||||
};
|
||||
|
||||
const deleteSession = async (projectName, sessionId, provider = 'claude') => {
|
||||
if (!confirm(t('messages.deleteSessionConfirm'))) {
|
||||
return;
|
||||
}
|
||||
const showDeleteSessionConfirmation = (projectName, sessionId, sessionTitle, provider = 'claude') => {
|
||||
setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
|
||||
};
|
||||
|
||||
const confirmDeleteSession = async () => {
|
||||
if (!sessionDeleteConfirmation) return;
|
||||
|
||||
const { projectName, sessionId, provider } = sessionDeleteConfirmation;
|
||||
setSessionDeleteConfirmation(null);
|
||||
|
||||
try {
|
||||
console.log('[Sidebar] Deleting session:', { projectName, sessionId, provider });
|
||||
@@ -343,18 +351,26 @@ function Sidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProject = async (projectName) => {
|
||||
if (!confirm(t('messages.deleteProjectConfirm'))) {
|
||||
return;
|
||||
}
|
||||
const deleteProject = (project) => {
|
||||
const sessionCount = getAllSessions(project).length;
|
||||
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 {
|
||||
const response = await api.deleteProject(projectName);
|
||||
const response = await api.deleteProject(project.name, !isEmpty);
|
||||
|
||||
if (response.ok) {
|
||||
// Call parent callback if provided
|
||||
if (onProjectDelete) {
|
||||
onProjectDelete(projectName);
|
||||
onProjectDelete(project.name);
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
@@ -364,6 +380,12 @@ function Sidebar({
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
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
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
className="h-full flex flex-col bg-card md:select-none"
|
||||
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
||||
@@ -669,7 +795,7 @@ function Sidebar({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('projects.fetchingProjects')}
|
||||
</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 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
@@ -679,7 +805,7 @@ function Sidebar({
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{loadingProgress.current}/{loadingProgress.total} projects
|
||||
{loadingProgress.current}/{loadingProgress.total} {t('projects.projects')}
|
||||
</p>
|
||||
{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>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fetching your Claude projects and sessions
|
||||
{t('projects.fetchingProjects')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -718,9 +844,10 @@ function Sidebar({
|
||||
const isExpanded = expandedProjects.has(project.name);
|
||||
const isSelected = selectedProject?.name === project.name;
|
||||
const isStarred = isProjectStarred(project.name);
|
||||
|
||||
const isDeleting = deletingProjects.has(project.name);
|
||||
|
||||
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 */}
|
||||
<div className="group md:group">
|
||||
{/* Mobile Project Item */}
|
||||
@@ -849,18 +976,16 @@ function Sidebar({
|
||||
: "text-gray-600 dark:text-gray-400"
|
||||
)} />
|
||||
</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"
|
||||
onClick={(e) => {
|
||||
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" />
|
||||
</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"
|
||||
onClick={(e) => {
|
||||
@@ -1009,18 +1134,16 @@ function Sidebar({
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
</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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteProject(project.name);
|
||||
deleteProject(project);
|
||||
}}
|
||||
title={t('tooltips.deleteProject')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<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"
|
||||
onClick={(e) => {
|
||||
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" />
|
||||
</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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(project.name, session.id, session.__provider);
|
||||
showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider);
|
||||
}}
|
||||
title={t('tooltips.deleteSession')}
|
||||
>
|
||||
|
||||
@@ -19,12 +19,14 @@ import enSettings from './locales/en/settings.json';
|
||||
import enAuth from './locales/en/auth.json';
|
||||
import enSidebar from './locales/en/sidebar.json';
|
||||
import enChat from './locales/en/chat.json';
|
||||
import enCodeEditor from './locales/en/codeEditor.json';
|
||||
|
||||
import zhCommon from './locales/zh-CN/common.json';
|
||||
import zhSettings from './locales/zh-CN/settings.json';
|
||||
import zhAuth from './locales/zh-CN/auth.json';
|
||||
import zhSidebar from './locales/zh-CN/sidebar.json';
|
||||
import zhChat from './locales/zh-CN/chat.json';
|
||||
import zhCodeEditor from './locales/zh-CN/codeEditor.json';
|
||||
|
||||
// Import supported languages configuration
|
||||
import { languages } from './languages.js';
|
||||
@@ -56,6 +58,7 @@ i18n
|
||||
auth: enAuth,
|
||||
sidebar: enSidebar,
|
||||
chat: enChat,
|
||||
codeEditor: enCodeEditor,
|
||||
},
|
||||
'zh-CN': {
|
||||
common: zhCommon,
|
||||
@@ -63,6 +66,7 @@ i18n
|
||||
auth: zhAuth,
|
||||
sidebar: zhSidebar,
|
||||
chat: zhChat,
|
||||
codeEditor: zhCodeEditor,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -76,7 +80,7 @@ i18n
|
||||
debug: import.meta.env.DEV,
|
||||
|
||||
// Namespaces - load only what's needed
|
||||
ns: ['common', 'settings', 'auth', 'sidebar', 'chat'],
|
||||
ns: ['common', 'settings', 'auth', 'sidebar', 'chat', 'codeEditor'],
|
||||
defaultNS: 'common',
|
||||
|
||||
// Key separator for nested keys (default: '.')
|
||||
|
||||
@@ -143,5 +143,63 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"existingPath": "Workspace Path",
|
||||
"newPath": "Where should the workspace be created?",
|
||||
"newPath": "Workspace Path",
|
||||
"existingPlaceholder": "/path/to/existing/workspace",
|
||||
"newPlaceholder": "/path/to/new/workspace",
|
||||
"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)",
|
||||
"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)",
|
||||
"githubAuthHelp": "Only required for private repositories. Public repos can be cloned without authentication.",
|
||||
"loadingTokens": "Loading stored tokens...",
|
||||
@@ -170,21 +170,53 @@
|
||||
"usingStoredToken": "Using stored token:",
|
||||
"usingProvidedToken": "Using provided token",
|
||||
"noAuthentication": "No authentication",
|
||||
"sshKey": "SSH Key",
|
||||
"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.",
|
||||
"newEmpty": "An empty workspace directory will be created at the specified path."
|
||||
"newWithClone": "The repository will be cloned from this folder.",
|
||||
"newEmpty": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
|
||||
"cloningRepository": "Cloning repository..."
|
||||
},
|
||||
"buttons": {
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"createProject": "Create Project",
|
||||
"creating": "Creating..."
|
||||
"creating": "Creating...",
|
||||
"cloning": "Cloning..."
|
||||
},
|
||||
"errors": {
|
||||
"selectType": "Please select whether you have an existing workspace or want to create a new one",
|
||||
"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",
|
||||
"autoScrollToBottom": "Auto-scroll to bottom",
|
||||
"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": {
|
||||
"agents": "Agents",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"newSession": "New Session",
|
||||
"codexSession": "Codex Session",
|
||||
"fetchingProjects": "Fetching your Claude projects and sessions",
|
||||
"projects": "projects",
|
||||
"noMatchingProjects": "No matching projects",
|
||||
"tryDifferentSearch": "Try adjusting your search term",
|
||||
"runClaudeCli": "Run Claude CLI in a project directory to get started"
|
||||
@@ -98,5 +99,14 @@
|
||||
},
|
||||
"version": {
|
||||
"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}}"
|
||||
},
|
||||
"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": {
|
||||
"existingPath": "工作区路径",
|
||||
"newPath": "应该在哪里创建工作区?",
|
||||
"newPath": "工作区路径",
|
||||
"existingPlaceholder": "/path/to/existing/workspace",
|
||||
"newPlaceholder": "/path/to/new/workspace",
|
||||
"existingHelp": "您现有工作区目录的完整路径",
|
||||
"newHelp": "将创建新工作区的完整路径",
|
||||
"newHelp": "工作区目录的完整路径",
|
||||
"githubUrl": "GitHub URL(可选)",
|
||||
"githubPlaceholder": "https://github.com/username/repository",
|
||||
"githubHelp": "留空以创建空工作区,或提供 GitHub URL 以克隆",
|
||||
"githubHelp": "可选:提供 GitHub URL 以克隆仓库",
|
||||
"githubAuth": "GitHub 身份验证(可选)",
|
||||
"githubAuthHelp": "仅私有仓库需要。公共仓库无需身份验证即可克隆。",
|
||||
"loadingTokens": "正在加载已保存的令牌...",
|
||||
@@ -170,21 +170,53 @@
|
||||
"usingStoredToken": "使用已保存的令牌:",
|
||||
"usingProvidedToken": "使用提供的令牌",
|
||||
"noAuthentication": "无身份验证",
|
||||
"sshKey": "SSH 密钥",
|
||||
"existingInfo": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。",
|
||||
"newWithClone": "将创建新工作区,并从 GitHub 克隆仓库。",
|
||||
"newEmpty": "将在指定路径创建一个空的工作区目录。"
|
||||
"newWithClone": "仓库将从此文件夹克隆。",
|
||||
"newEmpty": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。",
|
||||
"cloningRepository": "正在克隆仓库..."
|
||||
},
|
||||
"buttons": {
|
||||
"cancel": "取消",
|
||||
"back": "返回",
|
||||
"next": "下一步",
|
||||
"createProject": "创建项目",
|
||||
"creating": "创建中..."
|
||||
"creating": "创建中...",
|
||||
"cloning": "正在克隆..."
|
||||
},
|
||||
"errors": {
|
||||
"selectType": "请选择您已有现有工作区还是想创建新工作区",
|
||||
"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": "显示思考过程",
|
||||
"autoScrollToBottom": "自动滚动到底部",
|
||||
"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": {
|
||||
"agents": "智能体",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"newSession": "新会话",
|
||||
"codexSession": "Codex 会话",
|
||||
"fetchingProjects": "正在获取您的 Claude 项目和会话",
|
||||
"projects": "项目",
|
||||
"noMatchingProjects": "未找到匹配的项目",
|
||||
"tryDifferentSearch": "尝试调整您的搜索词",
|
||||
"runClaudeCli": "在项目目录中运行 Claude CLI 以开始使用"
|
||||
@@ -98,5 +99,14 @@
|
||||
},
|
||||
"version": {
|
||||
"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}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
deleteProject: (projectName) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}`, {
|
||||
deleteProject: (projectName, force = false) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
createProject: (path) =>
|
||||
@@ -158,6 +158,12 @@ export const api = {
|
||||
return authenticatedFetch(`/api/browse-filesystem?${params}`);
|
||||
},
|
||||
|
||||
createFolder: (folderPath) =>
|
||||
authenticatedFetch('/api/create-folder', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: folderPath }),
|
||||
}),
|
||||
|
||||
// User endpoints
|
||||
user: {
|
||||
gitConfig: () => authenticatedFetch('/api/user/git-config'),
|
||||
|
||||
@@ -45,4 +45,4 @@ export default defineConfig(({ command, mode }) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user