mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-02 06:47:34 +00:00
Compare commits
25 Commits
v1.15.0
...
refactor/w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
312654fdc6 | ||
|
|
438b9698cc | ||
|
|
20d31da4f4 | ||
|
|
4f87018e61 | ||
|
|
b2fdb90203 | ||
|
|
8bea3d83c8 | ||
|
|
cfd766819a | ||
|
|
471892b2bd | ||
|
|
eca96c6973 | ||
|
|
5a4813f9bd | ||
|
|
f6970d6ad9 | ||
|
|
e65a210cb3 | ||
|
|
8e9f7f0536 | ||
|
|
51b316f69c | ||
|
|
dc21fb532a | ||
|
|
d9233f60b6 | ||
|
|
430d0ddc4a | ||
|
|
e9719256fc | ||
|
|
55caaf060c | ||
|
|
f9c7321c8c | ||
|
|
88bda6e5c0 | ||
|
|
86b421c790 | ||
|
|
41ef84c283 | ||
|
|
53224e47b6 | ||
|
|
bbb51dbf99 |
37
package-lock.json
generated
37
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.15.0",
|
"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.15.0",
|
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.15.0",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
server/constants/config.js
Normal file
5
server/constants/config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Environment Flag: Is Platform
|
||||||
|
* Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
|
||||||
|
*/
|
||||||
|
export const IS_PLATFORM = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||||
@@ -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, { FORBIDDEN_PATHS } 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
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -556,22 +582,13 @@ app.post('/api/create-folder', authenticateToken, async (req, res) => {
|
|||||||
if (!folderPath) {
|
if (!folderPath) {
|
||||||
return res.status(400).json({ error: 'Path is required' });
|
return res.status(400).json({ error: 'Path is required' });
|
||||||
}
|
}
|
||||||
const homeDir = os.homedir();
|
const expandedPath = expandWorkspacePath(folderPath);
|
||||||
const targetPath = path.resolve(folderPath.replace('~', homeDir));
|
const resolvedInput = path.resolve(expandedPath);
|
||||||
const normalizedPath = path.normalize(targetPath);
|
const validation = await validateWorkspacePath(resolvedInput);
|
||||||
const comparePath = normalizedPath.toLowerCase();
|
if (!validation.valid) {
|
||||||
const forbiddenLower = FORBIDDEN_PATHS.map(p => p.toLowerCase());
|
return res.status(403).json({ error: validation.error });
|
||||||
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 targetPath = validation.resolvedPath || resolvedInput;
|
||||||
const parentDir = path.dirname(targetPath);
|
const parentDir = path.dirname(targetPath);
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(parentDir);
|
await fs.promises.access(parentDir);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function sanitizeGitError(message, token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
export const FORBIDDEN_PATHS = [
|
export const FORBIDDEN_PATHS = [
|
||||||
@@ -48,7 +48,7 @@ export 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);
|
||||||
|
|||||||
@@ -62,4 +62,4 @@ export const CODEX_MODELS = {
|
|||||||
],
|
],
|
||||||
|
|
||||||
DEFAULT: 'gpt-5.2'
|
DEFAULT: 'gpt-5.2'
|
||||||
};
|
};
|
||||||
16
src/App.jsx
16
src/App.jsx
@@ -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)}
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -27,17 +28,15 @@ function LoginModal({
|
|||||||
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 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':
|
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 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';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onProjectCreated) {
|
if (onProjectCreated) {
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
5
src/constants/config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Environment Flag: Is Platform
|
||||||
|
* Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
|
||||||
|
*/
|
||||||
|
export const IS_PLATFORM = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
import 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();
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import React, { createContext, useContext } from 'react';
|
|
||||||
import { useWebSocket } from '../utils/websocket';
|
|
||||||
|
|
||||||
const WebSocketContext = createContext({
|
|
||||||
ws: null,
|
|
||||||
sendMessage: () => {},
|
|
||||||
messages: [],
|
|
||||||
isConnected: false
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useWebSocketContext = () => {
|
|
||||||
const context = useContext(WebSocketContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useWebSocketContext must be used within a WebSocketProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WebSocketProvider = ({ children }) => {
|
|
||||||
const webSocketData = useWebSocket();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WebSocketContext.Provider value={webSocketData}>
|
|
||||||
{children}
|
|
||||||
</WebSocketContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WebSocketContext;
|
|
||||||
125
src/contexts/WebSocketContext.tsx
Normal file
125
src/contexts/WebSocketContext.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
import { IS_PLATFORM } from '../constants/config';
|
||||||
|
|
||||||
|
type WebSocketContextType = {
|
||||||
|
ws: WebSocket | null;
|
||||||
|
sendMessage: (message: any) => void;
|
||||||
|
latestMessage: any | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
||||||
|
|
||||||
|
export const useWebSocket = () => {
|
||||||
|
const context = useContext(WebSocketContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useWebSocket must be used within a WebSocketProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildWebSocketUrl = (token: string | null) => {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
if (IS_PLATFORM) return `${protocol}//${window.location.host}/ws`; // Platform mode: Use same domain as the page (goes through proxy)
|
||||||
|
if (!token) return null;
|
||||||
|
return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; // OSS mode: Use same host:port that served the page
|
||||||
|
};
|
||||||
|
|
||||||
|
const useWebSocketProviderState = (): WebSocketContextType => {
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const unmountedRef = useRef(false); // Track if component is unmounted
|
||||||
|
const [latestMessage, setLatestMessage] = useState<any>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unmountedRef.current = true;
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []); // 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;
|
||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
export function useWebSocket() {
|
|
||||||
const [ws, setWs] = useState(null);
|
|
||||||
const [messages, setMessages] = useState([]);
|
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
|
||||||
const reconnectTimeoutRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
connect();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
}
|
|
||||||
if (ws) {
|
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []); // Keep dependency array but add proper cleanup
|
|
||||||
|
|
||||||
const connect = async () => {
|
|
||||||
try {
|
|
||||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
|
||||||
|
|
||||||
// Construct WebSocket URL
|
|
||||||
let wsUrl;
|
|
||||||
|
|
||||||
if (isPlatform) {
|
|
||||||
// Platform mode: Use same domain as the page (goes through proxy)
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
wsUrl = `${protocol}//${window.location.host}/ws`;
|
|
||||||
} else {
|
|
||||||
// OSS mode: Connect to same host:port that served the page
|
|
||||||
const token = localStorage.getItem('auth-token');
|
|
||||||
if (!token) {
|
|
||||||
console.warn('No authentication token found for WebSocket connection');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const websocket = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
websocket.onopen = () => {
|
|
||||||
setIsConnected(true);
|
|
||||||
setWs(websocket);
|
|
||||||
};
|
|
||||||
|
|
||||||
websocket.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
setMessages(prev => [...prev, data]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing WebSocket message:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
websocket.onclose = () => {
|
|
||||||
setIsConnected(false);
|
|
||||||
setWs(null);
|
|
||||||
|
|
||||||
// Attempt to reconnect after 3 seconds
|
|
||||||
reconnectTimeoutRef.current = setTimeout(() => {
|
|
||||||
connect();
|
|
||||||
}, 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
websocket.onerror = (error) => {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating WebSocket connection:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendMessage = (message) => {
|
|
||||||
if (ws && isConnected) {
|
|
||||||
ws.send(JSON.stringify(message));
|
|
||||||
} else {
|
|
||||||
console.warn('WebSocket not connected');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
ws,
|
|
||||||
sendMessage,
|
|
||||||
messages,
|
|
||||||
isConnected
|
|
||||||
};
|
|
||||||
}
|
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"allowJs": true,
|
||||||
|
// "checkJs": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "shared", "vite.config.js"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user