Compare commits

...

49 Commits

Author SHA1 Message Date
Haileyesus
312654fdc6 refactor: comment out debug log for render count in AppContent component 2026-01-31 15:59:43 +03:00
Haileyesus
438b9698cc refactor: optimize WebSocket connection handling with useCallback and useMemo 2026-01-31 15:56:34 +03:00
Haileyesus
20d31da4f4 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.
2026-01-31 15:43:24 +03:00
Haileyesus
4f87018e61 refactor: update import path for IS_PLATFORM constant to use config file 2026-01-31 15:30:54 +03:00
Haileyesus
b2fdb90203 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).
2026-01-31 15:01:17 +03:00
Haileyesus
8bea3d83c8 refactor: Use IS_PLATFORM constant for platform detection in authenticatedFetch function (backend) 2026-01-31 14:35:26 +03:00
Haileyesus
cfd766819a refactor: Centralize platform mode detection using IS_PLATFORM constant; use token from Auth context in WebSocket connection 2026-01-31 14:34:01 +03:00
Haileyesus
471892b2bd refactor: Extract WebSocket URL construction into a separate function 2026-01-31 12:03:40 +03:00
Haileyesus
eca96c6973 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.
2026-01-31 11:58:46 +03:00
Haileyesus
5a4813f9bd fix: add type definition for WebSocket URL and remove redundant protocol declaration 2026-01-31 11:54:24 +03:00
Haileyesus
f6970d6ad9 fix: replace WebSocketContext default value with null and add type definitions 2026-01-31 11:45:46 +03:00
Haileyesus
e65a210cb3 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.
2026-01-31 11:35:43 +03:00
Haileyesus
8e9f7f0536 fix: update WebSocket context import to use useWebSocket hook 2026-01-30 21:52:25 +03:00
Haileyesus
51b316f69c fix: connect() doesn't need to be async 2026-01-30 21:52:25 +03:00
Haileyesus
dc21fb532a fix: remove unnecessary websocket.js file and replace its usage directly in WebSocketContext 2026-01-30 21:52:25 +03:00
Haileyesus
d9233f60b6 chore: add comment for render count tracker 2026-01-30 21:52:25 +03:00
Haileyesus
430d0ddc4a chore: add comments that will be used later 2026-01-30 21:52:25 +03: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
simosmik
f16e3e763d Release 1.14.0 2026-01-26 00:11:12 +00:00
simosmik
477bc404b0 fix: switch login mechanism for claude code 2026-01-25 23:55:05 +00:00
viper151
ae5a21cd6e Merge pull request #337 from timbot/main
fix: prevent codex spawn error when codex CLI is not installed
2026-01-25 23:18:57 +01:00
viper151
b2c69d6ea8 Merge branch 'main' into main 2026-01-25 23:17:29 +01:00
viper151
8825baf5b4 Update codex.js 2026-01-25 23:17:08 +01:00
viper151
0d1a3df1f7 Merge pull request #318 from siteboon/fix/session-streamed-to-another-chat
Fix/session streamed to another chat
2026-01-25 23:15:31 +01:00
Tim Smith
dab089b29f fix: prevent codex spawn error when codex CLI is not installed
Return success with empty servers array from the config read endpoint
when no config file exists, so the frontend doesn't fall through to
the CLI list endpoint which attempts to spawn the codex binary.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 11:21:43 -08:00
32 changed files with 767 additions and 286 deletions

37
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.13.6", "version": "1.16.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.13.6", "version": "1.16.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.29", "@anthropic-ai/claude-agent-sdk": "^0.1.29",
@@ -68,6 +68,7 @@
"cloudcli": "server/cli.js" "cloudcli": "server/cli.js"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.19.7",
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
@@ -79,6 +80,7 @@
"release-it": "^19.0.5", "release-it": "^19.0.5",
"sharp": "^0.34.2", "sharp": "^0.34.2",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "^5.9.3",
"vite": "^7.0.4" "vite": "^7.0.4"
} }
}, },
@@ -2906,6 +2908,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/parse-path": { "node_modules/@types/parse-path": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
@@ -11662,6 +11674,20 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/uglify-js": { "node_modules/uglify-js": {
"version": "3.19.3", "version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@@ -11686,6 +11712,13 @@
"node": ">=18.17" "node": ">=18.17"
} }
}, },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unified": { "node_modules/unified": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.13.6", "version": "1.16.3",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "server/index.js", "main": "server/index.js",
@@ -14,7 +14,7 @@
"dist/", "dist/",
"README.md" "README.md"
], ],
"homepage": "https://claudecodeui.siteboon.ai", "homepage": "https://cloudcli.ai",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/siteboon/claudecodeui.git" "url": "git+https://github.com/siteboon/claudecodeui.git"
@@ -28,6 +28,7 @@
"client": "vite --host", "client": "vite --host",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json",
"start": "npm run build && npm run server", "start": "npm run build && npm run server",
"release": "./release.sh" "release": "./release.sh"
}, },
@@ -96,6 +97,7 @@
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.19.7",
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
@@ -107,6 +109,7 @@
"release-it": "^19.0.5", "release-it": "^19.0.5",
"sharp": "^0.34.2", "sharp": "^0.34.2",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "^5.9.3",
"vite": "^7.0.4" "vite": "^7.0.4"
} }
} }

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

