Compare commits

..

30 Commits

Author SHA1 Message Date
simosmik
1ed3358cbd Release 1.16.4 2026-02-11 15:26:18 +00:00
Haileyesus
c1e025b665 fix: claude code login issues (#375)
* fix: claude code login issues

1. Now, the browser opens in a new tab automatically
2. Clicking "C" to copy works
3. I have removed the "x-term" link selector since it didn't select the whole link

* fix: remove unnecessary terminal hyperlink for auth URL

* fix(shell): resolve clipboard handling for copy and paste events

* feat(shell): add authentication URL display and copy functionality - allows copy for mobile users

* revert: Update login command for unauthenticated users to use '/exit'

---------

Co-authored-by: Haileyesus <something@gmail.com>
2026-02-11 12:05:28 +01:00
Ayaan-buzzni
cf3d23ee31 feat(i18n): add Korean language support (#367)
* feat(i18n): add Korean language support

- Add Korean (ko) translation files for all namespaces:
  - common.json, auth.json, settings.json, sidebar.json, chat.json, codeEditor.json
- Register Korean locale in config.js and languages.js
- Follow translation guidelines:
  - Keep technical terms in English (UI, API, Shell, Git, etc.)
  - Use Korean phonetic for some terms (TaskMaster → 테스크마스터)
  - Maintain concise translations to match English length where possible

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(i18n): keep technical term "UI" in Korean translation

- Change "인터페이스" back to "UI" in sidebar subtitle
- Keep technical terms in English as per translation guidelines

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 09:47:10 +03:00
Haileyesus
e7d6c40452 Refactor WebSocket context + centralize platform flag (#363)
* fix: remove unnecessary websocket.js file and replace its usage directly in `WebSocketContext`

* fix: connect() doesn't need to be async

* fix: update WebSocket context import to use useWebSocket hook

* fix: use `useRef` for WebSocketContext

The main issue with using states was, previously the websocket never closed
properly on unmount, so multiple connections could be opened.

This was because the useEffect cleanup function was closing an old websocket
(that was initialized to null) instead of the current one.

We could have fixed this by adding `ws` to the useEffect dependency array, but
this was unnecessary since `ws` doesn't affect rendering so we shouldn't use a state.

* fix: replace `WebSocketContext` default value with null and add type definitions

* fix: add type definition for WebSocket URL and remove redundant protocol declaration

* fix: Prevent WebSocket reconnection attempts after unmount

Right now, when the WebSocketContext component unmounts,
there is still a pending reconnection attempt that tries
to reconnect the WebSocket after 3 seconds.

* refactor: Extract WebSocket URL construction into a separate function

* refactor: Centralize platform mode detection using IS_PLATFORM constant; use `token` from Auth context in WebSocket connection

* refactor: Use IS_PLATFORM constant for platform detection in authenticatedFetch function (backend)

* refactor: move IS_PLATFORM to config file for both frontend and backend

The reason we couldn't place it in shared/modelConstants.js is that the
frontend uses Vite which requires import.meta.env for environment variables,
while the backend uses process.env. Therefore, we created separate config files
for the frontend (src/constants/config.ts) and backend (server/constants/config.js).

* refactor: update import path for IS_PLATFORM constant to use config file

* refactor: replace `messages` with `latestMessage` in WebSocket context and related components

Why?
Because, messages was only being used to access the latest message in the components it's used in.

* refactor: optimize WebSocket connection handling with useCallback and useMemo

* refactor: comment out debug log for render count in AppContent component

* refactor(backend): update environment variable handling and replace VITE_IS_PLATFORM with IS_PLATFORM constant

* refactor: update WebSocket connection effect to depend on token changes for reconnection

---------
2026-02-03 10:05:15 +01:00
Haileyesus
216932e7f9 fix: correct spelling of "claude code" and update license to GPL-3.0 2026-02-02 20:51:44 +01:00
simosmik
e9719256fc Release 1.16.3 2026-01-30 17:13:57 +00:00
simosmik
55caaf060c fix: no-session-persistence removal 2026-01-30 17:09:54 +00:00
simosmik
f9c7321c8c Release 1.16.2 2026-01-30 16:58:51 +00:00
Haileyesus
88bda6e5c0 chore: update version to 1.16.0 and comment out checkJs in tsconfig 2026-01-30 16:49:30 +00:00
Haileyesus
86b421c790 feat: setup TypeScript with configuration and type definitions 2026-01-30 17:19:45 +01:00
viper151
41ef84c283 Merge pull request #353 from siteboon/fix/WORKSPACES_ROOT-issue-in-deployed-version 2026-01-29 20:55:55 +01:00
simosmik
53224e47b6 fix: change claude login order and command 2026-01-28 23:08:11 +00:00
Haileyesus
bbb51dbf99 fix: enforce WORKSPACES_ROOT in folder browser and folder creation 2026-01-28 22:12:20 +03:00
simosmik
2d06cae0ca Release 1.15.0 2026-01-28 10:06:50 +00:00
Haileyesus
14fb81586c Merge pull request #320 from EricBlanquer/fix/new-project-folder-selection
feat: enhance project creation wizard - folder browser fixes and git clone improvements
2026-01-27 22:58:56 +03:00
viper151
4d2b592ec6 Update Router to use dynamic basename 2026-01-26 15:38:00 +01:00
viper151
4957220a05 Remove base path configuration from Vite config 2026-01-26 13:39:24 +01:00
viper151
3debc3a249 Reorder return statements for claude commands 2026-01-26 13:34:38 +01:00
viper151
5512e2e15b Merge pull request #343 from siteboon/viper151-patch-1-1
Set base path for Vite configuration
2026-01-26 11:58:22 +01:00
viper151
1b42dba902 Set base path for Vite configuration 2026-01-26 11:56:05 +01:00
Eric Blanquer​
ede56ad81b fix: simplify project wizard labels for clarity 2026-01-26 03:25:43 +01:00
Eric Blanquer
36094fb73f fix: encode Windows paths correctly in addProjectManually
The regex only replaced forward slashes, causing Windows paths like
C:\Users\Eric\my_project to remain unchanged instead of being encoded
to C--Users-Eric-my-project. This caused API routes to fail.
2026-01-26 03:09:22 +01:00
Eric Blanquer​
57828653bf fix: handle EEXIST race and prevent data loss on clone 2026-01-26 03:09:22 +01:00
Eric Blanquer​
8ef0951901 fix: update i18n translations for clone progress and SSH detection 2026-01-26 03:09:22 +01:00
Eric Blanquer​
ab50c5c1a8 fix: address CodeRabbit review comments
- Add path validation to /api/create-folder endpoint (forbidden system dirs)
- Fix repo name extraction to handle trailing slashes in URLs
- Add cleanup of partial clone directory on SSE clone failure
- Remove dead code branch (unreachable message)
- Fix Windows path separator detection in createNewFolder
- Replace alert() with setError() for consistent error handling
- Detect ssh:// URLs in addition to git@ for SSH key display
- Show create folder button for both workspace types
2026-01-26 03:09:22 +01:00
Eric Blanquer​
6726e8f44e feat: enhance project creation wizard with folder creation and git clone progress
- Add "+" button to create new folders directly from folder browser
- Add SSE endpoint for git clone with real-time progress display
- Show clone progress (receiving objects, resolving deltas) in UI
- Detect SSH URLs and display "SSH Key" instead of "No authentication"
- Hide token section for SSH URLs (tokens only work with HTTPS)
- Fix auto-advance behavior: only auto-advance for "Existing Workspace"
- Fix various misleading UI messages
- Support auth token via query param for SSE endpoints
2026-01-26 03:09:22 +01:00
Eric Blanquer​
07f89e5240 fix: folder browser navigation issues
- Show parent directory (..) button even when folder has no subfolders
- Handle Windows backslash paths when calculating parent directory
- Prevent navigation to Windows drive root (C:\) from breaking
2026-01-26 03:09:22 +01:00
Eric Blanquer​
8a675a713b fix: use resolved path from API in folder browser
When selecting home folder in New Project wizard, ~ was being used
instead of /home/<user>, causing "Workspace path does not exist" error.
Now uses the resolved absolute path returned by the browse-filesystem API.
2026-01-26 03:09:22 +01:00
simosmik
5724c11253 fix:disabling zoom on focus on mobile iframe 2026-01-26 00:29:56 +00:00
simosmik
c7b9976986 fix: text selection on login for claude 2026-01-26 00:20:19 +00:00
40 changed files with 2100 additions and 347 deletions

37
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
}
}

View 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';

View File

@@ -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
View 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);
}

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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')) {

View File

@@ -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 />} />

View File

@@ -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 */}

View File

@@ -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.

View File

@@ -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 (

View File

@@ -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}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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 />;
}

View File

@@ -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>
);
}

View File

@@ -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
View 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';

View File

@@ -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();

View File

@@ -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 = {

View File

@@ -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;

View 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;

View File

@@ -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,

View File

@@ -14,6 +14,11 @@ export const languages = [
label: 'English',
nativeName: 'English',
},
{
value: 'ko',
label: 'Korean',
nativeName: '한국어',
},
{
value: 'zh-CN',
label: 'Simplified Chinese',

View File

@@ -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": {

View 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": "로그아웃"
}
}

View 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": "명령어"
}
}

View 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로 닫기"
}
}

View 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": "사이드바 닫기"
}
}
}

View 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의 기능을 확장하는 서버를 추가할 수 있습니다."
}
}
}

View 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": "이 작업은 취소할 수 없습니다."
}
}

View File

@@ -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": {

View File

@@ -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'),

View File

@@ -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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

20
tsconfig.json Normal file
View 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"]
}