mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-12 03:37:32 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ed3358cbd | ||
|
|
c1e025b665 | ||
|
|
cf3d23ee31 | ||
|
|
e7d6c40452 | ||
|
|
216932e7f9 | ||
|
|
e9719256fc | ||
|
|
55caaf060c | ||
|
|
f9c7321c8c | ||
|
|
88bda6e5c0 | ||
|
|
86b421c790 | ||
|
|
41ef84c283 | ||
|
|
53224e47b6 | ||
|
|
bbb51dbf99 | ||
|
|
2d06cae0ca | ||
|
|
14fb81586c | ||
|
|
4d2b592ec6 | ||
|
|
4957220a05 | ||
|
|
3debc3a249 | ||
|
|
5512e2e15b | ||
|
|
1b42dba902 | ||
|
|
ede56ad81b | ||
|
|
36094fb73f | ||
|
|
57828653bf | ||
|
|
8ef0951901 | ||
|
|
ab50c5c1a8 | ||
|
|
6726e8f44e | ||
|
|
07f89e5240 | ||
|
|
8a675a713b | ||
|
|
5724c11253 | ||
|
|
c7b9976986 |
37
package-lock.json
generated
37
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.14.0",
|
||||
"version": "1.16.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.14.0",
|
||||
"version": "1.16.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
||||
@@ -68,6 +68,7 @@
|
||||
"cloudcli": "server/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.7",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
@@ -79,6 +80,7 @@
|
||||
"release-it": "^19.0.5",
|
||||
"sharp": "^0.34.2",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
},
|
||||
@@ -2906,6 +2908,16 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-path": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
|
||||
@@ -11662,6 +11674,20 @@
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
@@ -11686,6 +11712,13 @@
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
|
||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.14.0",
|
||||
"version": "1.16.4",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
@@ -14,7 +14,7 @@
|
||||
"dist/",
|
||||
"README.md"
|
||||
],
|
||||
"homepage": "https://claudecodeui.siteboon.ai",
|
||||
"homepage": "https://cloudcli.ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/siteboon/claudecodeui.git"
|
||||
@@ -28,18 +28,19 @@
|
||||
"client": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"start": "npm run build && npm run server",
|
||||
"release": "./release.sh"
|
||||
},
|
||||
"keywords": [
|
||||
"claude coode",
|
||||
"claude code",
|
||||
"ai",
|
||||
"anthropic",
|
||||
"ui",
|
||||
"mobile"
|
||||
],
|
||||
"author": "Claude Code UI Contributors",
|
||||
"license": "MIT",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@@ -96,6 +97,7 @@
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.7",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
@@ -107,6 +109,7 @@
|
||||
"release-it": "^19.0.5",
|
||||
"sharp": "^0.34.2",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
5
server/constants/config.js
Normal file
5
server/constants/config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Environment Flag: Is Platform
|
||||
* Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
|
||||
*/
|
||||
export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
|
||||
237
server/index.js
237
server/index.js
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
// Load environment variables from .env file
|
||||
// Load environment variables before other imports execute
|
||||
import './load-env.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -28,22 +29,6 @@ const c = {
|
||||
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
||||
};
|
||||
|
||||
try {
|
||||
const envPath = path.join(__dirname, '../.env');
|
||||
const envFile = fs.readFileSync(envPath, 'utf8');
|
||||
envFile.split('\n').forEach(line => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine && !trimmedLine.startsWith('#')) {
|
||||
const [key, ...valueParts] = trimmedLine.split('=');
|
||||
if (key && valueParts.length > 0 && !process.env[key]) {
|
||||
process.env[key] = valueParts.join('=').trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('No .env file found or error reading it:', e.message);
|
||||
}
|
||||
|
||||
console.log('PORT from env:', process.env.PORT);
|
||||
|
||||
import express from 'express';
|
||||
@@ -70,12 +55,13 @@ 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, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
|
||||
import cliAuthRoutes from './routes/cli-auth.js';
|
||||
import userRoutes from './routes/user.js';
|
||||
import codexRoutes from './routes/codex.js';
|
||||
import { initializeDatabase } from './database/db.js';
|
||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||
import { IS_PLATFORM } from './constants/config.js';
|
||||
|
||||
// File system watcher for projects folder
|
||||
let projectsWatcher = null;
|
||||
@@ -192,6 +178,69 @@ const server = http.createServer(app);
|
||||
|
||||
const ptySessionsMap = new Map();
|
||||
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
|
||||
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
|
||||
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
|
||||
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
|
||||
|
||||
function stripAnsiSequences(value = '') {
|
||||
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
|
||||
}
|
||||
|
||||
function normalizeDetectedUrl(url) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
|
||||
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
|
||||
if (!cleaned) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(cleaned);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUrlsFromText(value = '') {
|
||||
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
|
||||
|
||||
// Handle wrapped terminal URLs split across lines by terminal width.
|
||||
const wrappedMatches = [];
|
||||
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
|
||||
const lines = value.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
|
||||
if (!startMatch) continue;
|
||||
|
||||
let combined = startMatch[0];
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const continuation = lines[j].trim();
|
||||
if (!continuation) break;
|
||||
if (!continuationRegex.test(continuation)) break;
|
||||
combined += continuation;
|
||||
j++;
|
||||
}
|
||||
|
||||
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
|
||||
}
|
||||
|
||||
return Array.from(new Set([...directMatches, ...wrappedMatches]));
|
||||
}
|
||||
|
||||
function shouldAutoOpenUrlFromOutput(value = '') {
|
||||
const normalized = value.toLowerCase();
|
||||
return (
|
||||
normalized.includes('browser didn\'t open') ||
|
||||
normalized.includes('open this url') ||
|
||||
normalized.includes('continue in your browser') ||
|
||||
normalized.includes('press enter to open') ||
|
||||
normalized.includes('open_url:')
|
||||
);
|
||||
}
|
||||
|
||||
// Single WebSocket server that handles both paths
|
||||
const wss = new WebSocketServer({
|
||||
@@ -200,7 +249,7 @@ const wss = new WebSocketServer({
|
||||
console.log('WebSocket connection attempt to:', info.req.url);
|
||||
|
||||
// Platform mode: always allow connection
|
||||
if (process.env.VITE_IS_PLATFORM === 'true') {
|
||||
if (IS_PLATFORM) {
|
||||
const user = authenticateWebSocket(null); // Will return first user
|
||||
if (!user) {
|
||||
console.log('[WARN] Platform mode: No user found in database');
|
||||
@@ -484,22 +533,42 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const expandWorkspacePath = (inputPath) => {
|
||||
if (!inputPath) return inputPath;
|
||||
if (inputPath === '~') {
|
||||
return WORKSPACES_ROOT;
|
||||
}
|
||||
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
|
||||
return path.join(WORKSPACES_ROOT, inputPath.slice(2));
|
||||
}
|
||||
return inputPath;
|
||||
};
|
||||
|
||||
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
|
||||
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { path: dirPath } = req.query;
|
||||
|
||||
console.log('[API] Browse filesystem request for path:', dirPath);
|
||||
console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
|
||||
// Default to home directory if no path provided
|
||||
const homeDir = os.homedir();
|
||||
let targetPath = dirPath ? dirPath.replace('~', homeDir) : homeDir;
|
||||
const defaultRoot = WORKSPACES_ROOT;
|
||||
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
|
||||
|
||||
// Resolve and normalize the path
|
||||
targetPath = path.resolve(targetPath);
|
||||
|
||||
// Security check - ensure path is within allowed workspace root
|
||||
const validation = await validateWorkspacePath(targetPath);
|
||||
if (!validation.valid) {
|
||||
return res.status(403).json({ error: validation.error });
|
||||
}
|
||||
const resolvedPath = validation.resolvedPath || targetPath;
|
||||
|
||||
// Security check - ensure path is accessible
|
||||
try {
|
||||
await fs.promises.access(targetPath);
|
||||
const stats = await fs.promises.stat(targetPath);
|
||||
await fs.promises.access(resolvedPath);
|
||||
const stats = await fs.promises.stat(resolvedPath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Path is not a directory' });
|
||||
@@ -509,7 +578,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
}
|
||||
|
||||
// Use existing getFileTree function with shallow depth (only direct children)
|
||||
const fileTree = await getFileTree(targetPath, 1, 0, false); // maxDepth=1, showHidden=false
|
||||
const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
|
||||
|
||||
// Filter only directories and format for suggestions
|
||||
const directories = fileTree
|
||||
@@ -529,7 +598,13 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
|
||||
// Add common directories if browsing home directory
|
||||
const suggestions = [];
|
||||
if (targetPath === homeDir) {
|
||||
let resolvedWorkspaceRoot = defaultRoot;
|
||||
try {
|
||||
resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
|
||||
} catch (error) {
|
||||
// Use default root as-is if realpath fails
|
||||
}
|
||||
if (resolvedPath === resolvedWorkspaceRoot) {
|
||||
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
|
||||
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
|
||||
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
|
||||
@@ -540,7 +615,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
}
|
||||
|
||||
res.json({
|
||||
path: targetPath,
|
||||
path: resolvedPath,
|
||||
suggestions: suggestions
|
||||
});
|
||||
|
||||
@@ -550,6 +625,46 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/create-folder', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { path: folderPath } = req.body;
|
||||
if (!folderPath) {
|
||||
return res.status(400).json({ error: 'Path is required' });
|
||||
}
|
||||
const expandedPath = expandWorkspacePath(folderPath);
|
||||
const resolvedInput = path.resolve(expandedPath);
|
||||
const validation = await validateWorkspacePath(resolvedInput);
|
||||
if (!validation.valid) {
|
||||
return res.status(403).json({ error: validation.error });
|
||||
}
|
||||
const targetPath = validation.resolvedPath || resolvedInput;
|
||||
const parentDir = path.dirname(targetPath);
|
||||
try {
|
||||
await fs.promises.access(parentDir);
|
||||
} catch (err) {
|
||||
return res.status(404).json({ error: 'Parent directory does not exist' });
|
||||
}
|
||||
try {
|
||||
await fs.promises.access(targetPath);
|
||||
return res.status(409).json({ error: 'Folder already exists' });
|
||||
} catch (err) {
|
||||
// Folder doesn't exist, which is what we want
|
||||
}
|
||||
try {
|
||||
await fs.promises.mkdir(targetPath, { recursive: false });
|
||||
res.json({ success: true, path: targetPath });
|
||||
} catch (mkdirError) {
|
||||
if (mkdirError.code === 'EEXIST') {
|
||||
return res.status(409).json({ error: 'Folder already exists' });
|
||||
}
|
||||
throw mkdirError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating folder:', error);
|
||||
res.status(500).json({ error: 'Failed to create folder' });
|
||||
}
|
||||
});
|
||||
|
||||
// Read file content endpoint
|
||||
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
@@ -908,7 +1023,8 @@ function handleShellConnection(ws) {
|
||||
console.log('🐚 Shell client connected');
|
||||
let shellProcess = null;
|
||||
let ptySessionKey = null;
|
||||
let outputBuffer = [];
|
||||
let urlDetectionBuffer = '';
|
||||
const announcedAuthUrls = new Set();
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
try {
|
||||
@@ -922,6 +1038,8 @@ function handleShellConnection(ws) {
|
||||
const provider = data.provider || 'claude';
|
||||
const initialCommand = data.initialCommand;
|
||||
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
|
||||
urlDetectionBuffer = '';
|
||||
announcedAuthUrls.clear();
|
||||
|
||||
// Login commands (Claude/Cursor auth) should never reuse cached sessions
|
||||
const isLoginCommand = initialCommand && (
|
||||
@@ -1061,9 +1179,7 @@ function handleShellConnection(ws) {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
FORCE_COLOR: '3',
|
||||
// Override browser opening commands to echo URL for detection
|
||||
BROWSER: os.platform() === 'win32' ? 'echo "OPEN_URL:"' : 'echo "OPEN_URL:"'
|
||||
FORCE_COLOR: '3'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1093,38 +1209,47 @@ function handleShellConnection(ws) {
|
||||
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||
let outputData = data;
|
||||
|
||||
// Check for various URL opening patterns
|
||||
const patterns = [
|
||||
// Direct browser opening commands
|
||||
/(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g,
|
||||
// BROWSER environment variable override
|
||||
const cleanChunk = stripAnsiSequences(data);
|
||||
urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
|
||||
|
||||
outputData = outputData.replace(
|
||||
/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
|
||||
// Git and other tools opening URLs
|
||||
/Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi,
|
||||
// General URL patterns that might be opened
|
||||
/Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
|
||||
/View at:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
|
||||
/Browse to:\s*(https?:\/\/[^\s\x1b\x07]+)/gi
|
||||
];
|
||||
'[INFO] Opening in browser: $1'
|
||||
);
|
||||
|
||||
patterns.forEach(pattern => {
|
||||
let match;
|
||||
while ((match = pattern.exec(data)) !== null) {
|
||||
const url = match[1];
|
||||
console.log('[DEBUG] Detected URL for opening:', url);
|
||||
const emitAuthUrl = (detectedUrl, autoOpen = false) => {
|
||||
const normalizedUrl = normalizeDetectedUrl(detectedUrl);
|
||||
if (!normalizedUrl) return;
|
||||
|
||||
// Send URL opening message to client
|
||||
const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
|
||||
if (isNewUrl) {
|
||||
announcedAuthUrls.add(normalizedUrl);
|
||||
session.ws.send(JSON.stringify({
|
||||
type: 'url_open',
|
||||
url: url
|
||||
type: 'auth_url',
|
||||
url: normalizedUrl,
|
||||
autoOpen
|
||||
}));
|
||||
|
||||
// Replace the OPEN_URL pattern with a user-friendly message
|
||||
if (pattern.source.includes('OPEN_URL')) {
|
||||
outputData = outputData.replace(match[0], `[INFO] Opening in browser: ${url}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer)
|
||||
.map((url) => normalizeDetectedUrl(url))
|
||||
.filter(Boolean);
|
||||
|
||||
// Prefer the most complete URL if shorter prefix variants are also present.
|
||||
const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) =>
|
||||
!urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
|
||||
);
|
||||
|
||||
dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
|
||||
|
||||
if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) {
|
||||
const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
|
||||
current.length > longest.length ? current : longest
|
||||
);
|
||||
emitAuthUrl(bestUrl, true);
|
||||
}
|
||||
|
||||
// Send regular output
|
||||
session.ws.send(JSON.stringify({
|
||||
|
||||
24
server/load-env.js
Normal file
24
server/load-env.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// Load environment variables from .env before other imports execute.
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
try {
|
||||
const envPath = path.join(__dirname, '../.env');
|
||||
const envFile = fs.readFileSync(envPath, 'utf8');
|
||||
envFile.split('\n').forEach(line => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine && !trimmedLine.startsWith('#')) {
|
||||
const [key, ...valueParts] = trimmedLine.split('=');
|
||||
if (key && valueParts.length > 0 && !process.env[key]) {
|
||||
process.env[key] = valueParts.join('=').trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('No .env file found or error reading it:', e.message);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { userDb } from '../database/db.js';
|
||||
import { IS_PLATFORM } from '../constants/config.js';
|
||||
|
||||
// Get JWT secret from environment or use default (for development)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
|
||||
@@ -21,7 +22,7 @@ const validateApiKey = (req, res, next) => {
|
||||
// JWT authentication middleware
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
// Platform mode: use single database user
|
||||
if (process.env.VITE_IS_PLATFORM === 'true') {
|
||||
if (IS_PLATFORM) {
|
||||
try {
|
||||
const user = userDb.getFirstUser();
|
||||
if (!user) {
|
||||
@@ -37,7 +38,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.' });
|
||||
@@ -75,7 +81,7 @@ const generateToken = (user) => {
|
||||
// WebSocket authentication function
|
||||
const authenticateWebSocket = (token) => {
|
||||
// Platform mode: bypass token validation, return first user
|
||||
if (process.env.VITE_IS_PLATFORM === 'true') {
|
||||
if (IS_PLATFORM) {
|
||||
try {
|
||||
const user = userDb.getFirstUser();
|
||||
if (user) {
|
||||
|
||||
@@ -1095,7 +1095,7 @@ async function addProjectManually(projectPath, displayName = null) {
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { spawnCursor } from '../cursor-cli.js';
|
||||
import { queryCodex } from '../openai-codex.js';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||
import { IS_PLATFORM } from '../constants/config.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -18,7 +19,7 @@ const router = express.Router();
|
||||
* Middleware to authenticate agent API requests.
|
||||
*
|
||||
* Supports two authentication modes:
|
||||
* 1. Platform mode (VITE_IS_PLATFORM=true): For managed/hosted deployments where
|
||||
* 1. Platform mode (IS_PLATFORM=true): For managed/hosted deployments where
|
||||
* authentication is handled by an external proxy. Requests are trusted and
|
||||
* the default user context is used.
|
||||
*
|
||||
@@ -28,7 +29,7 @@ const router = express.Router();
|
||||
const validateExternalApiKey = (req, res, next) => {
|
||||
// Platform mode: Authentication is handled externally (e.g., by a proxy layer).
|
||||
// Trust the request and use the default user context.
|
||||
if (process.env.VITE_IS_PLATFORM === 'true') {
|
||||
if (IS_PLATFORM) {
|
||||
try {
|
||||
const user = userDb.getFirstUser();
|
||||
if (!user) {
|
||||
|
||||
@@ -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();
|
||||
export 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'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -35,7 +48,7 @@ const FORBIDDEN_PATHS = [
|
||||
* @param {string} requestedPath - The path to validate
|
||||
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
|
||||
*/
|
||||
async function validateWorkspacePath(requestedPath) {
|
||||
export async function validateWorkspacePath(requestedPath) {
|
||||
try {
|
||||
// Resolve to absolute path
|
||||
let absolutePath = path.resolve(requestedPath);
|
||||
@@ -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')) {
|
||||
|
||||
18
src/App.jsx
18
src/App.jsx
@@ -31,7 +31,7 @@ import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { TaskMasterProvider } from './contexts/TaskMasterContext';
|
||||
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
|
||||
import { WebSocketProvider, useWebSocketContext } from './contexts/WebSocketContext';
|
||||
import { WebSocketProvider, useWebSocket } from './contexts/WebSocketContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import { useVersionCheck } from './hooks/useVersionCheck';
|
||||
import useLocalStorage from './hooks/useLocalStorage';
|
||||
@@ -40,11 +40,15 @@ import { I18nextProvider, useTranslation } from 'react-i18next';
|
||||
import i18n from './i18n/config.js';
|
||||
|
||||
|
||||
// ! Move to a separate file called AppContent.ts
|
||||
// Main App component with routing
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const { sessionId } = useParams();
|
||||
const { t } = useTranslation('common');
|
||||
// * This is a tracker for avoiding excessive re-renders during development
|
||||
const renderCountRef = useRef(0);
|
||||
// console.log(`AppContent render count: ${renderCountRef.current++}`);
|
||||
|
||||
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
|
||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
@@ -81,7 +85,7 @@ function AppContent() {
|
||||
// Triggers ChatInterface to reload messages without switching sessions
|
||||
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
|
||||
|
||||
const { ws, sendMessage, messages } = useWebSocketContext();
|
||||
const { ws, sendMessage, latestMessage } = useWebSocket();
|
||||
|
||||
// Ref to track loading progress timeout for cleanup
|
||||
const loadingProgressTimeoutRef = useRef(null);
|
||||
@@ -175,9 +179,7 @@ function AppContent() {
|
||||
|
||||
// Handle WebSocket messages for real-time project updates
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
|
||||
if (latestMessage) {
|
||||
// Handle loading progress updates
|
||||
if (latestMessage.type === 'loading_progress') {
|
||||
if (loadingProgressTimeoutRef.current) {
|
||||
@@ -277,7 +279,7 @@ function AppContent() {
|
||||
loadingProgressTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [messages, selectedProject, selectedSession, activeSessions]);
|
||||
}, [latestMessage, selectedProject, selectedSession, activeSessions]);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
@@ -916,7 +918,7 @@ function AppContent() {
|
||||
setActiveTab={setActiveTab}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
messages={messages}
|
||||
latestMessage={latestMessage}
|
||||
isMobile={isMobile}
|
||||
isPWA={isPWA}
|
||||
onMenuClick={() => setSidebarOpen(true)}
|
||||
@@ -990,7 +992,7 @@ function App() {
|
||||
<TasksSettingsProvider>
|
||||
<TaskMasterProvider>
|
||||
<ProtectedRoute>
|
||||
<Router>
|
||||
<Router basename={window.__ROUTER_BASENAME__ || ''}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppContent />} />
|
||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||
|
||||
@@ -43,6 +43,8 @@ import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelCo
|
||||
|
||||
import { safeJsonParse } from '../lib/utils.js';
|
||||
|
||||
// ! Move all utility functions to utils/chatUtils.ts
|
||||
|
||||
// Helper function to decode HTML entities in text
|
||||
function decodeHtmlEntities(text) {
|
||||
if (!text) return text;
|
||||
@@ -1860,7 +1862,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
|
||||
// - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID
|
||||
//
|
||||
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations.
|
||||
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, onTaskClick, onShowAllTasks }) {
|
||||
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, latestMessage, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, onTaskClick, onShowAllTasks }) {
|
||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
||||
const { t } = useTranslation('chat');
|
||||
const [input, setInput] = useState(() => {
|
||||
@@ -3242,8 +3244,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
useEffect(() => {
|
||||
// Handle WebSocket messages
|
||||
if (messages.length > 0) {
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
if (latestMessage) {
|
||||
const messageData = latestMessage.data?.message || latestMessage.data;
|
||||
|
||||
// Filter messages by session ID to prevent cross-session interference
|
||||
@@ -4068,7 +4069,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
}
|
||||
}
|
||||
}, [messages]);
|
||||
}, [latestMessage]);
|
||||
|
||||
// Load file list when project changes
|
||||
useEffect(() => {
|
||||
@@ -4879,7 +4880,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
};
|
||||
|
||||
|
||||
|
||||
// ! Unused
|
||||
const handleNewSession = () => {
|
||||
setChatMessages([]);
|
||||
setInput('');
|
||||
@@ -5590,7 +5591,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
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-sm sm:text-base leading-[21px] sm:leading-6 whitespace-pre-wrap break-words">
|
||||
<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>
|
||||
@@ -5619,7 +5620,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 */}
|
||||
|
||||
@@ -960,6 +960,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
{gitStatus.details && (
|
||||
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{gitStatus.details}</p>
|
||||
)}
|
||||
{/* // ! This can be a custom component that can be reused for " Tip: Create a new project..." as well */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 max-w-md">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 text-center">
|
||||
<strong>Tip:</strong> Run <code className="bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded font-mono text-xs">git init</code> in your project directory to initialize git source control.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { X } from 'lucide-react';
|
||||
import StandaloneShell from './StandaloneShell';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
/**
|
||||
* Reusable login modal component for Claude, Cursor, and Codex CLI authentication
|
||||
@@ -27,17 +28,15 @@ function LoginModal({
|
||||
const getCommand = () => {
|
||||
if (customCommand) return customCommand;
|
||||
|
||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
|
||||
switch (provider) {
|
||||
case 'claude':
|
||||
return isAuthenticated ? 'claude /login --dangerously-skip-permissions' : 'claude setup-token --dangerously-skip-permissions';
|
||||
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /exit --dangerously-skip-permissions';
|
||||
case 'cursor':
|
||||
return 'cursor-agent login';
|
||||
case 'codex':
|
||||
return isPlatform ? 'codex login --device-auth' : 'codex login';
|
||||
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
|
||||
default:
|
||||
return isAuthenticated ? 'claude /login --dangerously-skip-permissions' : 'claude setup-token --dangerously-skip-permissions';
|
||||
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /exit --dangerously-skip-permissions';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,9 +57,7 @@ function LoginModal({
|
||||
if (onComplete) {
|
||||
onComplete(exitCode);
|
||||
}
|
||||
if (exitCode === 0) {
|
||||
onClose();
|
||||
}
|
||||
// Keep modal open so users can read login output and close explicitly.
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -36,9 +36,9 @@ function MainContent({
|
||||
setActiveTab,
|
||||
ws,
|
||||
sendMessage,
|
||||
messages,
|
||||
latestMessage,
|
||||
isMobile,
|
||||
isPWA,
|
||||
isPWA, // ! Unused
|
||||
onMenuClick,
|
||||
isLoading,
|
||||
onInputFocusChange,
|
||||
@@ -477,7 +477,7 @@ function MainContent({
|
||||
selectedSession={selectedSession}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
messages={messages}
|
||||
latestMessage={latestMessage}
|
||||
onFileOpen={handleFileOpen}
|
||||
onInputFocusChange={onInputFocusChange}
|
||||
onSessionActive={onSessionActive}
|
||||
|
||||
@@ -6,6 +6,7 @@ import CodexLogo from './CodexLogo';
|
||||
import LoginModal from './LoginModal';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
const Onboarding = ({ onComplete }) => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
@@ -15,7 +16,7 @@ const Onboarding = ({ onComplete }) => {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [activeLoginProvider, setActiveLoginProvider] = useState(null);
|
||||
const [selectedProject] = useState({ name: 'default', fullPath: '' });
|
||||
const [selectedProject] = useState({ name: 'default', fullPath: IS_PLATFORM ? '/workspace' : '' });
|
||||
|
||||
const [claudeAuthStatus, setClaudeAuthStatus] = useState({
|
||||
authenticated: false,
|
||||
|
||||
@@ -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,32 +123,69 @@ 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();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || t('projectWizard.errors.failedToCreate'));
|
||||
throw new Error(data.details || data.error || t('projectWizard.errors.failedToCreate'));
|
||||
}
|
||||
|
||||
// Success!
|
||||
if (onProjectCreated) {
|
||||
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>
|
||||
|
||||
@@ -4,6 +4,7 @@ import SetupForm from './SetupForm';
|
||||
import LoginForm from './LoginForm';
|
||||
import Onboarding from './Onboarding';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
const LoadingScreen = () => (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
@@ -27,7 +28,7 @@ const LoadingScreen = () => (
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth();
|
||||
|
||||
if (import.meta.env.VITE_IS_PLATFORM === 'true') {
|
||||
if (IS_PLATFORM) {
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
const xtermStyles = `
|
||||
.xterm .xterm-screen {
|
||||
@@ -25,6 +26,31 @@ if (typeof document !== 'undefined') {
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
|
||||
function fallbackCopyToClipboard(text) {
|
||||
if (!text || typeof document === 'undefined') return false;
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
let copied = false;
|
||||
try {
|
||||
copied = document.execCommand('copy');
|
||||
} catch {
|
||||
copied = false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const terminalRef = useRef(null);
|
||||
@@ -36,12 +62,15 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [lastSessionId, setLastSessionId] = useState(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [authUrl, setAuthUrl] = useState('');
|
||||
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle');
|
||||
|
||||
const selectedProjectRef = useRef(selectedProject);
|
||||
const selectedSessionRef = useRef(selectedSession);
|
||||
const initialCommandRef = useRef(initialCommand);
|
||||
const isPlainShellRef = useRef(isPlainShell);
|
||||
const onProcessCompleteRef = useRef(onProcessComplete);
|
||||
const authUrlRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
selectedProjectRef.current = selectedProject;
|
||||
@@ -51,14 +80,49 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
onProcessCompleteRef.current = onProcessComplete;
|
||||
});
|
||||
|
||||
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
|
||||
if (!url) return false;
|
||||
|
||||
const popup = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
if (popup) {
|
||||
try {
|
||||
popup.opener = null;
|
||||
} catch {
|
||||
// Ignore cross-origin restrictions when trying to null opener
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
|
||||
if (!url) return false;
|
||||
|
||||
let copied = false;
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
copied = true;
|
||||
}
|
||||
} catch {
|
||||
copied = false;
|
||||
}
|
||||
|
||||
if (!copied) {
|
||||
copied = fallbackCopyToClipboard(url);
|
||||
}
|
||||
|
||||
return copied;
|
||||
}, []);
|
||||
|
||||
const connectWebSocket = useCallback(async () => {
|
||||
if (isConnecting || isConnected) return;
|
||||
|
||||
try {
|
||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
let wsUrl;
|
||||
|
||||
if (isPlatform) {
|
||||
if (IS_PLATFORM) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/shell`;
|
||||
} else {
|
||||
@@ -77,6 +141,9 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
ws.current.onopen = () => {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
authUrlRef.current = '';
|
||||
setAuthUrl('');
|
||||
setAuthUrlCopyStatus('idle');
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current && terminal.current) {
|
||||
@@ -119,8 +186,16 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
if (terminal.current) {
|
||||
terminal.current.write(output);
|
||||
}
|
||||
} else if (data.type === 'auth_url' && data.url) {
|
||||
authUrlRef.current = data.url;
|
||||
setAuthUrl(data.url);
|
||||
setAuthUrlCopyStatus('idle');
|
||||
} else if (data.type === 'url_open') {
|
||||
window.open(data.url, '_blank');
|
||||
if (data.url) {
|
||||
authUrlRef.current = data.url;
|
||||
setAuthUrl(data.url);
|
||||
setAuthUrlCopyStatus('idle');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Shell] Error handling WebSocket message:', error, event.data);
|
||||
@@ -130,6 +205,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
ws.current.onclose = (event) => {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setAuthUrlCopyStatus('idle');
|
||||
|
||||
if (terminal.current) {
|
||||
terminal.current.clear();
|
||||
@@ -145,7 +221,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [isConnecting, isConnected]);
|
||||
}, [isConnecting, isConnected, openAuthUrlInBrowser]);
|
||||
|
||||
const connectToShell = useCallback(() => {
|
||||
if (!isInitialized || isConnected || isConnecting) return;
|
||||
@@ -166,6 +242,9 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
authUrlRef.current = '';
|
||||
setAuthUrl('');
|
||||
setAuthUrlCopyStatus('idle');
|
||||
}, []);
|
||||
|
||||
const sessionDisplayName = useMemo(() => {
|
||||
@@ -201,6 +280,9 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
|
||||
setIsConnected(false);
|
||||
setIsInitialized(false);
|
||||
authUrlRef.current = '';
|
||||
setAuthUrl('');
|
||||
setAuthUrlCopyStatus('idle');
|
||||
|
||||
setTimeout(() => {
|
||||
setIsRestarting(false);
|
||||
@@ -234,7 +316,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
tabStopWidth: 4,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: true,
|
||||
macOptionClickForcesSelection: false,
|
||||
macOptionClickForcesSelection: true,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
@@ -272,7 +354,10 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
terminal.current.loadAddon(fitAddon.current);
|
||||
terminal.current.loadAddon(webLinksAddon);
|
||||
// Disable xterm link auto-detection in minimal (login) mode to avoid partial wrapped URL links.
|
||||
if (!minimal) {
|
||||
terminal.current.loadAddon(webLinksAddon);
|
||||
}
|
||||
// Note: ClipboardAddon removed - we handle clipboard operations manually in attachCustomKeyEventHandler
|
||||
|
||||
try {
|
||||
@@ -284,12 +369,41 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
terminal.current.open(terminalRef.current);
|
||||
|
||||
terminal.current.attachCustomKeyEventHandler((event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) {
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
minimal &&
|
||||
isPlainShellRef.current &&
|
||||
authUrlRef.current &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
event.key?.toLowerCase() === 'c'
|
||||
) {
|
||||
copyAuthUrlToClipboard(authUrlRef.current).catch(() => {});
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key?.toLowerCase() === 'c' &&
|
||||
terminal.current.hasSelection()
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.execCommand('copy');
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key?.toLowerCase() === 'v'
|
||||
) {
|
||||
// Block native browser/xterm paste so clipboard data is only sent after
|
||||
// the explicit clipboard-read flow resolves (avoids duplicate pastes).
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
navigator.clipboard.readText().then(text => {
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
@@ -359,7 +473,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
terminal.current = null;
|
||||
}
|
||||
};
|
||||
}, [selectedProject?.path || selectedProject?.fullPath, isRestarting]);
|
||||
}, [selectedProject?.path || selectedProject?.fullPath, isRestarting, minimal, copyAuthUrlToClipboard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoConnect || !isInitialized || isConnecting || isConnected) return;
|
||||
@@ -383,9 +497,47 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
}
|
||||
|
||||
if (minimal) {
|
||||
const hasAuthUrl = Boolean(authUrl);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-gray-900">
|
||||
<div className="h-full w-full bg-gray-900 relative">
|
||||
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
||||
{hasAuthUrl && (
|
||||
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
|
||||
<input
|
||||
type="text"
|
||||
value={authUrl}
|
||||
readOnly
|
||||
onClick={(event) => event.currentTarget.select()}
|
||||
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
aria-label="Authentication URL"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
openAuthUrlInBrowser(authUrl);
|
||||
}}
|
||||
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Open URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const copied = await copyAuthUrlToClipboard(authUrl);
|
||||
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
|
||||
}}
|
||||
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
|
||||
>
|
||||
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import ProjectCreationWizard from './ProjectCreationWizard';
|
||||
import { api } from '../utils/api';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
// Move formatTimeAgo outside component to avoid recreation on every render
|
||||
const formatTimeAgo = (dateString, currentTime, t) => {
|
||||
@@ -622,7 +623,7 @@ function Sidebar({
|
||||
<div className="md:p-4 md:border-b md:border-border">
|
||||
{/* Desktop Header */}
|
||||
<div className="hidden md:flex items-center justify-between">
|
||||
{import.meta.env.VITE_IS_PLATFORM === 'true' ? (
|
||||
{IS_PLATFORM ? (
|
||||
<a
|
||||
href="https://cloudcli.ai/dashboard"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity group"
|
||||
@@ -673,7 +674,7 @@ function Sidebar({
|
||||
style={isPWA && isMobile ? { paddingTop: '16px' } : {}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
{import.meta.env.VITE_IS_PLATFORM === 'true' ? (
|
||||
{IS_PLATFORM ? (
|
||||
<a
|
||||
href="https://cloudcli.ai/dashboard"
|
||||
className="flex items-center gap-3 active:opacity-70 transition-opacity"
|
||||
|
||||
5
src/constants/config.ts
Normal file
5
src/constants/config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Environment Flag: Is Platform
|
||||
* Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
|
||||
*/
|
||||
export const IS_PLATFORM = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { api } from '../utils/api';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
const AuthContext = createContext({
|
||||
user: null,
|
||||
@@ -31,7 +32,7 @@ export const AuthProvider = ({ children }) => {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.VITE_IS_PLATFORM === 'true') {
|
||||
if (IS_PLATFORM) {
|
||||
setUser({ username: 'platform-user' });
|
||||
setNeedsSetup(false);
|
||||
checkOnboardingStatus();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '../utils/api';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useWebSocketContext } from './WebSocketContext';
|
||||
import { useWebSocket } from './WebSocketContext';
|
||||
|
||||
const TaskMasterContext = createContext({
|
||||
// TaskMaster project state
|
||||
@@ -42,7 +42,7 @@ export const useTaskMaster = () => {
|
||||
|
||||
export const TaskMasterProvider = ({ children }) => {
|
||||
// Get WebSocket messages from shared context to avoid duplicate connections
|
||||
const { messages } = useWebSocketContext();
|
||||
const { latestMessage } = useWebSocket();
|
||||
|
||||
// Authentication context
|
||||
const { user, token, isLoading: authLoading } = useAuth();
|
||||
@@ -238,9 +238,8 @@ export const TaskMasterProvider = ({ children }) => {
|
||||
}
|
||||
}, [currentProject?.name, user, token, refreshTasks]);
|
||||
|
||||
// Handle WebSocket messages for TaskMaster updates
|
||||
// Handle WebSocket latestMessage for TaskMaster updates
|
||||
useEffect(() => {
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
if (!latestMessage) return;
|
||||
|
||||
|
||||
@@ -268,7 +267,7 @@ export const TaskMasterProvider = ({ children }) => {
|
||||
// Ignore non-TaskMaster messages
|
||||
break;
|
||||
}
|
||||
}, [messages, refreshProjects, refreshTasks, refreshMCPStatus, currentProject]);
|
||||
}, [latestMessage, refreshProjects, refreshTasks, refreshMCPStatus, currentProject]);
|
||||
|
||||
// Context value
|
||||
const contextValue = {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { useWebSocket } from '../utils/websocket';
|
||||
|
||||
const WebSocketContext = createContext({
|
||||
ws: null,
|
||||
sendMessage: () => {},
|
||||
messages: [],
|
||||
isConnected: false
|
||||
});
|
||||
|
||||
export const useWebSocketContext = () => {
|
||||
const context = useContext(WebSocketContext);
|
||||
if (!context) {
|
||||
throw new Error('useWebSocketContext must be used within a WebSocketProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const WebSocketProvider = ({ children }) => {
|
||||
const webSocketData = useWebSocket();
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={webSocketData}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebSocketContext;
|
||||
125
src/contexts/WebSocketContext.tsx
Normal file
125
src/contexts/WebSocketContext.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
type WebSocketContextType = {
|
||||
ws: WebSocket | null;
|
||||
sendMessage: (message: any) => void;
|
||||
latestMessage: any | null;
|
||||
isConnected: boolean;
|
||||
};
|
||||
|
||||
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
||||
|
||||
export const useWebSocket = () => {
|
||||
const context = useContext(WebSocketContext);
|
||||
if (!context) {
|
||||
throw new Error('useWebSocket must be used within a WebSocketProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const buildWebSocketUrl = (token: string | null) => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
if (IS_PLATFORM) return `${protocol}//${window.location.host}/ws`; // Platform mode: Use same domain as the page (goes through proxy)
|
||||
if (!token) return null;
|
||||
return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; // OSS mode: Use same host:port that served the page
|
||||
};
|
||||
|
||||
const useWebSocketProviderState = (): WebSocketContextType => {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const unmountedRef = useRef(false); // Track if component is unmounted
|
||||
const [latestMessage, setLatestMessage] = useState<any>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { token } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
unmountedRef.current = true;
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [token]); // everytime token changes, we reconnect
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (unmountedRef.current) return; // Prevent connection if unmounted
|
||||
try {
|
||||
// Construct WebSocket URL
|
||||
const wsUrl = buildWebSocketUrl(token);
|
||||
|
||||
if (!wsUrl) return console.warn('No authentication token found for WebSocket connection');
|
||||
|
||||
const websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
setIsConnected(true);
|
||||
wsRef.current = websocket;
|
||||
};
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
setLatestMessage(data);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
setIsConnected(false);
|
||||
wsRef.current = null;
|
||||
|
||||
// Attempt to reconnect after 3 seconds
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (unmountedRef.current) return; // Prevent reconnection if unmounted
|
||||
connect();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating WebSocket connection:', error);
|
||||
}
|
||||
}, [token]); // everytime token changes, we reconnect
|
||||
|
||||
const sendMessage = useCallback((message: any) => {
|
||||
const socket = wsRef.current;
|
||||
if (socket && isConnected) {
|
||||
socket.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn('WebSocket not connected');
|
||||
}
|
||||
}, [isConnected]);
|
||||
|
||||
const value: WebSocketContextType = useMemo(() =>
|
||||
({
|
||||
ws: wsRef.current,
|
||||
sendMessage,
|
||||
latestMessage,
|
||||
isConnected
|
||||
}), [sendMessage, latestMessage, isConnected]);
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const WebSocketProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const webSocketData = useWebSocketProviderState();
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={webSocketData}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebSocketContext;
|
||||
@@ -21,6 +21,13 @@ import enSidebar from './locales/en/sidebar.json';
|
||||
import enChat from './locales/en/chat.json';
|
||||
import enCodeEditor from './locales/en/codeEditor.json';
|
||||
|
||||
import koCommon from './locales/ko/common.json';
|
||||
import koSettings from './locales/ko/settings.json';
|
||||
import koAuth from './locales/ko/auth.json';
|
||||
import koSidebar from './locales/ko/sidebar.json';
|
||||
import koChat from './locales/ko/chat.json';
|
||||
import koCodeEditor from './locales/ko/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';
|
||||
@@ -60,6 +67,14 @@ i18n
|
||||
chat: enChat,
|
||||
codeEditor: enCodeEditor,
|
||||
},
|
||||
ko: {
|
||||
common: koCommon,
|
||||
settings: koSettings,
|
||||
auth: koAuth,
|
||||
sidebar: koSidebar,
|
||||
chat: koChat,
|
||||
codeEditor: koCodeEditor,
|
||||
},
|
||||
'zh-CN': {
|
||||
common: zhCommon,
|
||||
settings: zhSettings,
|
||||
|
||||
@@ -14,6 +14,11 @@ export const languages = [
|
||||
label: 'English',
|
||||
nativeName: 'English',
|
||||
},
|
||||
{
|
||||
value: 'ko',
|
||||
label: 'Korean',
|
||||
nativeName: '한국어',
|
||||
},
|
||||
{
|
||||
value: 'zh-CN',
|
||||
label: 'Simplified Chinese',
|
||||
|
||||
@@ -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,25 @@
|
||||
"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": {
|
||||
|
||||
37
src/i18n/locales/ko/auth.json
Normal file
37
src/i18n/locales/ko/auth.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "다시 오신 것을 환영합니다",
|
||||
"description": "Claude Code UI 계정에 로그인하세요",
|
||||
"username": "사용자명",
|
||||
"password": "비밀번호",
|
||||
"submit": "로그인",
|
||||
"loading": "로그인 중...",
|
||||
"errors": {
|
||||
"invalidCredentials": "사용자명 또는 비밀번호가 잘못되었습니다",
|
||||
"requiredFields": "모든 항목을 입력해주세요",
|
||||
"networkError": "네트워크 오류. 다시 시도해주세요."
|
||||
},
|
||||
"placeholders": {
|
||||
"username": "사용자명을 입력하세요",
|
||||
"password": "비밀번호를 입력하세요"
|
||||
}
|
||||
},
|
||||
"register": {
|
||||
"title": "계정 생성",
|
||||
"username": "사용자명",
|
||||
"password": "비밀번호",
|
||||
"confirmPassword": "비밀번호 확인",
|
||||
"submit": "계정 생성",
|
||||
"loading": "계정 생성 중...",
|
||||
"errors": {
|
||||
"passwordMismatch": "비밀번호가 일치하지 않습니다",
|
||||
"usernameTaken": "이미 사용 중인 사용자명입니다",
|
||||
"weakPassword": "비밀번호가 너무 약합니다"
|
||||
}
|
||||
},
|
||||
"logout": {
|
||||
"title": "로그아웃",
|
||||
"confirm": "정말 로그아웃하시겠습니까?",
|
||||
"button": "로그아웃"
|
||||
}
|
||||
}
|
||||
205
src/i18n/locales/ko/chat.json
Normal file
205
src/i18n/locales/ko/chat.json
Normal file
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"codeBlock": {
|
||||
"copy": "복사",
|
||||
"copied": "복사됨",
|
||||
"copyCode": "코드 복사"
|
||||
},
|
||||
"messageTypes": {
|
||||
"user": "U",
|
||||
"error": "오류",
|
||||
"tool": "도구",
|
||||
"claude": "Claude",
|
||||
"cursor": "Cursor",
|
||||
"codex": "Codex"
|
||||
},
|
||||
"tools": {
|
||||
"settings": "도구 설정",
|
||||
"error": "도구 오류",
|
||||
"result": "도구 결과",
|
||||
"viewParams": "입력 파라미터 보기",
|
||||
"viewRawParams": "Raw 파라미터 보기",
|
||||
"viewDiff": "편집 Diff 보기:",
|
||||
"creatingFile": "새 파일 생성:",
|
||||
"updatingTodo": "Todo 리스트 업데이트",
|
||||
"read": "읽기",
|
||||
"readFile": "파일 읽기",
|
||||
"updateTodo": "Todo 리스트 업데이트",
|
||||
"readTodo": "Todo 리스트 읽기",
|
||||
"searchResults": "결과"
|
||||
},
|
||||
"search": {
|
||||
"found": "{{count}}개의 {{type}} 발견",
|
||||
"file": "파일",
|
||||
"files": "파일",
|
||||
"pattern": "패턴:",
|
||||
"in": "위치:"
|
||||
},
|
||||
"fileOperations": {
|
||||
"updated": "파일이 업데이트되었습니다",
|
||||
"created": "파일이 생성되었습니다",
|
||||
"written": "파일이 작성되었습니다",
|
||||
"diff": "Diff",
|
||||
"newFile": "새 파일",
|
||||
"viewContent": "파일 내용 보기",
|
||||
"viewFullOutput": "전체 출력 보기 ({{count}}자)",
|
||||
"contentDisplayed": "파일 내용이 위의 Diff 보기에 표시됩니다"
|
||||
},
|
||||
"interactive": {
|
||||
"title": "대화형 프롬프트",
|
||||
"waiting": "CLI에서 응답을 기다리는 중",
|
||||
"instruction": "Claude가 실행 중인 터미널에서 옵션을 선택해주세요.",
|
||||
"selectedOption": "✓ Claude가 옵션 {{number}}을(를) 선택했습니다",
|
||||
"instructionDetail": "CLI에서 화살표 키 또는 숫자를 입력하여 이 옵션을 대화형으로 선택합니다."
|
||||
},
|
||||
"thinking": {
|
||||
"title": "생각 중...",
|
||||
"emoji": "💭 생각 중..."
|
||||
},
|
||||
"json": {
|
||||
"response": "JSON 응답"
|
||||
},
|
||||
"permissions": {
|
||||
"grant": "{{tool}}에 대한 권한 부여",
|
||||
"added": "권한이 추가되었습니다",
|
||||
"addTo": "{{entry}}을(를) 허용된 도구에 추가합니다.",
|
||||
"retry": "권한이 저장되었습니다. 도구를 사용하려면 요청을 재시도하세요.",
|
||||
"error": "권한을 업데이트할 수 없습니다. 다시 시도해주세요.",
|
||||
"openSettings": "설정 열기"
|
||||
},
|
||||
"todo": {
|
||||
"updated": "Todo 리스트가 업데이트되었습니다",
|
||||
"current": "현재 Todo 리스트"
|
||||
},
|
||||
"plan": {
|
||||
"viewPlan": "📋 구현 계획 보기",
|
||||
"title": "구현 계획"
|
||||
},
|
||||
"usageLimit": {
|
||||
"resetAt": "Claude 사용량 한도에 도달했습니다. 한도는 **{{time}} {{timezone}}** - {{date}}에 초기화됩니다"
|
||||
},
|
||||
"codex": {
|
||||
"permissionMode": "권한 모드",
|
||||
"modes": {
|
||||
"default": "기본 모드",
|
||||
"acceptEdits": "편집 허용",
|
||||
"bypassPermissions": "권한 우회",
|
||||
"plan": "Plan 모드"
|
||||
},
|
||||
"descriptions": {
|
||||
"default": "신뢰할 수 있는 명령어(ls, cat, grep, git status 등)만 자동 실행됩니다. 다른 명령어는 건너뜁니다. 워크스페이스에 쓰기 가능.",
|
||||
"acceptEdits": "워크스페이스 내에서 모든 명령어가 자동 실행됩니다. 샌드박스 내 완전 자동 모드.",
|
||||
"bypassPermissions": "제한 없는 전체 시스템 접근. 모든 명령어가 전체 디스크 및 네트워크 접근 권한으로 자동 실행됩니다. 주의해서 사용하세요.",
|
||||
"plan": "계획 모드 - 명령어가 실행되지 않습니다"
|
||||
},
|
||||
"technicalDetails": "기술 상세"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "/를 입력하여 명령어, @를 입력하여 파일, 또는 {{provider}}에게 무엇이든 물어보세요...",
|
||||
"placeholderDefault": "메시지를 입력하세요...",
|
||||
"disabled": "입력 비활성화",
|
||||
"attachFiles": "파일 첨부",
|
||||
"attachImages": "이미지 첨부",
|
||||
"send": "전송",
|
||||
"stop": "중지",
|
||||
"hintText": {
|
||||
"ctrlEnter": "Ctrl+Enter로 전송 • Shift+Enter로 줄바꿈 • Tab으로 모드 변경 • /로 슬래시 명령어",
|
||||
"enter": "Enter로 전송 • Shift+Enter로 줄바꿈 • Tab으로 모드 변경 • /로 슬래시 명령어"
|
||||
},
|
||||
"clickToChangeMode": "클릭하여 권한 모드 변경 (또는 입력창에서 Tab)",
|
||||
"showAllCommands": "모든 명령어 보기"
|
||||
},
|
||||
"thinkingMode": {
|
||||
"selector": {
|
||||
"title": "Thinking 모드",
|
||||
"description": "확장된 thinking은 Claude에게 대안을 평가할 시간을 더 줍니다",
|
||||
"active": "활성",
|
||||
"tip": "높은 thinking 모드는 시간이 더 걸리지만 더 철저한 분석을 제공합니다"
|
||||
},
|
||||
"modes": {
|
||||
"none": {
|
||||
"name": "Standard",
|
||||
"description": "일반 Claude 응답",
|
||||
"prefix": ""
|
||||
},
|
||||
"think": {
|
||||
"name": "Think",
|
||||
"description": "기본 확장 thinking",
|
||||
"prefix": "think"
|
||||
},
|
||||
"thinkHard": {
|
||||
"name": "Think Hard",
|
||||
"description": "더 철저한 평가",
|
||||
"prefix": "think hard"
|
||||
},
|
||||
"thinkHarder": {
|
||||
"name": "Think Harder",
|
||||
"description": "대안을 포함한 심층 분석",
|
||||
"prefix": "think harder"
|
||||
},
|
||||
"ultrathink": {
|
||||
"name": "Ultrathink",
|
||||
"description": "최대 thinking 예산",
|
||||
"prefix": "ultrathink"
|
||||
}
|
||||
},
|
||||
"buttonTitle": "Thinking 모드: {{mode}}"
|
||||
},
|
||||
"providerSelection": {
|
||||
"title": "AI 어시스턴트 선택",
|
||||
"description": "새 대화를 시작할 프로바이더를 선택하세요",
|
||||
"selectModel": "모델 선택",
|
||||
"providerInfo": {
|
||||
"anthropic": "by Anthropic",
|
||||
"openai": "by OpenAI",
|
||||
"cursorEditor": "AI 코드 에디터"
|
||||
},
|
||||
"readyPrompt": {
|
||||
"claude": "{{model}}로 Claude를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
||||
"cursor": "{{model}}로 Cursor를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
||||
"codex": "{{model}}로 Codex를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
|
||||
"default": "시작하려면 위에서 프로바이더를 선택하세요"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
"continue": {
|
||||
"title": "대화 계속하기",
|
||||
"description": "코드에 대해 질문하거나, 변경을 요청하거나, 개발 작업에 도움을 받으세요"
|
||||
},
|
||||
"loading": {
|
||||
"olderMessages": "이전 메시지 로딩 중...",
|
||||
"sessionMessages": "세션 메시지 로딩 중..."
|
||||
},
|
||||
"messages": {
|
||||
"showingOf": "{{total}}개 중 {{shown}}개 표시",
|
||||
"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/ko/codeEditor.json
Normal file
30
src/i18n/locales/ko/codeEditor.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"toolbar": {
|
||||
"changes": "변경사항",
|
||||
"previousChange": "이전 변경",
|
||||
"nextChange": "다음 변경",
|
||||
"hideDiff": "Diff 하이라이트 숨기기",
|
||||
"showDiff": "Diff 하이라이트 표시",
|
||||
"settings": "에디터 설정",
|
||||
"collapse": "에디터 접기",
|
||||
"expand": "에디터 전체 너비로 펼치기"
|
||||
},
|
||||
"loading": "{{fileName}} 로딩 중...",
|
||||
"header": {
|
||||
"showingChanges": "변경사항 표시"
|
||||
},
|
||||
"actions": {
|
||||
"download": "파일 다운로드",
|
||||
"save": "저장",
|
||||
"saving": "저장 중...",
|
||||
"saved": "저장됨!",
|
||||
"exitFullscreen": "전체화면 종료",
|
||||
"fullscreen": "전체화면",
|
||||
"close": "닫기"
|
||||
},
|
||||
"footer": {
|
||||
"lines": "줄:",
|
||||
"characters": "문자:",
|
||||
"shortcuts": "Ctrl+S로 저장 • Esc로 닫기"
|
||||
}
|
||||
}
|
||||
222
src/i18n/locales/ko/common.json
Normal file
222
src/i18n/locales/ko/common.json
Normal file
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"buttons": {
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"delete": "삭제",
|
||||
"create": "생성",
|
||||
"edit": "편집",
|
||||
"close": "닫기",
|
||||
"confirm": "확인",
|
||||
"submit": "제출",
|
||||
"retry": "재시도",
|
||||
"refresh": "새로고침",
|
||||
"search": "검색",
|
||||
"clear": "지우기",
|
||||
"copy": "복사",
|
||||
"download": "다운로드",
|
||||
"upload": "업로드",
|
||||
"browse": "찾아보기"
|
||||
},
|
||||
"tabs": {
|
||||
"chat": "채팅",
|
||||
"shell": "Shell",
|
||||
"files": "파일",
|
||||
"git": "소스 관리",
|
||||
"tasks": "작업"
|
||||
},
|
||||
"status": {
|
||||
"loading": "로딩 중...",
|
||||
"success": "성공",
|
||||
"error": "오류",
|
||||
"failed": "실패",
|
||||
"pending": "대기 중",
|
||||
"completed": "완료",
|
||||
"inProgress": "진행 중"
|
||||
},
|
||||
"messages": {
|
||||
"savedSuccessfully": "저장되었습니다",
|
||||
"deletedSuccessfully": "삭제되었습니다",
|
||||
"updatedSuccessfully": "업데이트되었습니다",
|
||||
"operationFailed": "작업 실패",
|
||||
"networkError": "네트워크 오류. 연결을 확인해주세요.",
|
||||
"unauthorized": "인증되지 않았습니다. 로그인해주세요.",
|
||||
"notFound": "찾을 수 없음",
|
||||
"invalidInput": "잘못된 입력",
|
||||
"requiredField": "필수 항목입니다",
|
||||
"unknownError": "알 수 없는 오류가 발생했습니다"
|
||||
},
|
||||
"navigation": {
|
||||
"settings": "설정",
|
||||
"home": "홈",
|
||||
"back": "뒤로",
|
||||
"next": "다음",
|
||||
"previous": "이전",
|
||||
"logout": "로그아웃"
|
||||
},
|
||||
"common": {
|
||||
"language": "언어",
|
||||
"theme": "테마",
|
||||
"darkMode": "다크 모드",
|
||||
"lightMode": "라이트 모드",
|
||||
"name": "이름",
|
||||
"description": "설명",
|
||||
"enabled": "활성화",
|
||||
"disabled": "비활성화",
|
||||
"optional": "선택사항",
|
||||
"version": "버전",
|
||||
"select": "선택",
|
||||
"selectAll": "전체 선택",
|
||||
"deselectAll": "전체 해제"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "방금 전",
|
||||
"minutesAgo": "{{count}}분 전",
|
||||
"hoursAgo": "{{count}}시간 전",
|
||||
"daysAgo": "{{count}}일 전",
|
||||
"yesterday": "어제"
|
||||
},
|
||||
"fileOperations": {
|
||||
"newFile": "새 파일",
|
||||
"newFolder": "새 폴더",
|
||||
"rename": "이름 변경",
|
||||
"move": "이동",
|
||||
"copyPath": "경로 복사",
|
||||
"openInEditor": "에디터에서 열기"
|
||||
},
|
||||
"mainContent": {
|
||||
"loading": "Claude Code UI 로딩 중",
|
||||
"settingUpWorkspace": "워크스페이스 설정 중...",
|
||||
"chooseProject": "프로젝트 선택",
|
||||
"selectProjectDescription": "사이드바에서 프로젝트를 선택하여 Claude와 코딩을 시작하세요. 각 프로젝트에는 채팅 세션과 파일 히스토리가 포함됩니다.",
|
||||
"tip": "팁",
|
||||
"createProjectMobile": "위의 메뉴 버튼을 눌러 프로젝트에 접근하세요",
|
||||
"createProjectDesktop": "사이드바의 폴더 아이콘을 클릭하여 새 프로젝트를 생성하세요",
|
||||
"newSession": "새 세션",
|
||||
"untitledSession": "제목 없는 세션",
|
||||
"projectFiles": "프로젝트 파일"
|
||||
},
|
||||
"fileTree": {
|
||||
"loading": "파일 로딩 중...",
|
||||
"files": "파일",
|
||||
"simpleView": "간단히 보기",
|
||||
"compactView": "컴팩트 보기",
|
||||
"detailedView": "상세히 보기",
|
||||
"searchPlaceholder": "파일 및 폴더 검색...",
|
||||
"clearSearch": "검색 지우기",
|
||||
"name": "이름",
|
||||
"size": "크기",
|
||||
"modified": "수정일",
|
||||
"permissions": "권한",
|
||||
"noFilesFound": "파일을 찾을 수 없음",
|
||||
"checkProjectPath": "프로젝트 경로가 접근 가능한지 확인하세요",
|
||||
"noMatchesFound": "일치하는 항목 없음",
|
||||
"tryDifferentSearch": "다른 검색어를 시도하거나 검색을 지우세요",
|
||||
"justNow": "방금 전",
|
||||
"minAgo": "{{count}}분 전",
|
||||
"hoursAgo": "{{count}}시간 전",
|
||||
"daysAgo": "{{count}}일 전"
|
||||
},
|
||||
"projectWizard": {
|
||||
"title": "새 프로젝트 생성",
|
||||
"steps": {
|
||||
"type": "유형",
|
||||
"configure": "설정",
|
||||
"confirm": "확인"
|
||||
},
|
||||
"step1": {
|
||||
"question": "이미 워크스페이스가 있으신가요, 아니면 새로 생성하시겠습니까?",
|
||||
"existing": {
|
||||
"title": "기존 워크스페이스",
|
||||
"description": "서버에 이미 워크스페이스가 있고 프로젝트 목록에 추가만 하면 됩니다"
|
||||
},
|
||||
"new": {
|
||||
"title": "새 워크스페이스",
|
||||
"description": "새 워크스페이스를 생성하고, 선택적으로 GitHub 저장소에서 clone합니다"
|
||||
}
|
||||
},
|
||||
"step2": {
|
||||
"existingPath": "워크스페이스 경로",
|
||||
"newPath": "워크스페이스 경로",
|
||||
"existingPlaceholder": "/path/to/existing/workspace",
|
||||
"newPlaceholder": "/path/to/new/workspace",
|
||||
"existingHelp": "기존 워크스페이스 디렉토리의 전체 경로",
|
||||
"newHelp": "워크스페이스 디렉토리의 전체 경로",
|
||||
"githubUrl": "GitHub URL (선택사항)",
|
||||
"githubPlaceholder": "https://github.com/username/repository",
|
||||
"githubHelp": "선택사항: 저장소를 clone하려면 GitHub URL을 입력하세요",
|
||||
"githubAuth": "GitHub 인증 (선택사항)",
|
||||
"githubAuthHelp": "비공개 저장소에만 필요합니다. 공개 저장소는 인증 없이 clone할 수 있습니다.",
|
||||
"loadingTokens": "저장된 토큰 로딩 중...",
|
||||
"storedToken": "저장된 토큰",
|
||||
"newToken": "새 토큰",
|
||||
"nonePublic": "없음 (공개)",
|
||||
"selectToken": "토큰 선택",
|
||||
"selectTokenPlaceholder": "-- 토큰 선택 --",
|
||||
"tokenPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"tokenHelp": "이 토큰은 이 작업에만 사용됩니다",
|
||||
"publicRepoInfo": "공개 저장소는 인증이 필요하지 않습니다. 공개 저장소를 clone하는 경우 토큰을 생략할 수 있습니다.",
|
||||
"noTokensHelp": "저장된 토큰이 없습니다. 설정 → API Keys에서 토큰을 추가하면 재사용이 편리합니다.",
|
||||
"optionalTokenPublic": "GitHub 토큰 (공개 저장소는 선택사항)",
|
||||
"tokenPublicPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (공개 저장소는 비워두세요)"
|
||||
},
|
||||
"step3": {
|
||||
"reviewConfig": "설정 검토",
|
||||
"workspaceType": "워크스페이스 유형:",
|
||||
"existingWorkspace": "기존 워크스페이스",
|
||||
"newWorkspace": "새 워크스페이스",
|
||||
"path": "경로:",
|
||||
"cloneFrom": "Clone 소스:",
|
||||
"authentication": "인증:",
|
||||
"usingStoredToken": "저장된 토큰 사용:",
|
||||
"usingProvidedToken": "제공된 토큰 사용",
|
||||
"noAuthentication": "인증 없음",
|
||||
"sshKey": "SSH 키",
|
||||
"existingInfo": "워크스페이스가 프로젝트 목록에 추가되며 Claude/Cursor 세션에서 사용할 수 있습니다.",
|
||||
"newWithClone": "이 폴더에 저장소가 clone됩니다.",
|
||||
"newEmpty": "워크스페이스가 프로젝트 목록에 추가되며 Claude/Cursor 세션에서 사용할 수 있습니다.",
|
||||
"cloningRepository": "저장소 clone 중..."
|
||||
},
|
||||
"buttons": {
|
||||
"cancel": "취소",
|
||||
"back": "뒤로",
|
||||
"next": "다음",
|
||||
"createProject": "프로젝트 생성",
|
||||
"creating": "생성 중...",
|
||||
"cloning": "Clone 중..."
|
||||
},
|
||||
"errors": {
|
||||
"selectType": "기존 워크스페이스를 사용할지 새로 생성할지 선택해주세요",
|
||||
"providePath": "워크스페이스 경로를 입력해주세요",
|
||||
"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": "사이드바 닫기"
|
||||
}
|
||||
}
|
||||
}
|
||||
418
src/i18n/locales/ko/settings.json
Normal file
418
src/i18n/locales/ko/settings.json
Normal file
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"title": "설정",
|
||||
"tabs": {
|
||||
"account": "계정",
|
||||
"permissions": "권한",
|
||||
"mcpServers": "MCP 서버",
|
||||
"appearance": "외관"
|
||||
},
|
||||
"account": {
|
||||
"title": "계정",
|
||||
"language": "언어",
|
||||
"languageLabel": "표시 언어",
|
||||
"languageDescription": "인터페이스에 사용할 언어를 선택하세요",
|
||||
"username": "사용자명",
|
||||
"email": "이메일",
|
||||
"profile": "프로필",
|
||||
"changePassword": "비밀번호 변경"
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP 서버",
|
||||
"addServer": "서버 추가",
|
||||
"editServer": "서버 편집",
|
||||
"deleteServer": "서버 삭제",
|
||||
"serverName": "서버 이름",
|
||||
"serverType": "서버 유형",
|
||||
"config": "설정",
|
||||
"testConnection": "연결 테스트",
|
||||
"status": "상태",
|
||||
"connected": "연결됨",
|
||||
"disconnected": "연결 끊김",
|
||||
"scope": {
|
||||
"label": "범위",
|
||||
"user": "사용자",
|
||||
"project": "프로젝트"
|
||||
}
|
||||
},
|
||||
"appearance": {
|
||||
"title": "외관",
|
||||
"theme": "테마",
|
||||
"codeEditor": "코드 에디터",
|
||||
"editorTheme": "에디터 테마",
|
||||
"wordWrap": "자동 줄바꿈",
|
||||
"showMinimap": "미니맵 표시",
|
||||
"lineNumbers": "줄 번호",
|
||||
"fontSize": "글꼴 크기"
|
||||
},
|
||||
"actions": {
|
||||
"saveChanges": "변경사항 저장",
|
||||
"resetToDefaults": "기본값으로 초기화",
|
||||
"cancelChanges": "변경 취소"
|
||||
},
|
||||
"quickSettings": {
|
||||
"title": "빠른 설정",
|
||||
"sections": {
|
||||
"appearance": "외관",
|
||||
"toolDisplay": "도구 표시",
|
||||
"viewOptions": "보기 옵션",
|
||||
"inputSettings": "입력 설정",
|
||||
"whisperDictation": "Whisper 음성 인식"
|
||||
},
|
||||
"darkMode": "다크 모드",
|
||||
"autoExpandTools": "도구 자동 펼치기",
|
||||
"showRawParameters": "Raw 파라미터 표시",
|
||||
"showThinking": "생각 과정 표시",
|
||||
"autoScrollToBottom": "자동 스크롤",
|
||||
"sendByCtrlEnter": "Ctrl+Enter로 전송",
|
||||
"sendByCtrlEnterDescription": "활성화하면 Enter 대신 Ctrl+Enter로 메시지를 전송합니다. IME 사용자가 실수로 전송하는 것을 방지하는 데 유용합니다.",
|
||||
"dragHandle": {
|
||||
"dragging": "드래그 핸들",
|
||||
"closePanel": "설정 패널 닫기",
|
||||
"openPanel": "설정 패널 열기",
|
||||
"draggingStatus": "드래그 중...",
|
||||
"toggleAndMove": "클릭하여 토글, 드래그하여 이동"
|
||||
},
|
||||
"whisper": {
|
||||
"modes": {
|
||||
"default": "기본 모드",
|
||||
"defaultDescription": "음성을 그대로 텍스트로 변환",
|
||||
"prompt": "프롬프트 향상",
|
||||
"promptDescription": "거친 아이디어를 명확하고 상세한 AI 프롬프트로 변환",
|
||||
"vibe": "Vibe 모드",
|
||||
"vibeDescription": "아이디어를 상세한 에이전트 지침 형식으로 변환"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mainTabs": {
|
||||
"agents": "에이전트",
|
||||
"appearance": "외관",
|
||||
"git": "Git",
|
||||
"apiTokens": "API & 토큰",
|
||||
"tasks": "작업"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
"label": "다크 모드",
|
||||
"description": "라이트/다크 테마 전환"
|
||||
},
|
||||
"projectSorting": {
|
||||
"label": "프로젝트 정렬",
|
||||
"description": "사이드바에서 프로젝트 정렬 방식",
|
||||
"alphabetical": "알파벳순",
|
||||
"recentActivity": "최근 활동순"
|
||||
},
|
||||
"codeEditor": {
|
||||
"title": "코드 에디터",
|
||||
"theme": {
|
||||
"label": "에디터 테마",
|
||||
"description": "코드 에디터의 기본 테마"
|
||||
},
|
||||
"wordWrap": {
|
||||
"label": "자동 줄바꿈",
|
||||
"description": "에디터에서 기본적으로 자동 줄바꿈 활성화"
|
||||
},
|
||||
"showMinimap": {
|
||||
"label": "미니맵 표시",
|
||||
"description": "Diff 보기에서 쉬운 탐색을 위한 미니맵 표시"
|
||||
},
|
||||
"lineNumbers": {
|
||||
"label": "줄 번호 표시",
|
||||
"description": "에디터에 줄 번호 표시"
|
||||
},
|
||||
"fontSize": {
|
||||
"label": "글꼴 크기",
|
||||
"description": "에디터 글꼴 크기 (픽셀)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcpForm": {
|
||||
"title": {
|
||||
"add": "MCP 서버 추가",
|
||||
"edit": "MCP 서버 편집"
|
||||
},
|
||||
"importMode": {
|
||||
"form": "폼 입력",
|
||||
"json": "JSON 가져오기"
|
||||
},
|
||||
"scope": {
|
||||
"label": "범위",
|
||||
"userGlobal": "사용자 (전역)",
|
||||
"projectLocal": "프로젝트 (로컬)",
|
||||
"userDescription": "사용자 범위: 모든 프로젝트에서 사용 가능",
|
||||
"projectDescription": "로컬 범위: 선택한 프로젝트에서만 사용 가능",
|
||||
"cannotChange": "기존 서버를 편집할 때는 범위를 변경할 수 없습니다"
|
||||
},
|
||||
"fields": {
|
||||
"serverName": "서버 이름",
|
||||
"transportType": "전송 유형",
|
||||
"command": "명령어",
|
||||
"arguments": "인수 (한 줄에 하나씩)",
|
||||
"jsonConfig": "JSON 설정",
|
||||
"url": "URL",
|
||||
"envVars": "환경 변수 (KEY=value, 한 줄에 하나씩)",
|
||||
"headers": "헤더 (KEY=value, 한 줄에 하나씩)",
|
||||
"selectProject": "프로젝트 선택..."
|
||||
},
|
||||
"placeholders": {
|
||||
"serverName": "my-server"
|
||||
},
|
||||
"validation": {
|
||||
"missingType": "필수 항목 누락: type",
|
||||
"stdioRequiresCommand": "stdio 유형은 command 필드가 필요합니다",
|
||||
"httpRequiresUrl": "{{type}} 유형은 url 필드가 필요합니다",
|
||||
"invalidJson": "잘못된 JSON 형식",
|
||||
"jsonHelp": "MCP 서버 설정을 JSON 형식으로 붙여넣으세요. 예시:",
|
||||
"jsonExampleStdio": "• stdio: {\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"@upstash/context7-mcp\"]}",
|
||||
"jsonExampleHttp": "• http/sse: {\"type\":\"http\",\"url\":\"https://api.example.com/mcp\"}"
|
||||
},
|
||||
"configDetails": "설정 상세 ({{configFile}}에서)",
|
||||
"projectPath": "경로: {{path}}",
|
||||
"actions": {
|
||||
"cancel": "취소",
|
||||
"saving": "저장 중...",
|
||||
"addServer": "서버 추가",
|
||||
"updateServer": "서버 업데이트"
|
||||
}
|
||||
},
|
||||
"saveStatus": {
|
||||
"success": "설정이 저장되었습니다!",
|
||||
"error": "설정 저장 실패",
|
||||
"saving": "저장 중..."
|
||||
},
|
||||
"footerActions": {
|
||||
"save": "설정 저장",
|
||||
"cancel": "취소"
|
||||
},
|
||||
"git": {
|
||||
"title": "Git 설정",
|
||||
"description": "커밋을 위한 Git 정보를 설정합니다. 이 설정은 git config --global로 전역 적용됩니다",
|
||||
"name": {
|
||||
"label": "Git 이름",
|
||||
"help": "Git 커밋에 사용될 이름"
|
||||
},
|
||||
"email": {
|
||||
"label": "Git 이메일",
|
||||
"help": "Git 커밋에 사용될 이메일"
|
||||
},
|
||||
"actions": {
|
||||
"save": "설정 저장",
|
||||
"saving": "저장 중..."
|
||||
},
|
||||
"status": {
|
||||
"success": "저장 완료"
|
||||
}
|
||||
},
|
||||
"apiKeys": {
|
||||
"title": "API 키",
|
||||
"description": "다른 애플리케이션에서 외부 API에 접근하기 위한 API 키를 생성합니다.",
|
||||
"newKey": {
|
||||
"alertTitle": "⚠️ API 키를 저장하세요",
|
||||
"alertMessage": "이 키는 지금만 볼 수 있습니다. 안전하게 보관하세요.",
|
||||
"iveSavedIt": "저장했습니다"
|
||||
},
|
||||
"form": {
|
||||
"placeholder": "API 키 이름 (예: Production Server)",
|
||||
"createButton": "생성",
|
||||
"cancelButton": "취소"
|
||||
},
|
||||
"newButton": "새 API 키",
|
||||
"empty": "생성된 API 키가 없습니다.",
|
||||
"list": {
|
||||
"created": "생성일:",
|
||||
"lastUsed": "마지막 사용:"
|
||||
},
|
||||
"confirmDelete": "이 API 키를 삭제하시겠습니까?",
|
||||
"status": {
|
||||
"active": "활성",
|
||||
"inactive": "비활성"
|
||||
},
|
||||
"github": {
|
||||
"title": "GitHub 토큰",
|
||||
"description": "외부 API를 통해 비공개 저장소를 clone하기 위한 GitHub Personal Access Token을 추가합니다.",
|
||||
"descriptionAlt": "비공개 저장소를 clone하기 위한 GitHub Personal Access Token을 추가합니다. 저장하지 않고 API 요청에 직접 토큰을 전달할 수도 있습니다.",
|
||||
"addButton": "토큰 추가",
|
||||
"form": {
|
||||
"namePlaceholder": "토큰 이름 (예: Personal Repos)",
|
||||
"tokenPlaceholder": "GitHub Personal Access Token (ghp_...)",
|
||||
"descriptionPlaceholder": "설명 (선택사항)",
|
||||
"addButton": "토큰 추가",
|
||||
"cancelButton": "취소",
|
||||
"howToCreate": "GitHub Personal Access Token 생성 방법 →"
|
||||
},
|
||||
"empty": "추가된 GitHub 토큰이 없습니다.",
|
||||
"added": "추가일:",
|
||||
"confirmDelete": "이 GitHub 토큰을 삭제하시겠습니까?"
|
||||
},
|
||||
"apiDocsLink": "API 문서",
|
||||
"documentation": {
|
||||
"title": "외부 API 문서",
|
||||
"description": "외부 API를 사용하여 애플리케이션에서 Claude/Cursor 세션을 트리거하는 방법을 알아보세요.",
|
||||
"viewLink": "API 문서 보기 →"
|
||||
},
|
||||
"loading": "로딩 중...",
|
||||
"version": {
|
||||
"updateAvailable": "업데이트 가능: v{{version}}"
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
"checking": "TaskMaster 설치 확인 중...",
|
||||
"notInstalled": {
|
||||
"title": "TaskMaster AI CLI가 설치되지 않았습니다",
|
||||
"description": "작업 관리 기능을 사용하려면 TaskMaster CLI가 필요합니다. 시작하려면 설치하세요:",
|
||||
"installCommand": "npm install -g task-master-ai",
|
||||
"viewOnGitHub": "GitHub에서 보기",
|
||||
"afterInstallation": "설치 후:",
|
||||
"steps": {
|
||||
"restart": "이 애플리케이션을 재시작하세요",
|
||||
"autoAvailable": "TaskMaster 기능이 자동으로 활성화됩니다",
|
||||
"initCommand": "프로젝트 디렉토리에서 task-master init을 사용하세요"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"enableLabel": "TaskMaster 통합 활성화",
|
||||
"enableDescription": "인터페이스 전체에 TaskMaster 작업, 배너 및 사이드바 표시"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"authStatus": {
|
||||
"checking": "확인 중...",
|
||||
"connected": "연결됨",
|
||||
"notConnected": "연결되지 않음",
|
||||
"disconnected": "연결 끊김",
|
||||
"checkingAuth": "인증 상태 확인 중...",
|
||||
"loggedInAs": "{{email}}(으)로 로그인됨",
|
||||
"authenticatedUser": "인증된 사용자"
|
||||
},
|
||||
"account": {
|
||||
"claude": {
|
||||
"description": "Anthropic Claude AI 어시스턴트"
|
||||
},
|
||||
"cursor": {
|
||||
"description": "Cursor AI 기반 코드 에디터"
|
||||
},
|
||||
"codex": {
|
||||
"description": "OpenAI Codex AI 어시스턴트"
|
||||
}
|
||||
},
|
||||
"connectionStatus": "연결 상태",
|
||||
"login": {
|
||||
"title": "로그인",
|
||||
"reAuthenticate": "재인증",
|
||||
"description": "AI 기능을 활성화하려면 {{agent}} 계정에 로그인하세요",
|
||||
"reAuthDescription": "다른 계정으로 로그인하거나 자격 증명을 새로고침하세요",
|
||||
"button": "로그인",
|
||||
"reLoginButton": "재로그인"
|
||||
},
|
||||
"error": "오류: {{error}}"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "권한 설정",
|
||||
"skipPermissions": {
|
||||
"label": "권한 확인 건너뛰기 (주의해서 사용)",
|
||||
"claudeDescription": "--dangerously-skip-permissions 플래그와 동일",
|
||||
"cursorDescription": "Cursor CLI의 -f 플래그와 동일"
|
||||
},
|
||||
"allowedTools": {
|
||||
"title": "허용된 도구",
|
||||
"description": "권한 확인 없이 자동으로 허용되는 도구",
|
||||
"placeholder": "예: \"Bash(git log:*)\" 또는 \"Write\"",
|
||||
"quickAdd": "자주 쓰는 도구 빠른 추가:",
|
||||
"empty": "설정된 허용 도구 없음"
|
||||
},
|
||||
"blockedTools": {
|
||||
"title": "차단된 도구",
|
||||
"description": "권한 확인 없이 자동으로 차단되는 도구",
|
||||
"placeholder": "예: \"Bash(rm:*)\"",
|
||||
"empty": "설정된 차단 도구 없음"
|
||||
},
|
||||
"allowedCommands": {
|
||||
"title": "허용된 Shell 명령어",
|
||||
"description": "권한 확인 없이 자동으로 허용되는 Shell 명령어",
|
||||
"placeholder": "예: \"Shell(ls)\" 또는 \"Shell(git status)\"",
|
||||
"quickAdd": "자주 쓰는 명령어 빠른 추가:",
|
||||
"empty": "설정된 허용 명령어 없음"
|
||||
},
|
||||
"blockedCommands": {
|
||||
"title": "차단된 Shell 명령어",
|
||||
"description": "자동으로 차단되는 Shell 명령어",
|
||||
"placeholder": "예: \"Shell(rm -rf)\" 또는 \"Shell(sudo)\"",
|
||||
"empty": "설정된 차단 명령어 없음"
|
||||
},
|
||||
"toolExamples": {
|
||||
"title": "도구 패턴 예시:",
|
||||
"bashGitLog": "- 모든 git log 명령어 허용",
|
||||
"bashGitDiff": "- 모든 git diff 명령어 허용",
|
||||
"write": "- 모든 Write 도구 사용 허용",
|
||||
"bashRm": "- 모든 rm 명령어 차단 (위험)"
|
||||
},
|
||||
"shellExamples": {
|
||||
"title": "Shell 명령어 예시:",
|
||||
"ls": "- ls 명령어 허용",
|
||||
"gitStatus": "- git status 허용",
|
||||
"npmInstall": "- npm install 허용",
|
||||
"rmRf": "- 재귀 삭제 차단"
|
||||
},
|
||||
"codex": {
|
||||
"permissionMode": "권한 모드",
|
||||
"description": "Codex가 파일 수정 및 명령어 실행을 처리하는 방식을 제어합니다",
|
||||
"modes": {
|
||||
"default": {
|
||||
"title": "기본",
|
||||
"description": "신뢰할 수 있는 명령어(ls, cat, grep, git status 등)만 자동 실행됩니다. 다른 명령어는 건너뜁니다. 워크스페이스에 쓰기 가능."
|
||||
},
|
||||
"acceptEdits": {
|
||||
"title": "편집 허용",
|
||||
"description": "워크스페이스 내에서 모든 명령어가 자동 실행됩니다. 샌드박스 내 완전 자동 모드."
|
||||
},
|
||||
"bypassPermissions": {
|
||||
"title": "권한 우회",
|
||||
"description": "제한 없는 전체 시스템 접근. 모든 명령어가 전체 디스크 및 네트워크 접근 권한으로 자동 실행됩니다. 주의해서 사용하세요."
|
||||
}
|
||||
},
|
||||
"technicalDetails": "기술 상세",
|
||||
"technicalInfo": {
|
||||
"default": "sandboxMode=workspace-write, approvalPolicy=untrusted. 신뢰할 수 있는 명령어: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find(-exec 제외) 등.",
|
||||
"acceptEdits": "sandboxMode=workspace-write, approvalPolicy=never. 프로젝트 디렉토리 내에서 모든 명령어 자동 실행.",
|
||||
"bypassPermissions": "sandboxMode=danger-full-access, approvalPolicy=never. 전체 시스템 접근, 신뢰할 수 있는 환경에서만 사용하세요.",
|
||||
"overrideNote": "채팅 인터페이스의 모드 버튼을 사용하여 세션별로 재정의할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"add": "추가"
|
||||
}
|
||||
},
|
||||
"mcpServers": {
|
||||
"title": "MCP 서버",
|
||||
"description": {
|
||||
"claude": "Model Context Protocol 서버는 Claude에 추가 도구와 데이터 소스를 제공합니다",
|
||||
"cursor": "Model Context Protocol 서버는 Cursor에 추가 도구와 데이터 소스를 제공합니다",
|
||||
"codex": "Model Context Protocol 서버는 Codex에 추가 도구와 데이터 소스를 제공합니다"
|
||||
},
|
||||
"addButton": "MCP 서버 추가",
|
||||
"empty": "설정된 MCP 서버 없음",
|
||||
"serverType": "유형",
|
||||
"scope": {
|
||||
"local": "로컬",
|
||||
"user": "사용자"
|
||||
},
|
||||
"config": {
|
||||
"command": "명령어",
|
||||
"url": "URL",
|
||||
"args": "인수",
|
||||
"environment": "환경"
|
||||
},
|
||||
"tools": {
|
||||
"title": "도구",
|
||||
"count": "({{count}}):",
|
||||
"more": "+{{count}}개 더"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "서버 편집",
|
||||
"delete": "서버 삭제"
|
||||
},
|
||||
"help": {
|
||||
"title": "Codex MCP 정보",
|
||||
"description": "Codex는 stdio 기반 MCP 서버를 지원합니다. 추가 도구와 리소스로 Codex의 기능을 확장하는 서버를 추가할 수 있습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/i18n/locales/ko/sidebar.json
Normal file
112
src/i18n/locales/ko/sidebar.json
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"projects": {
|
||||
"title": "프로젝트",
|
||||
"newProject": "새 프로젝트",
|
||||
"deleteProject": "프로젝트 삭제",
|
||||
"renameProject": "프로젝트 이름 변경",
|
||||
"noProjects": "프로젝트가 없습니다",
|
||||
"loadingProjects": "프로젝트 로딩 중...",
|
||||
"searchPlaceholder": "프로젝트 검색...",
|
||||
"projectNamePlaceholder": "프로젝트 이름",
|
||||
"starred": "즐겨찾기",
|
||||
"all": "전체",
|
||||
"untitledSession": "제목 없는 세션",
|
||||
"newSession": "새 세션",
|
||||
"codexSession": "Codex 세션",
|
||||
"fetchingProjects": "Claude 프로젝트와 세션을 가져오는 중",
|
||||
"projects": "프로젝트",
|
||||
"noMatchingProjects": "일치하는 프로젝트 없음",
|
||||
"tryDifferentSearch": "검색어를 변경해보세요",
|
||||
"runClaudeCli": "프로젝트 디렉토리에서 Claude CLI를 실행하여 시작하세요"
|
||||
},
|
||||
"app": {
|
||||
"title": "Claude Code UI",
|
||||
"subtitle": "AI 코딩 어시스턴트 UI"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "세션",
|
||||
"newSession": "새 세션",
|
||||
"deleteSession": "세션 삭제",
|
||||
"renameSession": "세션 이름 변경",
|
||||
"noSessions": "세션이 없습니다",
|
||||
"loadingSessions": "세션 로딩 중...",
|
||||
"unnamed": "이름 없음",
|
||||
"loading": "로딩 중...",
|
||||
"showMore": "더 많은 세션 보기"
|
||||
},
|
||||
"tooltips": {
|
||||
"viewEnvironments": "환경 보기",
|
||||
"hideSidebar": "사이드바 숨기기",
|
||||
"createProject": "새 프로젝트 생성",
|
||||
"refresh": "프로젝트 및 세션 새로고침 (Ctrl+R)",
|
||||
"renameProject": "프로젝트 이름 변경 (F2)",
|
||||
"deleteProject": "빈 프로젝트 삭제 (Delete)",
|
||||
"addToFavorites": "즐겨찾기에 추가",
|
||||
"removeFromFavorites": "즐겨찾기에서 제거",
|
||||
"editSessionName": "세션 이름 직접 편집",
|
||||
"deleteSession": "이 세션 영구 삭제",
|
||||
"save": "저장",
|
||||
"cancel": "취소"
|
||||
},
|
||||
"navigation": {
|
||||
"chat": "채팅",
|
||||
"files": "파일",
|
||||
"git": "Git",
|
||||
"terminal": "터미널",
|
||||
"tasks": "작업"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "새로고침",
|
||||
"settings": "설정",
|
||||
"collapseAll": "모두 접기",
|
||||
"expandAll": "모두 펼치기",
|
||||
"cancel": "취소",
|
||||
"save": "저장",
|
||||
"delete": "삭제",
|
||||
"rename": "이름 변경"
|
||||
},
|
||||
"status": {
|
||||
"active": "활성",
|
||||
"inactive": "비활성",
|
||||
"thinking": "생각 중...",
|
||||
"error": "오류",
|
||||
"aborted": "중단됨",
|
||||
"unknown": "알 수 없음"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "방금 전",
|
||||
"oneMinuteAgo": "1분 전",
|
||||
"minutesAgo": "{{count}}분 전",
|
||||
"oneHourAgo": "1시간 전",
|
||||
"hoursAgo": "{{count}}시간 전",
|
||||
"oneDayAgo": "1일 전",
|
||||
"daysAgo": "{{count}}일 전"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "정말 삭제하시겠습니까?",
|
||||
"renameSuccess": "이름이 변경되었습니다",
|
||||
"deleteSuccess": "삭제되었습니다",
|
||||
"errorOccurred": "오류가 발생했습니다",
|
||||
"deleteSessionConfirm": "이 세션을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"deleteProjectConfirm": "이 빈 프로젝트를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"enterProjectPath": "프로젝트 경로를 입력해주세요",
|
||||
"deleteSessionFailed": "세션 삭제 실패. 다시 시도해주세요.",
|
||||
"deleteSessionError": "세션 삭제 오류. 다시 시도해주세요.",
|
||||
"deleteProjectFailed": "프로젝트 삭제 실패. 다시 시도해주세요.",
|
||||
"deleteProjectError": "프로젝트 삭제 오류. 다시 시도해주세요.",
|
||||
"createProjectFailed": "프로젝트 생성 실패. 다시 시도해주세요.",
|
||||
"createProjectError": "프로젝트 생성 오류. 다시 시도해주세요."
|
||||
},
|
||||
"version": {
|
||||
"updateAvailable": "업데이트 가능"
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"deleteProject": "프로젝트 삭제",
|
||||
"deleteSession": "세션 삭제",
|
||||
"confirmDelete": "정말 삭제하시겠습니까",
|
||||
"sessionCount_one": "이 프로젝트에는 {{count}}개의 대화가 있습니다.",
|
||||
"sessionCount_other": "이 프로젝트에는 {{count}}개의 대화가 있습니다.",
|
||||
"allConversationsDeleted": "모든 대화가 영구적으로 삭제됩니다.",
|
||||
"cannotUndo": "이 작업은 취소할 수 없습니다."
|
||||
}
|
||||
}
|
||||
@@ -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,25 @@
|
||||
"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": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IS_PLATFORM } from "../constants/config";
|
||||
|
||||
// Utility function for authenticated API calls
|
||||
export const authenticatedFetch = (url, options = {}) => {
|
||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
const token = localStorage.getItem('auth-token');
|
||||
|
||||
const defaultHeaders = {};
|
||||
@@ -10,7 +11,7 @@ export const authenticatedFetch = (url, options = {}) => {
|
||||
defaultHeaders['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
if (!isPlatform && token) {
|
||||
if (!IS_PLATFORM && token) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
@@ -158,6 +159,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'),
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export function useWebSocket() {
|
||||
const [ws, setWs] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const reconnectTimeoutRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}, []); // Keep dependency array but add proper cleanup
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
|
||||
// Construct WebSocket URL
|
||||
let wsUrl;
|
||||
|
||||
if (isPlatform) {
|
||||
// Platform mode: Use same domain as the page (goes through proxy)
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
} else {
|
||||
// OSS mode: Connect to same host:port that served the page
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
console.warn('No authentication token found for WebSocket connection');
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
const websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
setIsConnected(true);
|
||||
setWs(websocket);
|
||||
};
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
setMessages(prev => [...prev, data]);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
setIsConnected(false);
|
||||
setWs(null);
|
||||
|
||||
// Attempt to reconnect after 3 seconds
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connect();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating WebSocket connection:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = (message) => {
|
||||
if (ws && isConnected) {
|
||||
ws.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn('WebSocket not connected');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ws,
|
||||
sendMessage,
|
||||
messages,
|
||||
isConnected
|
||||
};
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowJs": true,
|
||||
// "checkJs": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "shared", "vite.config.js"]
|
||||
}
|
||||
Reference in New Issue
Block a user