@@ -70,7 +70,7 @@ import mcpUtilsRoutes from './routes/mcp-utils.js';
import commandsRoutes from './routes/commands.js'; import commandsRoutes from './routes/commands.js';
import settingsRoutes from './routes/settings.js'; import settingsRoutes from './routes/settings.js';
import agentRoutes from './routes/agent.js'; import agentRoutes from './routes/agent.js';
import projectsRoutes from './routes/projects.js'; import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
import cliAuthRoutes from './routes/cli-auth.js'; import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js'; import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js'; import codexRoutes from './routes/codex.js';
@@ -484,22 +484,42 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
} }
}); });
const expandWorkspacePath = (inputPath) => {
if (!inputPath) return inputPath;
if (inputPath === '~') {
return WORKSPACES_ROOT;
}
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
return path.join(WORKSPACES_ROOT, inputPath.slice(2));
}
return inputPath;
};
// Browse filesystem endpoint for project suggestions - uses existing getFileTree // Browse filesystem endpoint for project suggestions - uses existing getFileTree
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => { app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
try { try {
const { path: dirPath } = req.query; const { path: dirPath } = req.query;
console.log('[API] Browse filesystem request for path:', dirPath);
console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
// Default to home directory if no path provided // Default to home directory if no path provided
const homeDir = os.homedir(); const defaultRoot = WORKSPACES_ROOT;
let targetPath = dirPath ? dirPath.replace('~', homeDir) : homeDir; let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
// Resolve and normalize the path // Resolve and normalize the path
targetPath = path.resolve(targetPath); targetPath = path.resolve(targetPath);
// Security check - ensure path is within allowed workspace root
const validation = await validateWorkspacePath(targetPath);
if (!validation.valid) {
return res.status(403).json({ error: validation.error });
}
const resolvedPath = validation.resolvedPath || targetPath;
// Security check - ensure path is accessible // Security check - ensure path is accessible
try { try {
await fs.promises.access(targetPath); await fs.promises.access(resolvedPath);
const stats = await fs.promises.stat(targetPath); const stats = await fs.promises.stat(resolvedPath);
if (!stats.isDirectory()) { if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Path is not a directory' }); return res.status(400).json({ error: 'Path is not a directory' });
@@ -509,7 +529,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
} }
// Use existing getFileTree function with shallow depth (only direct children) // Use existing getFileTree function with shallow depth (only direct children)
const fileTree = await getFileTree(targetPath, 1, 0, false); // maxDepth=1, showHidden=false const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
// Filter only directories and format for suggestions // Filter only directories and format for suggestions
const directories = fileTree const directories = fileTree
@@ -529,7 +549,13 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
// Add common directories if browsing home directory // Add common directories if browsing home directory
const suggestions = []; const suggestions = [];
if (targetPath === homeDir) { let resolvedWorkspaceRoot = defaultRoot;
try {
resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
} catch (error) {
// Use default root as-is if realpath fails
}
if (resolvedPath === resolvedWorkspaceRoot) {
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace']; const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name)); const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name)); const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
@@ -540,7 +566,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
} }
res.json({ res.json({
path: targetPath, path: resolvedPath,
suggestions: suggestions suggestions: suggestions
}); });
@@ -550,6 +576,46 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
} }
}); });
app.post('/api/create-folder', authenticateToken, async (req, res) => {
try {
const { path: folderPath } = req.body;
if (!folderPath) {
return res.status(400).json({ error: 'Path is required' });
}
const expandedPath = expandWorkspacePath(folderPath);
const resolvedInput = path.resolve(expandedPath);
const validation = await validateWorkspacePath(resolvedInput);
if (!validation.valid) {
return res.status(403).json({ error: validation.error });
}
const targetPath = validation.resolvedPath || resolvedInput;
const parentDir = path.dirname(targetPath);
try {
await fs.promises.access(parentDir);
} catch (err) {
return res.status(404).json({ error: 'Parent directory does not exist' });
}
try {
await fs.promises.access(targetPath);
return res.status(409).json({ error: 'Folder already exists' });
} catch (err) {
// Folder doesn't exist, which is what we want
}
try {
await fs.promises.mkdir(targetPath, { recursive: false });
res.json({ success: true, path: targetPath });
} catch (mkdirError) {
if (mkdirError.code === 'EEXIST') {
return res.status(409).json({ error: 'Folder already exists' });
}
throw mkdirError;
}
} catch (error) {
console.error('Error creating folder:', error);
res.status(500).json({ error: 'Failed to create folder' });
}
});
// Read file content endpoint // Read file content endpoint
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => { app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
try { try {

View File

@@ -37,7 +37,12 @@ const authenticateToken = async (req, res, next) => {
// Normal OSS JWT validation // Normal OSS JWT validation
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
// Also check query param for SSE endpoints (EventSource can't set headers)
if (!token && req.query.token) {
token = req.query.token;
}
if (!token) { if (!token) {
return res.status(401).json({ error: 'Access denied. No token provided.' }); return res.status(401).json({ error: 'Access denied. No token provided.' });

View File

@@ -1095,7 +1095,7 @@ async function addProjectManually(projectPath, displayName = null) {
} }
// Generate project name (encode path for use as directory name) // Generate project name (encode path for use as directory name)
const projectName = absolutePath.replace(/\//g, '-'); const projectName = absolutePath.replace(/[\\/:\s~_]/g, '-');
// Check if project already exists in config // Check if project already exists in config
const config = await loadProjectConfig(); const config = await loadProjectConfig();

View File

@@ -262,8 +262,7 @@ router.get('/mcp/config/read', async (req, res) => {
} }
if (!configData) { if (!configData) {
return res.json({ success: false, message: 'No Codex configuration file found', servers: [] }); return res.json({ success: true, configPath, servers: [] }); }
}
const servers = []; const servers = [];

View File

@@ -7,11 +7,17 @@ import { addProjectManually } from '../projects.js';
const router = express.Router(); const router = express.Router();
function sanitizeGitError(message, token) {
if (!message || !token) return message;
return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '***');
}
// Configure allowed workspace root (defaults to user's home directory) // Configure allowed workspace root (defaults to user's home directory)
const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir(); export const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
// System-critical paths that should never be used as workspace directories // System-critical paths that should never be used as workspace directories
const FORBIDDEN_PATHS = [ export const FORBIDDEN_PATHS = [
// Unix
'/', '/',
'/etc', '/etc',
'/bin', '/bin',
@@ -27,7 +33,14 @@ const FORBIDDEN_PATHS = [
'/lib64', '/lib64',
'/opt', '/opt',
'/tmp', '/tmp',
'/run' '/run',
// Windows
'C:\\Windows',
'C:\\Program Files',
'C:\\Program Files (x86)',
'C:\\ProgramData',
'C:\\System Volume Information',
'C:\\$Recycle.Bin'
]; ];
/** /**
@@ -35,7 +48,7 @@ const FORBIDDEN_PATHS = [
* @param {string} requestedPath - The path to validate * @param {string} requestedPath - The path to validate
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>} * @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
*/ */
async function validateWorkspacePath(requestedPath) { export async function validateWorkspacePath(requestedPath) {
try { try {
// Resolve to absolute path // Resolve to absolute path
let absolutePath = path.resolve(requestedPath); let absolutePath = path.resolve(requestedPath);
@@ -212,20 +225,7 @@ router.post('/create-workspace', async (req, res) => {
// Handle new workspace creation // Handle new workspace creation
if (workspaceType === 'new') { if (workspaceType === 'new') {
// Check if path already exists // Create the directory if it doesn't exist
try {
await fs.access(absolutePath);
return res.status(400).json({
error: 'Path already exists. Please choose a different path or use "existing workspace" option.'
});
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
// Path doesn't exist - good, we can create it
}
// Create the directory
await fs.mkdir(absolutePath, { recursive: true }); await fs.mkdir(absolutePath, { recursive: true });
// If GitHub URL is provided, clone the repository // If GitHub URL is provided, clone the repository
@@ -246,30 +246,55 @@ router.post('/create-workspace', async (req, res) => {
githubToken = newGithubToken; githubToken = newGithubToken;
} }
// Clone the repository // Extract repo name from URL for the clone destination
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
const repoName = normalizedUrl.split('/').pop() || 'repository';
const clonePath = path.join(absolutePath, repoName);
// Check if clone destination already exists to prevent data loss
try { try {
await cloneGitHubRepository(githubUrl, absolutePath, githubToken); await fs.access(clonePath);
return res.status(409).json({
error: 'Directory already exists',
details: `The destination path "${clonePath}" already exists. Please choose a different location or remove the existing directory.`
});
} catch (err) {
// Directory doesn't exist, which is what we want
}
// Clone the repository into a subfolder
try {
await cloneGitHubRepository(githubUrl, clonePath, githubToken);
} catch (error) { } catch (error) {
// Clean up created directory on failure // Only clean up if clone created partial data (check if dir exists and is empty or partial)
try { try {
await fs.rm(absolutePath, { recursive: true, force: true }); const stats = await fs.stat(clonePath);
if (stats.isDirectory()) {
await fs.rm(clonePath, { recursive: true, force: true });
}
} catch (cleanupError) { } catch (cleanupError) {
console.error('Failed to clean up directory after clone failure:', cleanupError); // Directory doesn't exist or cleanup failed - ignore
// Continue to throw original error
} }
throw new Error(`Failed to clone repository: ${error.message}`); throw new Error(`Failed to clone repository: ${error.message}`);
} }
// Add the cloned repo path to the project list
const project = await addProjectManually(clonePath);
return res.json({
success: true,
project,
message: 'New workspace created and repository cloned successfully'
});
} }
// Add the new workspace to the project list // Add the new workspace to the project list (no clone)
const project = await addProjectManually(absolutePath); const project = await addProjectManually(absolutePath);
return res.json({ return res.json({
success: true, success: true,
project, project,
message: githubUrl message: 'New workspace created successfully'
? 'New workspace created and repository cloned successfully'
: 'New workspace created successfully'
}); });
} }
@@ -305,31 +330,179 @@ async function getGithubTokenById(tokenId, userId) {
return null; return null;
} }
/**
* Clone repository with progress streaming (SSE)
* GET /api/projects/clone-progress
*/
router.get('/clone-progress', async (req, res) => {
const { path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.query;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const sendEvent = (type, data) => {
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
};
try {
if (!workspacePath || !githubUrl) {
sendEvent('error', { message: 'workspacePath and githubUrl are required' });
res.end();
return;
}
const validation = await validateWorkspacePath(workspacePath);
if (!validation.valid) {
sendEvent('error', { message: validation.error });
res.end();
return;
}
const absolutePath = validation.resolvedPath;
await fs.mkdir(absolutePath, { recursive: true });
let githubToken = null;
if (githubTokenId) {
const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);
if (!token) {
await fs.rm(absolutePath, { recursive: true, force: true });
sendEvent('error', { message: 'GitHub token not found' });
res.end();
return;
}
githubToken = token.github_token;
} else if (newGithubToken) {
githubToken = newGithubToken;
}
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
const repoName = normalizedUrl.split('/').pop() || 'repository';
const clonePath = path.join(absolutePath, repoName);
// Check if clone destination already exists to prevent data loss
try {
await fs.access(clonePath);
sendEvent('error', { message: `Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.` });
res.end();
return;
} catch (err) {
// Directory doesn't exist, which is what we want
}
let cloneUrl = githubUrl;
if (githubToken) {
try {
const url = new URL(githubUrl);
url.username = githubToken;
url.password = '';
cloneUrl = url.toString();
} catch (error) {
// SSH URL or invalid - use as-is
}
}
sendEvent('progress', { message: `Cloning into '${repoName}'...` });
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, clonePath], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0'
}
});
let lastError = '';
gitProcess.stdout.on('data', (data) => {
const message = data.toString().trim();
if (message) {
sendEvent('progress', { message });
}
});
gitProcess.stderr.on('data', (data) => {
const message = data.toString().trim();
lastError = message;
if (message) {
sendEvent('progress', { message });
}
});
gitProcess.on('close', async (code) => {
if (code === 0) {
try {
const project = await addProjectManually(clonePath);
sendEvent('complete', { project, message: 'Repository cloned successfully' });
} catch (error) {
sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` });
}
} else {
const sanitizedError = sanitizeGitError(lastError, githubToken);
let errorMessage = 'Git clone failed';
if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {
errorMessage = 'Authentication failed. Please check your credentials.';
} else if (lastError.includes('Repository not found')) {
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
} else if (lastError.includes('already exists')) {
errorMessage = 'Directory already exists';
} else if (sanitizedError) {
errorMessage = sanitizedError;
}
try {
await fs.rm(clonePath, { recursive: true, force: true });
} catch (cleanupError) {
console.error('Failed to clean up after clone failure:', sanitizeGitError(cleanupError.message, githubToken));
}
sendEvent('error', { message: errorMessage });
}
res.end();
});
gitProcess.on('error', (error) => {
if (error.code === 'ENOENT') {
sendEvent('error', { message: 'Git is not installed or not in PATH' });
} else {
sendEvent('error', { message: error.message });
}
res.end();
});
req.on('close', () => {
gitProcess.kill();
});
} catch (error) {
sendEvent('error', { message: error.message });
res.end();
}
});
/** /**
* Helper function to clone a GitHub repository * Helper function to clone a GitHub repository
*/ */
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) { function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Parse GitHub URL and inject token if provided
let cloneUrl = githubUrl; let cloneUrl = githubUrl;
if (githubToken) { if (githubToken) {
try { try {
const url = new URL(githubUrl); const url = new URL(githubUrl);
// Format: https://TOKEN@github.com/user/repo.git
url.username = githubToken; url.username = githubToken;
url.password = ''; url.password = '';
cloneUrl = url.toString(); cloneUrl = url.toString();
} catch (error) { } catch (error) {
return reject(new Error('Invalid GitHub URL format')); // SSH URL - use as-is
} }
} }
const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], { const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: { env: {
...process.env, ...process.env,
GIT_TERMINAL_PROMPT: '0' // Disable git password prompts GIT_TERMINAL_PROMPT: '0'
} }
}); });
@@ -348,7 +521,6 @@ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
if (code === 0) { if (code === 0) {
resolve({ stdout, stderr }); resolve({ stdout, stderr });
} else { } else {
// Parse git error messages to provide helpful feedback
let errorMessage = 'Git clone failed'; let errorMessage = 'Git clone failed';
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) { if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {

View File

@@ -62,4 +62,4 @@ export const CODEX_MODELS = {
], ],
DEFAULT: 'gpt-5.2' DEFAULT: 'gpt-5.2'
}; };

View File

@@ -31,7 +31,7 @@ import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { TaskMasterProvider } from './contexts/TaskMasterContext'; import { TaskMasterProvider } from './contexts/TaskMasterContext';
import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
import { WebSocketProvider, useWebSocketContext } from './contexts/WebSocketContext'; import { WebSocketProvider, useWebSocket } from './contexts/WebSocketContext';
import ProtectedRoute from './components/ProtectedRoute'; import ProtectedRoute from './components/ProtectedRoute';
import { useVersionCheck } from './hooks/useVersionCheck'; import { useVersionCheck } from './hooks/useVersionCheck';
import useLocalStorage from './hooks/useLocalStorage'; import useLocalStorage from './hooks/useLocalStorage';
@@ -40,11 +40,15 @@ import { I18nextProvider, useTranslation } from 'react-i18next';
import i18n from './i18n/config.js'; import i18n from './i18n/config.js';
// ! Move to a separate file called AppContent.ts
// Main App component with routing // Main App component with routing
function AppContent() { function AppContent() {
const navigate = useNavigate(); const navigate = useNavigate();
const { sessionId } = useParams(); const { sessionId } = useParams();
const { t } = useTranslation('common'); 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 { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
const [showVersionModal, setShowVersionModal] = useState(false); const [showVersionModal, setShowVersionModal] = useState(false);
@@ -81,7 +85,7 @@ function AppContent() {
// Triggers ChatInterface to reload messages without switching sessions // Triggers ChatInterface to reload messages without switching sessions
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0); const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
const { ws, sendMessage, messages } = useWebSocketContext(); const { ws, sendMessage, latestMessage } = useWebSocket();
// Ref to track loading progress timeout for cleanup // Ref to track loading progress timeout for cleanup
const loadingProgressTimeoutRef = useRef(null); const loadingProgressTimeoutRef = useRef(null);
@@ -175,9 +179,7 @@ function AppContent() {
// Handle WebSocket messages for real-time project updates // Handle WebSocket messages for real-time project updates
useEffect(() => { useEffect(() => {
if (messages.length > 0) { if (latestMessage) {
const latestMessage = messages[messages.length - 1];
// Handle loading progress updates // Handle loading progress updates
if (latestMessage.type === 'loading_progress') { if (latestMessage.type === 'loading_progress') {
if (loadingProgressTimeoutRef.current) { if (loadingProgressTimeoutRef.current) {
@@ -277,7 +279,7 @@ function AppContent() {
loadingProgressTimeoutRef.current = null; loadingProgressTimeoutRef.current = null;
} }
}; };
}, [messages, selectedProject, selectedSession, activeSessions]); }, [latestMessage, selectedProject, selectedSession, activeSessions]);
const fetchProjects = async () => { const fetchProjects = async () => {
try { try {
@@ -916,7 +918,7 @@ function AppContent() {
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
ws={ws} ws={ws}
sendMessage={sendMessage} sendMessage={sendMessage}
messages={messages} latestMessage={latestMessage}
isMobile={isMobile} isMobile={isMobile}
isPWA={isPWA} isPWA={isPWA}
onMenuClick={() => setSidebarOpen(true)} onMenuClick={() => setSidebarOpen(true)}
@@ -990,7 +992,7 @@ function App() {
<TasksSettingsProvider> <TasksSettingsProvider>
<TaskMasterProvider> <TaskMasterProvider>
<ProtectedRoute> <ProtectedRoute>
<Router> <Router basename={window.__ROUTER_BASENAME__ || ''}>
<Routes> <Routes>
<Route path="/" element={<AppContent />} /> <Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} /> <Route path="/session/:sessionId" element={<AppContent />} />

View File

@@ -43,6 +43,8 @@ import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelCo
import { safeJsonParse } from '../lib/utils.js'; import { safeJsonParse } from '../lib/utils.js';
// ! Move all utility functions to utils/chatUtils.ts
// Helper function to decode HTML entities in text // Helper function to decode HTML entities in text
function decodeHtmlEntities(text) { function decodeHtmlEntities(text) {
if (!text) return 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 // - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID
// //
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations. // 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 { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const [input, setInput] = useState(() => { const [input, setInput] = useState(() => {
@@ -3242,8 +3244,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
useEffect(() => { useEffect(() => {
// Handle WebSocket messages // Handle WebSocket messages
if (messages.length > 0) { if (latestMessage) {
const latestMessage = messages[messages.length - 1];
const messageData = latestMessage.data?.message || latestMessage.data; const messageData = latestMessage.data?.message || latestMessage.data;
// Filter messages by session ID to prevent cross-session interference // Filter messages by session ID to prevent cross-session interference
@@ -4068,7 +4069,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} }
} }
}, [messages]); }, [latestMessage]);
// Load file list when project changes // Load file list when project changes
useEffect(() => { useEffect(() => {
@@ -4879,7 +4880,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}; };
// ! Unused
const handleNewSession = () => { const handleNewSession = () => {
setChatMessages([]); setChatMessages([]);
setInput(''); setInput('');
@@ -4975,7 +4976,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className="flex flex-col items-center justify-center h-full gap-3"> <div className="flex flex-col items-center justify-center h-full gap-3">
<ClaudeLogo className="w-10 h-10" /> <ClaudeLogo className="w-10 h-10" />
<div> <div>
<p className="font-semibold text-gray-900 dark:text-white">Claude</p> <p className="font-semibold text-gray-900 dark:text-white">Claude Code</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.anthropic')}</p> <p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.anthropic')}</p>
</div> </div>
</div> </div>
@@ -5590,7 +5591,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
aria-hidden="true" aria-hidden="true"
className="absolute inset-0 pointer-events-none overflow-hidden rounded-2xl" 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)} {renderInputWithMentions(input)}
</div> </div>
</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') })} placeholder={t('input.placeholder', { provider: provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude') })}
disabled={isLoading} disabled={isLoading}
className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base leading-[21px] sm:leading-6 transition-all duration-200" className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-base leading-6 transition-all duration-200"
style={{ height: '50px' }} style={{ height: '50px' }}
/> />
{/* Image upload button */} {/* Image upload button */}

View File

@@ -960,6 +960,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
{gitStatus.details && ( {gitStatus.details && (
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{gitStatus.details}</p> <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"> <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"> <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. <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 { X } from 'lucide-react';
import StandaloneShell from './StandaloneShell'; import StandaloneShell from './StandaloneShell';
import { IS_PLATFORM } from '../constants/config';
/** /**
* Reusable login modal component for Claude, Cursor, and Codex CLI authentication * Reusable login modal component for Claude, Cursor, and Codex CLI authentication
@@ -11,6 +12,7 @@ import StandaloneShell from './StandaloneShell';
* @param {Object} props.project - Project object containing name and path information * @param {Object} props.project - Project object containing name and path information
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode) * @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
* @param {string} props.customCommand - Optional custom command to override defaults * @param {string} props.customCommand - Optional custom command to override defaults
* @param {boolean} props.isAuthenticated - Whether user is already authenticated (for re-auth flow)
*/ */
function LoginModal({ function LoginModal({
isOpen, isOpen,
@@ -18,24 +20,23 @@ function LoginModal({
provider = 'claude', provider = 'claude',
project, project,
onComplete, onComplete,
customCommand customCommand,
isAuthenticated = false
}) { }) {
if (!isOpen) return null; if (!isOpen) return null;
const getCommand = () => { const getCommand = () => {
if (customCommand) return customCommand; if (customCommand) return customCommand;
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
switch (provider) { switch (provider) {
case 'claude': case 'claude':
return 'claude setup-token --dangerously-skip-permissions'; return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /exit --dangerously-skip-permissions';
case 'cursor': case 'cursor':
return 'cursor-agent login'; return 'cursor-agent login';
case 'codex': case 'codex':
return isPlatform ? 'codex login --device-auth' : 'codex login'; return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
default: default:
return 'claude setup-token --dangerously-skip-permissions'; return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /exit --dangerously-skip-permissions';
} }
}; };

View File

@@ -36,9 +36,9 @@ function MainContent({
setActiveTab, setActiveTab,
ws, ws,
sendMessage, sendMessage,
messages, latestMessage,
isMobile, isMobile,
isPWA, isPWA, // ! Unused
onMenuClick, onMenuClick,
isLoading, isLoading,
onInputFocusChange, onInputFocusChange,
@@ -477,7 +477,7 @@ function MainContent({
selectedSession={selectedSession} selectedSession={selectedSession}
ws={ws} ws={ws}
sendMessage={sendMessage} sendMessage={sendMessage}
messages={messages} latestMessage={latestMessage}
onFileOpen={handleFileOpen} onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange} onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive} onSessionActive={onSessionActive}

View File

@@ -6,6 +6,7 @@ import CodexLogo from './CodexLogo';
import LoginModal from './LoginModal'; import LoginModal from './LoginModal';
import { authenticatedFetch } from '../utils/api'; import { authenticatedFetch } from '../utils/api';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { IS_PLATFORM } from '../constants/config';
const Onboarding = ({ onComplete }) => { const Onboarding = ({ onComplete }) => {
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
@@ -15,7 +16,7 @@ const Onboarding = ({ onComplete }) => {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [activeLoginProvider, setActiveLoginProvider] = useState(null); const [activeLoginProvider, setActiveLoginProvider] = useState(null);
const [selectedProject] = useState({ name: 'default', fullPath: '' }); const [selectedProject] = useState({ name: 'default', fullPath: IS_PLATFORM ? '/workspace' : '' });
const [claudeAuthStatus, setClaudeAuthStatus] = useState({ const [claudeAuthStatus, setClaudeAuthStatus] = useState({
authenticated: false, authenticated: false,

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff } from 'lucide-react'; import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff, Plus } from 'lucide-react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { api } from '../utils/api'; import { api } from '../utils/api';
@@ -30,6 +30,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const [browserFolders, setBrowserFolders] = useState([]); const [browserFolders, setBrowserFolders] = useState([]);
const [loadingFolders, setLoadingFolders] = useState(false); const [loadingFolders, setLoadingFolders] = useState(false);
const [showHiddenFolders, setShowHiddenFolders] = useState(false); const [showHiddenFolders, setShowHiddenFolders] = useState(false);
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [creatingFolder, setCreatingFolder] = useState(false);
const [cloneProgress, setCloneProgress] = useState('');
// Load available GitHub tokens when needed // Load available GitHub tokens when needed
useEffect(() => { useEffect(() => {
@@ -78,9 +82,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const data = await response.json(); const data = await response.json();
if (data.suggestions) { if (data.suggestions) {
// Filter suggestions based on the input // Filter suggestions based on the input, excluding exact match
const filtered = data.suggestions.filter(s => const filtered = data.suggestions.filter(s =>
s.path.toLowerCase().startsWith(inputPath.toLowerCase()) s.path.toLowerCase().startsWith(inputPath.toLowerCase()) &&
s.path.toLowerCase() !== inputPath.toLowerCase()
); );
setPathSuggestions(filtered.slice(0, 5)); setPathSuggestions(filtered.slice(0, 5));
setShowPathDropdown(filtered.length > 0); setShowPathDropdown(filtered.length > 0);
@@ -118,32 +123,69 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const handleCreate = async () => { const handleCreate = async () => {
setIsCreating(true); setIsCreating(true);
setError(null); setError(null);
setCloneProgress('');
try { try {
if (workspaceType === 'new' && githubUrl) {
const params = new URLSearchParams({
path: workspacePath.trim(),
githubUrl: githubUrl.trim(),
});
if (tokenMode === 'stored' && selectedGithubToken) {
params.append('githubTokenId', selectedGithubToken);
} else if (tokenMode === 'new' && newGithubToken) {
params.append('newGithubToken', newGithubToken.trim());
}
const token = localStorage.getItem('auth-token');
const url = `/api/projects/clone-progress?${params}${token ? `&token=${token}` : ''}`;
await new Promise((resolve, reject) => {
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
setCloneProgress(data.message);
} else if (data.type === 'complete') {
eventSource.close();
if (onProjectCreated) {
onProjectCreated(data.project);
}
onClose();
resolve();
} else if (data.type === 'error') {
eventSource.close();
reject(new Error(data.message));
}
} catch (e) {
console.error('Error parsing SSE event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
reject(new Error('Connection lost during clone'));
};
});
return;
}
const payload = { const payload = {
workspaceType, workspaceType,
path: workspacePath.trim(), path: workspacePath.trim(),
}; };
// Add GitHub info if creating new workspace with GitHub URL
if (workspaceType === 'new' && githubUrl) {
payload.githubUrl = githubUrl.trim();
if (tokenMode === 'stored' && selectedGithubToken) {
payload.githubTokenId = parseInt(selectedGithubToken);
} else if (tokenMode === 'new' && newGithubToken) {
payload.newGithubToken = newGithubToken.trim();
}
}
const response = await api.createWorkspace(payload); const response = await api.createWorkspace(payload);
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || t('projectWizard.errors.failedToCreate')); throw new Error(data.details || data.error || t('projectWizard.errors.failedToCreate'));
} }
// Success!
if (onProjectCreated) { if (onProjectCreated) {
onProjectCreated(data.project); onProjectCreated(data.project);
} }
@@ -170,9 +212,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const loadBrowserFolders = async (path) => { const loadBrowserFolders = async (path) => {
try { try {
setLoadingFolders(true); setLoadingFolders(true);
setBrowserCurrentPath(path);
const response = await api.browseFilesystem(path); const response = await api.browseFilesystem(path);
const data = await response.json(); const data = await response.json();
setBrowserCurrentPath(data.path || path);
setBrowserFolders(data.suggestions || []); setBrowserFolders(data.suggestions || []);
} catch (error) { } catch (error) {
console.error('Error loading folders:', error); console.error('Error loading folders:', error);
@@ -193,6 +235,29 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
await loadBrowserFolders(folderPath); await loadBrowserFolders(folderPath);
}; };
const createNewFolder = async () => {
if (!newFolderName.trim()) return;
setCreatingFolder(true);
setError(null);
try {
const separator = browserCurrentPath.includes('\\') ? '\\' : '/';
const folderPath = `${browserCurrentPath}${separator}${newFolderName.trim()}`;
const response = await api.createFolder(folderPath);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
}
setNewFolderName('');
setShowNewFolderInput(false);
await loadBrowserFolders(data.path || folderPath);
} catch (error) {
console.error('Error creating folder:', error);
setError(error.message || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
} finally {
setCreatingFolder(false);
}
};
return ( return (
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4"> <div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto"> <div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
@@ -388,8 +453,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</p> </p>
</div> </div>
{/* GitHub Token (only if GitHub URL is provided) */} {/* GitHub Token (only for HTTPS URLs - SSH uses SSH keys) */}
{githubUrl && ( {githubUrl && !githubUrl.startsWith('git@') && !githubUrl.startsWith('ssh://') && (
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700"> <div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-3 mb-4"> <div className="flex items-start gap-3 mb-4">
<Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" /> <Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
@@ -551,6 +616,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}` ? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
: tokenMode === 'new' && newGithubToken : tokenMode === 'new' && newGithubToken
? t('projectWizard.step3.usingProvidedToken') ? t('projectWizard.step3.usingProvidedToken')
: (githubUrl.startsWith('git@') || githubUrl.startsWith('ssh://'))
? t('projectWizard.step3.sshKey', 'SSH Key')
: t('projectWizard.step3.noAuthentication')} : t('projectWizard.step3.noAuthentication')}
</span> </span>
</div> </div>
@@ -560,13 +627,22 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</div> </div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800"> <div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200"> {isCreating && cloneProgress ? (
{workspaceType === 'existing' <div className="space-y-2">
? t('projectWizard.step3.existingInfo') <p className="text-sm font-medium text-blue-800 dark:text-blue-200">{t('projectWizard.step3.cloningRepository', 'Cloning repository...')}</p>
: githubUrl <code className="block text-xs font-mono text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-all">
? t('projectWizard.step3.newWithClone') {cloneProgress}
: t('projectWizard.step3.newEmpty')} </code>
</p> </div>
) : (
<p className="text-sm text-blue-800 dark:text-blue-200">
{workspaceType === 'existing'
? t('projectWizard.step3.existingInfo')
: githubUrl
? t('projectWizard.step3.newWithClone')
: t('projectWizard.step3.newEmpty')}
</p>
)}
</div> </div>
</div> </div>
)} )}
@@ -596,7 +672,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
{isCreating ? ( {isCreating ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('projectWizard.buttons.creating')} {githubUrl ? t('projectWizard.buttons.cloning', 'Cloning...') : t('projectWizard.buttons.creating')}
</> </>
) : step === 3 ? ( ) : step === 3 ? (
<> <>
@@ -639,6 +715,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
> >
{showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />} {showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
</button> </button>
<button
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
className={`p-2 rounded-md transition-colors ${
showNewFolderInput
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title="Create new folder"
>
<Plus className="w-5 h-5" />
</button>
<button <button
onClick={() => setShowFolderBrowser(false)} onClick={() => setShowFolderBrowser(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700" className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
@@ -648,23 +735,67 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</div> </div>
</div> </div>
{/* New Folder Input */}
{showNewFolderInput && (
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20">
<div className="flex items-center gap-2">
<Input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="New folder name"
className="flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter') createNewFolder();
if (e.key === 'Escape') {
setShowNewFolderInput(false);
setNewFolderName('');
}
}}
autoFocus
/>
<Button
size="sm"
onClick={createNewFolder}
disabled={!newFolderName.trim() || creatingFolder}
>
{creatingFolder ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Create'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setShowNewFolderInput(false);
setNewFolderName('');
}}
>
Cancel
</Button>
</div>
</div>
)}
{/* Folder List */} {/* Folder List */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{loadingFolders ? ( {loadingFolders ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" /> <Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div> </div>
) : browserFolders.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No folders found
</div>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{/* Parent Directory */} {/* Parent Directory - check for Windows root (e.g., C:\) and Unix root */}
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && ( {browserCurrentPath !== '~' && browserCurrentPath !== '/' && !/^[A-Za-z]:\\?$/.test(browserCurrentPath) && (
<button <button
onClick={() => { onClick={() => {
const parentPath = browserCurrentPath.substring(0, browserCurrentPath.lastIndexOf('/')) || '/'; const lastSlash = Math.max(browserCurrentPath.lastIndexOf('/'), browserCurrentPath.lastIndexOf('\\'));
let parentPath;
if (lastSlash <= 0) {
parentPath = '/';
} else if (lastSlash === 2 && /^[A-Za-z]:/.test(browserCurrentPath)) {
parentPath = browserCurrentPath.substring(0, 3);
} else {
parentPath = browserCurrentPath.substring(0, lastSlash);
}
navigateToFolder(parentPath); navigateToFolder(parentPath);
}} }}
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3" className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
@@ -675,28 +806,34 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
)} )}
{/* Folders */} {/* Folders */}
{browserFolders {browserFolders.length === 0 ? (
.filter(folder => showHiddenFolders || !folder.name.startsWith('.')) <div className="text-center py-8 text-gray-500 dark:text-gray-400">
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) No subfolders found
.map((folder, index) => (
<div key={index} className="flex items-center gap-2">
<button
onClick={() => navigateToFolder(folder.path)}
className="flex-1 px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
>
<FolderPlus className="w-5 h-5 text-blue-500" />
<span className="font-medium text-gray-900 dark:text-white">{folder.name}</span>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => selectFolder(folder.path, true)}
className="text-xs px-3"
>
Select
</Button>
</div> </div>
))} ) : (
browserFolders
.filter(folder => showHiddenFolders || !folder.name.startsWith('.'))
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
.map((folder, index) => (
<div key={index} className="flex items-center gap-2">
<button
onClick={() => navigateToFolder(folder.path)}
className="flex-1 px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
>
<FolderPlus className="w-5 h-5 text-blue-500" />
<span className="font-medium text-gray-900 dark:text-white">{folder.name}</span>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => selectFolder(folder.path, workspaceType === 'existing')}
className="text-xs px-3"
>
Select
</Button>
</div>
))
)}
</div> </div>
)} )}
</div> </div>
@@ -712,13 +849,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<div className="flex items-center justify-end gap-2 p-4"> <div className="flex items-center justify-end gap-2 p-4">
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowFolderBrowser(false)} onClick={() => {
setShowFolderBrowser(false);
setShowNewFolderInput(false);
setNewFolderName('');
}}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => selectFolder(browserCurrentPath, true)} onClick={() => selectFolder(browserCurrentPath, workspaceType === 'existing')}
> >
Use this folder Use this folder
</Button> </Button>

View File

@@ -4,6 +4,7 @@ import SetupForm from './SetupForm';
import LoginForm from './LoginForm'; import LoginForm from './LoginForm';
import Onboarding from './Onboarding'; import Onboarding from './Onboarding';
import { MessageSquare } from 'lucide-react'; import { MessageSquare } from 'lucide-react';
import { IS_PLATFORM } from '../constants/config';
const LoadingScreen = () => ( const LoadingScreen = () => (
<div className="min-h-screen bg-background flex items-center justify-center p-4"> <div className="min-h-screen bg-background flex items-center justify-center p-4">
@@ -27,7 +28,7 @@ const LoadingScreen = () => (
const ProtectedRoute = ({ children }) => { const ProtectedRoute = ({ children }) => {
const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth(); const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth();
if (import.meta.env.VITE_IS_PLATFORM === 'true') { if (IS_PLATFORM) {
if (isLoading) { if (isLoading) {
return <LoadingScreen />; return <LoadingScreen />;
} }

View File

@@ -1966,6 +1966,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
provider={loginProvider} provider={loginProvider}
project={selectedProject} project={selectedProject}
onComplete={handleLoginComplete} onComplete={handleLoginComplete}
isAuthenticated={
loginProvider === 'claude' ? claudeAuthStatus.authenticated :
loginProvider === 'cursor' ? cursorAuthStatus.authenticated :
loginProvider === 'codex' ? codexAuthStatus.authenticated :
false
}
/> />
</div> </div>
); );

View File

@@ -5,6 +5,7 @@ import { WebglAddon } from '@xterm/addon-webgl';
import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IS_PLATFORM } from '../constants/config';
const xtermStyles = ` const xtermStyles = `
.xterm .xterm-screen { .xterm .xterm-screen {
@@ -55,10 +56,9 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
if (isConnecting || isConnected) return; if (isConnecting || isConnected) return;
try { try {
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
let wsUrl; let wsUrl;
if (isPlatform) { if (IS_PLATFORM) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/shell`; wsUrl = `${protocol}//${window.location.host}/shell`;
} else { } else {
@@ -234,7 +234,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
tabStopWidth: 4, tabStopWidth: 4,
windowsMode: false, windowsMode: false,
macOptionIsMeta: true, macOptionIsMeta: true,
macOptionClickForcesSelection: false, macOptionClickForcesSelection: true,
theme: { theme: {
background: '#1e1e1e', background: '#1e1e1e',
foreground: '#d4d4d4', foreground: '#d4d4d4',

View File

@@ -16,6 +16,7 @@ import ProjectCreationWizard from './ProjectCreationWizard';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { useTaskMaster } from '../contexts/TaskMasterContext'; import { useTaskMaster } from '../contexts/TaskMasterContext';
import { useTasksSettings } from '../contexts/TasksSettingsContext'; import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { IS_PLATFORM } from '../constants/config';
// Move formatTimeAgo outside component to avoid recreation on every render // Move formatTimeAgo outside component to avoid recreation on every render
const formatTimeAgo = (dateString, currentTime, t) => { const formatTimeAgo = (dateString, currentTime, t) => {
@@ -622,7 +623,7 @@ function Sidebar({
<div className="md:p-4 md:border-b md:border-border"> <div className="md:p-4 md:border-b md:border-border">
{/* Desktop Header */} {/* Desktop Header */}
<div className="hidden md:flex items-center justify-between"> <div className="hidden md:flex items-center justify-between">
{import.meta.env.VITE_IS_PLATFORM === 'true' ? ( {IS_PLATFORM ? (
<a <a
href="https://cloudcli.ai/dashboard" href="https://cloudcli.ai/dashboard"
className="flex items-center gap-3 hover:opacity-80 transition-opacity group" className="flex items-center gap-3 hover:opacity-80 transition-opacity group"
@@ -673,7 +674,7 @@ function Sidebar({
style={isPWA && isMobile ? { paddingTop: '16px' } : {}} style={isPWA && isMobile ? { paddingTop: '16px' } : {}}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{import.meta.env.VITE_IS_PLATFORM === 'true' ? ( {IS_PLATFORM ? (
<a <a
href="https://cloudcli.ai/dashboard" href="https://cloudcli.ai/dashboard"
className="flex items-center gap-3 active:opacity-70 transition-opacity" 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 React, { createContext, useContext, useEffect, useState } from 'react';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { IS_PLATFORM } from '../constants/config';
const AuthContext = createContext({ const AuthContext = createContext({
user: null, user: null,
@@ -31,7 +32,7 @@ export const AuthProvider = ({ children }) => {
const [error, setError] = useState(null); const [error, setError] = useState(null);
useEffect(() => { useEffect(() => {
if (import.meta.env.VITE_IS_PLATFORM === 'true') { if (IS_PLATFORM) {
setUser({ username: 'platform-user' }); setUser({ username: 'platform-user' });
setNeedsSetup(false); setNeedsSetup(false);
checkOnboardingStatus(); checkOnboardingStatus();

View File

@@ -1,7 +1,7 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { useAuth } from './AuthContext'; import { useAuth } from './AuthContext';
import { useWebSocketContext } from './WebSocketContext'; import { useWebSocket } from './WebSocketContext';
const TaskMasterContext = createContext({ const TaskMasterContext = createContext({
// TaskMaster project state // TaskMaster project state
@@ -42,7 +42,7 @@ export const useTaskMaster = () => {
export const TaskMasterProvider = ({ children }) => { export const TaskMasterProvider = ({ children }) => {
// Get WebSocket messages from shared context to avoid duplicate connections // Get WebSocket messages from shared context to avoid duplicate connections
const { messages } = useWebSocketContext(); const { latestMessage } = useWebSocket();
// Authentication context // Authentication context
const { user, token, isLoading: authLoading } = useAuth(); const { user, token, isLoading: authLoading } = useAuth();
@@ -238,9 +238,8 @@ export const TaskMasterProvider = ({ children }) => {
} }
}, [currentProject?.name, user, token, refreshTasks]); }, [currentProject?.name, user, token, refreshTasks]);
// Handle WebSocket messages for TaskMaster updates // Handle WebSocket latestMessage for TaskMaster updates
useEffect(() => { useEffect(() => {
const latestMessage = messages[messages.length - 1];
if (!latestMessage) return; if (!latestMessage) return;
@@ -268,7 +267,7 @@ export const TaskMasterProvider = ({ children }) => {
// Ignore non-TaskMaster messages // Ignore non-TaskMaster messages
break; break;
} }
}, [messages, refreshProjects, refreshTasks, refreshMCPStatus, currentProject]); }, [latestMessage, refreshProjects, refreshTasks, refreshMCPStatus, currentProject]);
// Context value // Context value
const contextValue = { 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();
}
};
}, []); // Keep dependency array but add proper cleanup
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

@@ -136,14 +136,14 @@
}, },
"step2": { "step2": {
"existingPath": "Workspace Path", "existingPath": "Workspace Path",
"newPath": "Where should the workspace be created?", "newPath": "Workspace Path",
"existingPlaceholder": "/path/to/existing/workspace", "existingPlaceholder": "/path/to/existing/workspace",
"newPlaceholder": "/path/to/new/workspace", "newPlaceholder": "/path/to/new/workspace",
"existingHelp": "Full path to your existing workspace directory", "existingHelp": "Full path to your existing workspace directory",
"newHelp": "Full path where the new workspace will be created", "newHelp": "Full path to your workspace directory",
"githubUrl": "GitHub URL (Optional)", "githubUrl": "GitHub URL (Optional)",
"githubPlaceholder": "https://github.com/username/repository", "githubPlaceholder": "https://github.com/username/repository",
"githubHelp": "Leave empty to create an empty workspace, or provide a GitHub URL to clone", "githubHelp": "Optional: provide a GitHub URL to clone a repository",
"githubAuth": "GitHub Authentication (Optional)", "githubAuth": "GitHub Authentication (Optional)",
"githubAuthHelp": "Only required for private repositories. Public repos can be cloned without authentication.", "githubAuthHelp": "Only required for private repositories. Public repos can be cloned without authentication.",
"loadingTokens": "Loading stored tokens...", "loadingTokens": "Loading stored tokens...",
@@ -170,21 +170,25 @@
"usingStoredToken": "Using stored token:", "usingStoredToken": "Using stored token:",
"usingProvidedToken": "Using provided token", "usingProvidedToken": "Using provided token",
"noAuthentication": "No authentication", "noAuthentication": "No authentication",
"sshKey": "SSH Key",
"existingInfo": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.", "existingInfo": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
"newWithClone": "A new workspace will be created and the repository will be cloned from GitHub.", "newWithClone": "The repository will be cloned from this folder.",
"newEmpty": "An empty workspace directory will be created at the specified path." "newEmpty": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
"cloningRepository": "Cloning repository..."
}, },
"buttons": { "buttons": {
"cancel": "Cancel", "cancel": "Cancel",
"back": "Back", "back": "Back",
"next": "Next", "next": "Next",
"createProject": "Create Project", "createProject": "Create Project",
"creating": "Creating..." "creating": "Creating...",
"cloning": "Cloning..."
}, },
"errors": { "errors": {
"selectType": "Please select whether you have an existing workspace or want to create a new one", "selectType": "Please select whether you have an existing workspace or want to create a new one",
"providePath": "Please provide a workspace path", "providePath": "Please provide a workspace path",
"failedToCreate": "Failed to create workspace" "failedToCreate": "Failed to create workspace",
"failedToCreateFolder": "Failed to create folder"
} }
}, },
"versionUpdate": { "versionUpdate": {

View File

@@ -136,14 +136,14 @@
}, },
"step2": { "step2": {
"existingPath": "工作区路径", "existingPath": "工作区路径",
"newPath": "应该在哪里创建工作区", "newPath": "工作区路径",
"existingPlaceholder": "/path/to/existing/workspace", "existingPlaceholder": "/path/to/existing/workspace",
"newPlaceholder": "/path/to/new/workspace", "newPlaceholder": "/path/to/new/workspace",
"existingHelp": "您现有工作区目录的完整路径", "existingHelp": "您现有工作区目录的完整路径",
"newHelp": "将创建新工作区的完整路径", "newHelp": "工作区目录的完整路径",
"githubUrl": "GitHub URL可选", "githubUrl": "GitHub URL可选",
"githubPlaceholder": "https://github.com/username/repository", "githubPlaceholder": "https://github.com/username/repository",
"githubHelp": "留空以创建空工作区,或提供 GitHub URL 以克隆", "githubHelp": "可选:提供 GitHub URL 以克隆仓库",
"githubAuth": "GitHub 身份验证(可选)", "githubAuth": "GitHub 身份验证(可选)",
"githubAuthHelp": "仅私有仓库需要。公共仓库无需身份验证即可克隆。", "githubAuthHelp": "仅私有仓库需要。公共仓库无需身份验证即可克隆。",
"loadingTokens": "正在加载已保存的令牌...", "loadingTokens": "正在加载已保存的令牌...",
@@ -170,21 +170,25 @@
"usingStoredToken": "使用已保存的令牌:", "usingStoredToken": "使用已保存的令牌:",
"usingProvidedToken": "使用提供的令牌", "usingProvidedToken": "使用提供的令牌",
"noAuthentication": "无身份验证", "noAuthentication": "无身份验证",
"sshKey": "SSH 密钥",
"existingInfo": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。", "existingInfo": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。",
"newWithClone": "将创建新工作区,并从 GitHub 克隆仓库。", "newWithClone": "仓库将从此文件夹克隆。",
"newEmpty": "将在指定路径创建一个空的工作区目录。" "newEmpty": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。",
"cloningRepository": "正在克隆仓库..."
}, },
"buttons": { "buttons": {
"cancel": "取消", "cancel": "取消",
"back": "返回", "back": "返回",
"next": "下一步", "next": "下一步",
"createProject": "创建项目", "createProject": "创建项目",
"creating": "创建中..." "creating": "创建中...",
"cloning": "正在克隆..."
}, },
"errors": { "errors": {
"selectType": "请选择您已有现有工作区还是想创建新工作区", "selectType": "请选择您已有现有工作区还是想创建新工作区",
"providePath": "请提供工作区路径", "providePath": "请提供工作区路径",
"failedToCreate": "创建工作区失败" "failedToCreate": "创建工作区失败",
"failedToCreateFolder": "创建文件夹失败"
} }
}, },
"versionUpdate": { "versionUpdate": {

View File

@@ -1,6 +1,7 @@
import { IS_PLATFORM } from "../constants/config";
// Utility function for authenticated API calls // Utility function for authenticated API calls
export const authenticatedFetch = (url, options = {}) => { export const authenticatedFetch = (url, options = {}) => {
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
const token = localStorage.getItem('auth-token'); const token = localStorage.getItem('auth-token');
const defaultHeaders = {}; const defaultHeaders = {};
@@ -10,7 +11,7 @@ export const authenticatedFetch = (url, options = {}) => {
defaultHeaders['Content-Type'] = 'application/json'; defaultHeaders['Content-Type'] = 'application/json';
} }
if (!isPlatform && token) { if (!IS_PLATFORM && token) {
defaultHeaders['Authorization'] = `Bearer ${token}`; defaultHeaders['Authorization'] = `Bearer ${token}`;
} }
@@ -158,6 +159,12 @@ export const api = {
return authenticatedFetch(`/api/browse-filesystem?${params}`); return authenticatedFetch(`/api/browse-filesystem?${params}`);
}, },
createFolder: (folderPath) =>
authenticatedFetch('/api/create-folder', {
method: 'POST',
body: JSON.stringify({ path: folderPath }),
}),
// User endpoints // User endpoints
user: { user: {
gitConfig: () => authenticatedFetch('/api/user/git-config'), gitConfig: () => authenticatedFetch('/api/user/git-config'),

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

View File

@@ -45,4 +45,4 @@ export default defineConfig(({ command, mode }) => {
} }
} }
} }
}) })