Compare commits

...

25 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
25 changed files with 281 additions and 188 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,6 +28,7 @@
"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"
},
@@ -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 = 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 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';
@@ -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
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 +529,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 +549,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 +566,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
}
res.json({
path: targetPath,
path: resolvedPath,
suggestions: suggestions
});
@@ -556,22 +582,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);

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

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

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();
}
};
}, []); // 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

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