Compare commits

...

10 Commits

Author SHA1 Message Date
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
28 changed files with 317 additions and 212 deletions

37
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.15.0",
"version": "1.16.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@siteboon/claude-code-ui",
"version": "1.15.0",
"version": "1.16.3",
"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.15.0",
"version": "1.16.3",
"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, { FORBIDDEN_PATHS } 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;
@@ -200,7 +186,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 +470,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 +515,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 +535,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 +552,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
}
res.json({
path: targetPath,
path: resolvedPath,
suggestions: suggestions
});
@@ -556,22 +568,13 @@ app.post('/api/create-folder', authenticateToken, async (req, res) => {
if (!folderPath) {
return res.status(400).json({ error: 'Path is required' });
}
const homeDir = os.homedir();
const targetPath = path.resolve(folderPath.replace('~', homeDir));
const normalizedPath = path.normalize(targetPath);
const comparePath = normalizedPath.toLowerCase();
const forbiddenLower = FORBIDDEN_PATHS.map(p => p.toLowerCase());
if (forbiddenLower.includes(comparePath) || comparePath === '/') {
return res.status(403).json({ error: 'Cannot create folders in system directories' });
}
for (const forbidden of forbiddenLower) {
if (comparePath.startsWith(forbidden + path.sep)) {
if (forbidden === '/var' && (comparePath.startsWith('/var/tmp') || comparePath.startsWith('/var/folders'))) {
continue;
}
return res.status(403).json({ error: `Cannot create folders in system directory: ${forbidden}` });
}
const 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);

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

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

@@ -13,7 +13,7 @@ function sanitizeGitError(message, token) {
}
// 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
export const FORBIDDEN_PATHS = [
@@ -48,7 +48,7 @@ export 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);

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

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

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 setup-token --dangerously-skip-permissions' : 'claude /login --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 setup-token --dangerously-skip-permissions' : 'claude /login --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,
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

@@ -183,7 +183,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
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'));
}
if (onProjectCreated) {

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 {
@@ -55,10 +56,9 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
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 {

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

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

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