Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ece52adac2 | ||
|
|
6c55638397 | ||
|
|
e28d989bee | ||
|
|
b24f5e421f | ||
|
|
6d17e6db81 | ||
|
|
99b204f5bf | ||
|
|
21d9242d50 | ||
|
|
5e4be4d113 | ||
|
|
46d119685d | ||
|
|
12dccbf224 | ||
|
|
2a0e4c58c2 | ||
|
|
a01d6c91e0 | ||
|
|
8774aa4289 | ||
|
|
5ba62a2b7b | ||
|
|
23c50f8fef | ||
|
|
8b40f9f177 | ||
|
|
51f935f6d8 | ||
|
|
f6408c5106 | ||
|
|
41be9a4f63 | ||
|
|
0a9c2484d6 | ||
|
|
2d912eeb64 | ||
|
|
952aeab70a | ||
|
|
95644fd457 | ||
|
|
42a80748af | ||
|
|
7031d9437c | ||
|
|
2ff59bd28c | ||
|
|
7fd63d83ac | ||
|
|
6db8be5f54 | ||
|
|
32481356cb | ||
|
|
3e0c23b7e2 | ||
|
|
22fa724503 | ||
|
|
67339b0e4b | ||
|
|
4fcf27bd13 | ||
|
|
4de2f5026e | ||
|
|
9cfccc04f3 | ||
|
|
7f4feb182e | ||
|
|
d36890be52 | ||
|
|
c925742df1 | ||
|
|
33aea3f7e8 | ||
|
|
f28dc0140e | ||
|
|
2ca929e5e5 | ||
|
|
36d0add224 | ||
|
|
7db22fae29 | ||
|
|
b808ca1b68 | ||
|
|
62ad40ad71 | ||
|
|
a8f212bff8 | ||
|
|
9cf0173bc9 | ||
|
|
c0d8241f3d | ||
|
|
23e5f7ac2d |
@@ -7,6 +7,6 @@
|
||||
|
||||
# Backend server port (Express API + WebSocket server)
|
||||
#API server
|
||||
PORT=3008
|
||||
PORT=3001
|
||||
#Frontend port
|
||||
VITE_PORT=3009
|
||||
VITE_PORT=5173
|
||||
0
index.html
Executable file → Normal file
1624
package-lock.json
generated
Executable file → Normal file
8
package.json
Executable file → Normal file
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-ui",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
@@ -22,7 +22,6 @@
|
||||
"author": "Claude Code UI Contributors",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^1.0.24",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -40,13 +39,14 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.515.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"node-pty": "^1.1.0-beta34",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
@@ -68,4 +68,4 @@
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
0
public/convert-icons.md
Executable file → Normal file
0
public/favicon.png
Executable file → Normal file
|
Before Width: | Height: | Size: 281 B After Width: | Height: | Size: 281 B |
0
public/favicon.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 504 B After Width: | Height: | Size: 504 B |
0
public/icons/claude-ai-icon.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
0
public/icons/generate-icons.md
Executable file → Normal file
0
public/icons/icon-128x128.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-128x128.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-144x144.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-144x144.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-152x152.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-152x152.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-192x192.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-192x192.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-384x384.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-384x384.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-512x512.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-512x512.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-72x72.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-72x72.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-96x96.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-96x96.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-template.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/logo.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 422 B |
0
public/manifest.json
Executable file → Normal file
0
public/screenshots/desktop-main.png
Executable file → Normal file
|
Before Width: | Height: | Size: 385 KiB After Width: | Height: | Size: 385 KiB |
0
public/screenshots/mobile-chat.png
Executable file → Normal file
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 192 KiB |
0
public/screenshots/tools-modal.png
Executable file → Normal file
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 295 KiB |
@@ -1,8 +1,12 @@
|
||||
import { spawn } from 'child_process';
|
||||
import crossSpawn from 'cross-spawn';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Use cross-spawn on Windows for better command execution
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
|
||||
let activeClaudeProcesses = new Map(); // Track active processes by session ID
|
||||
|
||||
async function spawnClaude(command, options = {}, ws) {
|
||||
@@ -23,7 +27,11 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
|
||||
// Add print flag with command if we have a command
|
||||
if (command && command.trim()) {
|
||||
args.push('--print', command);
|
||||
|
||||
// Separate arguments for better cross-platform compatibility
|
||||
// This prevents issues with spaces and quotes on Windows
|
||||
args.push('--print');
|
||||
args.push(command);
|
||||
}
|
||||
|
||||
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
|
||||
@@ -63,9 +71,9 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
|
||||
const modifiedCommand = command + imageNote;
|
||||
|
||||
// Update the command in args
|
||||
// Update the command in args - now that --print and command are separate
|
||||
const printIndex = args.indexOf('--print');
|
||||
if (printIndex !== -1 && args[printIndex + 1] === command) {
|
||||
if (printIndex !== -1 && printIndex + 1 < args.length && args[printIndex + 1] === command) {
|
||||
args[printIndex + 1] = modifiedCommand;
|
||||
}
|
||||
}
|
||||
@@ -227,7 +235,7 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
console.log('🔍 Full command args:', JSON.stringify(args, null, 2));
|
||||
console.log('🔍 Final Claude command will be: claude ' + args.join(' '));
|
||||
|
||||
const claudeProcess = spawn('claude', args, {
|
||||
const claudeProcess = spawnFunction('claude', args, {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env } // Inherit all environment variables
|
||||
@@ -380,4 +388,4 @@ function abortClaudeSession(sessionId) {
|
||||
export {
|
||||
spawnClaude,
|
||||
abortClaudeSession
|
||||
};
|
||||
};
|
||||
|
||||
1618
server/index.js
@@ -53,13 +53,8 @@ async function generateDisplayName(projectName, actualProjectDir = null) {
|
||||
// If it starts with /, it's an absolute path
|
||||
if (projectPath.startsWith('/')) {
|
||||
const parts = projectPath.split('/').filter(Boolean);
|
||||
if (parts.length > 3) {
|
||||
// Show last 2 folders with ellipsis: "...projects/myapp"
|
||||
return `.../${parts.slice(-2).join('/')}`;
|
||||
} else {
|
||||
// Show full path if short: "/home/user"
|
||||
return projectPath;
|
||||
}
|
||||
// Return only the last folder name
|
||||
return parts[parts.length - 1] || projectPath;
|
||||
}
|
||||
|
||||
return projectPath;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { userDb } from '../database/db.js';
|
||||
import { userDb, db } from '../database/db.js';
|
||||
import { generateToken, authenticateToken } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -33,31 +33,41 @@ router.post('/register', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' });
|
||||
}
|
||||
|
||||
// Check if users already exist (only allow one user)
|
||||
const hasUsers = userDb.hasUsers();
|
||||
if (hasUsers) {
|
||||
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
|
||||
// Use a transaction to prevent race conditions
|
||||
db.prepare('BEGIN').run();
|
||||
try {
|
||||
// Check if users already exist (only allow one user)
|
||||
const hasUsers = userDb.hasUsers();
|
||||
if (hasUsers) {
|
||||
db.prepare('ROLLBACK').run();
|
||||
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const saltRounds = 12;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// Create user
|
||||
const user = userDb.createUser(username, passwordHash);
|
||||
|
||||
// Generate token
|
||||
const token = generateToken(user);
|
||||
|
||||
// Update last login
|
||||
userDb.updateLastLogin(user.id);
|
||||
|
||||
db.prepare('COMMIT').run();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: { id: user.id, username: user.username },
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
db.prepare('ROLLBACK').run();
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const saltRounds = 12;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// Create user
|
||||
const user = userDb.createUser(username, passwordHash);
|
||||
|
||||
// Generate token
|
||||
const token = generateToken(user);
|
||||
|
||||
// Update last login
|
||||
userDb.updateLastLogin(user.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: { id: user.id, username: user.username },
|
||||
token
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
|
||||
@@ -444,10 +444,25 @@ router.get('/remote-status', async (req, res) => {
|
||||
trackingBranch = stdout.trim();
|
||||
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
||||
} catch (error) {
|
||||
// No upstream branch configured
|
||||
// No upstream branch configured - but check if we have remotes
|
||||
let hasRemote = false;
|
||||
let remoteName = null;
|
||||
try {
|
||||
const { stdout } = await execAsync('git remote', { cwd: projectPath });
|
||||
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
||||
if (remotes.length > 0) {
|
||||
hasRemote = true;
|
||||
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
||||
}
|
||||
} catch (remoteError) {
|
||||
// No remotes configured
|
||||
}
|
||||
|
||||
return res.json({
|
||||
hasRemote: false,
|
||||
hasRemote,
|
||||
hasUpstream: false,
|
||||
branch,
|
||||
remoteName,
|
||||
message: 'No remote tracking branch configured'
|
||||
});
|
||||
}
|
||||
@@ -462,6 +477,7 @@ router.get('/remote-status', async (req, res) => {
|
||||
|
||||
res.json({
|
||||
hasRemote: true,
|
||||
hasUpstream: true,
|
||||
branch,
|
||||
remoteBranch: trackingBranch,
|
||||
remoteName,
|
||||
@@ -653,6 +669,82 @@ router.post('/push', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Publish branch to remote (set upstream and push)
|
||||
router.post('/publish', async (req, res) => {
|
||||
const { project, branch } = req.body;
|
||||
|
||||
if (!project || !branch) {
|
||||
return res.status(400).json({ error: 'Project name and branch are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch to verify it matches the requested branch
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const currentBranchName = currentBranch.trim();
|
||||
|
||||
if (currentBranchName !== branch) {
|
||||
return res.status(400).json({
|
||||
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
|
||||
});
|
||||
}
|
||||
|
||||
// Check if remote exists
|
||||
let remoteName = 'origin';
|
||||
try {
|
||||
const { stdout } = await execAsync('git remote', { cwd: projectPath });
|
||||
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
||||
if (remotes.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
});
|
||||
}
|
||||
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
});
|
||||
}
|
||||
|
||||
// Publish the branch (set upstream and push)
|
||||
const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Branch published successfully',
|
||||
remoteName,
|
||||
branch
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git publish error:', error);
|
||||
|
||||
// Enhanced error handling for common publish scenarios
|
||||
let errorMessage = 'Publish failed';
|
||||
let details = error.message;
|
||||
|
||||
if (error.message.includes('rejected')) {
|
||||
errorMessage = 'Publish rejected';
|
||||
details = 'The remote branch already exists and has different commits. Use push instead.';
|
||||
} else if (error.message.includes('Could not resolve hostname')) {
|
||||
errorMessage = 'Network error';
|
||||
details = 'Unable to connect to remote repository. Check your internet connection.';
|
||||
} else if (error.message.includes('Permission denied')) {
|
||||
errorMessage = 'Authentication failed';
|
||||
details = 'Permission denied. Check your credentials or SSH keys.';
|
||||
} else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) {
|
||||
errorMessage = 'Remote not configured';
|
||||
details = 'Remote repository not properly configured. Check your remote URL.';
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: errorMessage,
|
||||
details: details
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Discard changes for a specific file
|
||||
router.post('/discard', async (req, res) => {
|
||||
const { project, file } = req.body;
|
||||
@@ -692,4 +784,39 @@ router.post('/discard', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Delete untracked file
|
||||
router.post('/delete-untracked', async (req, res) => {
|
||||
const { project, file } = req.body;
|
||||
|
||||
if (!project || !file) {
|
||||
return res.status(400).json({ error: 'Project name and file path are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Check if file is actually untracked
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
|
||||
if (!statusOutput.trim()) {
|
||||
return res.status(400).json({ error: 'File is not untracked or does not exist' });
|
||||
}
|
||||
|
||||
const status = statusOutput.substring(0, 2);
|
||||
|
||||
if (status !== '??') {
|
||||
return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
|
||||
}
|
||||
|
||||
// Delete the untracked file
|
||||
await fs.unlink(path.join(projectPath, file));
|
||||
|
||||
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
|
||||
} catch (error) {
|
||||
console.error('Git delete untracked error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -21,7 +21,7 @@ router.get('/cli/list', async (req, res) => {
|
||||
const { promisify } = await import('util');
|
||||
const exec = promisify(spawn);
|
||||
|
||||
const process = spawn('claude', ['mcp', 'list', '-s', 'user'], {
|
||||
const process = spawn('claude', ['mcp', 'list'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
@@ -58,29 +58,32 @@ router.get('/cli/list', async (req, res) => {
|
||||
// POST /api/mcp/cli/add - Add MCP server using Claude CLI
|
||||
router.post('/cli/add', async (req, res) => {
|
||||
try {
|
||||
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {} } = req.body;
|
||||
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {}, scope = 'user', projectPath } = req.body;
|
||||
|
||||
console.log('➕ Adding MCP server using Claude CLI:', name);
|
||||
console.log(`➕ Adding MCP server using Claude CLI (${scope} scope):`, name);
|
||||
|
||||
const { spawn } = await import('child_process');
|
||||
|
||||
let cliArgs = ['mcp', 'add'];
|
||||
|
||||
// Add scope flag
|
||||
cliArgs.push('--scope', scope);
|
||||
|
||||
if (type === 'http') {
|
||||
cliArgs.push('--transport', 'http', name, '-s', 'user', url);
|
||||
cliArgs.push('--transport', 'http', name, url);
|
||||
// Add headers if provided
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
cliArgs.push('--header', `${key}: ${value}`);
|
||||
});
|
||||
} else if (type === 'sse') {
|
||||
cliArgs.push('--transport', 'sse', name, '-s', 'user', url);
|
||||
cliArgs.push('--transport', 'sse', name, url);
|
||||
// Add headers if provided
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
cliArgs.push('--header', `${key}: ${value}`);
|
||||
});
|
||||
} else {
|
||||
// stdio (default): claude mcp add <name> -s user <command> [args...]
|
||||
cliArgs.push(name, '-s', 'user');
|
||||
// stdio (default): claude mcp add --scope user <name> <command> [args...]
|
||||
cliArgs.push(name);
|
||||
// Add environment variables
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
cliArgs.push('-e', `${key}=${value}`);
|
||||
@@ -93,9 +96,17 @@ router.post('/cli/add', async (req, res) => {
|
||||
|
||||
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
|
||||
|
||||
const process = spawn('claude', cliArgs, {
|
||||
// For local scope, we need to run the command in the project directory
|
||||
const spawnOptions = {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
};
|
||||
|
||||
if (scope === 'local' && projectPath) {
|
||||
spawnOptions.cwd = projectPath;
|
||||
console.log('📁 Running in project directory:', projectPath);
|
||||
}
|
||||
|
||||
const process = spawn('claude', cliArgs, spawnOptions);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
@@ -127,16 +138,136 @@ router.post('/cli/add', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/mcp/cli/add-json - Add MCP server using JSON format
|
||||
router.post('/cli/add-json', async (req, res) => {
|
||||
try {
|
||||
const { name, jsonConfig, scope = 'user', projectPath } = req.body;
|
||||
|
||||
console.log('➕ Adding MCP server using JSON format:', name);
|
||||
|
||||
// Validate and parse JSON config
|
||||
let parsedConfig;
|
||||
try {
|
||||
parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
|
||||
} catch (parseError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid JSON configuration',
|
||||
details: parseError.message
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!parsedConfig.type) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid configuration',
|
||||
details: 'Missing required field: type'
|
||||
});
|
||||
}
|
||||
|
||||
if (parsedConfig.type === 'stdio' && !parsedConfig.command) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid configuration',
|
||||
details: 'stdio type requires a command field'
|
||||
});
|
||||
}
|
||||
|
||||
if ((parsedConfig.type === 'http' || parsedConfig.type === 'sse') && !parsedConfig.url) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid configuration',
|
||||
details: `${parsedConfig.type} type requires a url field`
|
||||
});
|
||||
}
|
||||
|
||||
const { spawn } = await import('child_process');
|
||||
|
||||
// Build the command: claude mcp add-json --scope <scope> <name> '<json>'
|
||||
const cliArgs = ['mcp', 'add-json', '--scope', scope, name];
|
||||
|
||||
// Add the JSON config as a properly formatted string
|
||||
const jsonString = JSON.stringify(parsedConfig);
|
||||
cliArgs.push(jsonString);
|
||||
|
||||
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs[0], cliArgs[1], cliArgs[2], cliArgs[3], cliArgs[4], jsonString);
|
||||
|
||||
// For local scope, we need to run the command in the project directory
|
||||
const spawnOptions = {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
};
|
||||
|
||||
if (scope === 'local' && projectPath) {
|
||||
spawnOptions.cwd = projectPath;
|
||||
console.log('📁 Running in project directory:', projectPath);
|
||||
}
|
||||
|
||||
const process = spawn('claude', cliArgs, spawnOptions);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully via JSON` });
|
||||
} else {
|
||||
console.error('Claude CLI error:', stderr);
|
||||
res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (error) => {
|
||||
console.error('Error running Claude CLI:', error);
|
||||
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding MCP server via JSON:', error);
|
||||
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/mcp/cli/remove/:name - Remove MCP server using Claude CLI
|
||||
router.delete('/cli/remove/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const { scope } = req.query; // Get scope from query params
|
||||
|
||||
console.log('🗑️ Removing MCP server using Claude CLI:', name);
|
||||
// Handle the ID format (remove scope prefix if present)
|
||||
let actualName = name;
|
||||
let actualScope = scope;
|
||||
|
||||
// If the name includes a scope prefix like "local:test", extract it
|
||||
if (name.includes(':')) {
|
||||
const [prefix, serverName] = name.split(':');
|
||||
actualName = serverName;
|
||||
actualScope = actualScope || prefix; // Use prefix as scope if not provided in query
|
||||
}
|
||||
|
||||
console.log('🗑️ Removing MCP server using Claude CLI:', actualName, 'scope:', actualScope);
|
||||
|
||||
const { spawn } = await import('child_process');
|
||||
|
||||
const process = spawn('claude', ['mcp', 'remove', '-s', 'user', name], {
|
||||
// Build command args based on scope
|
||||
let cliArgs = ['mcp', 'remove'];
|
||||
|
||||
// Add scope flag if it's local scope
|
||||
if (actualScope === 'local') {
|
||||
cliArgs.push('--scope', 'local');
|
||||
} else if (actualScope === 'user' || !actualScope) {
|
||||
// User scope is default, but we can be explicit
|
||||
cliArgs.push('--scope', 'user');
|
||||
}
|
||||
|
||||
cliArgs.push(actualName);
|
||||
|
||||
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
|
||||
|
||||
const process = spawn('claude', cliArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
@@ -179,7 +310,7 @@ router.get('/cli/get/:name', async (req, res) => {
|
||||
|
||||
const { spawn } = await import('child_process');
|
||||
|
||||
const process = spawn('claude', ['mcp', 'get', '-s', 'user', name], {
|
||||
const process = spawn('claude', ['mcp', 'get', name], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
@@ -213,37 +344,172 @@ router.get('/cli/get/:name', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/mcp/config/read - Read MCP servers directly from Claude config files
|
||||
router.get('/config/read', async (req, res) => {
|
||||
try {
|
||||
console.log('📖 Reading MCP servers from Claude config files');
|
||||
|
||||
const homeDir = os.homedir();
|
||||
const configPaths = [
|
||||
path.join(homeDir, '.claude.json'),
|
||||
path.join(homeDir, '.claude', 'settings.json')
|
||||
];
|
||||
|
||||
let configData = null;
|
||||
let configPath = null;
|
||||
|
||||
// Try to read from either config file
|
||||
for (const filepath of configPaths) {
|
||||
try {
|
||||
const fileContent = await fs.readFile(filepath, 'utf8');
|
||||
configData = JSON.parse(fileContent);
|
||||
configPath = filepath;
|
||||
console.log(`✅ Found Claude config at: ${filepath}`);
|
||||
break;
|
||||
} catch (error) {
|
||||
// File doesn't exist or is not valid JSON, try next
|
||||
console.log(`ℹ️ Config not found or invalid at: ${filepath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!configData) {
|
||||
return res.json({
|
||||
success: false,
|
||||
message: 'No Claude configuration file found',
|
||||
servers: []
|
||||
});
|
||||
}
|
||||
|
||||
// Extract MCP servers from the config
|
||||
const servers = [];
|
||||
|
||||
// Check for user-scoped MCP servers (at root level)
|
||||
if (configData.mcpServers && typeof configData.mcpServers === 'object' && Object.keys(configData.mcpServers).length > 0) {
|
||||
console.log('🔍 Found user-scoped MCP servers:', Object.keys(configData.mcpServers));
|
||||
for (const [name, config] of Object.entries(configData.mcpServers)) {
|
||||
const server = {
|
||||
id: name,
|
||||
name: name,
|
||||
type: 'stdio', // Default type
|
||||
scope: 'user', // User scope - available across all projects
|
||||
config: {},
|
||||
raw: config // Include raw config for full details
|
||||
};
|
||||
|
||||
// Determine transport type and extract config
|
||||
if (config.command) {
|
||||
server.type = 'stdio';
|
||||
server.config.command = config.command;
|
||||
server.config.args = config.args || [];
|
||||
server.config.env = config.env || {};
|
||||
} else if (config.url) {
|
||||
server.type = config.transport || 'http';
|
||||
server.config.url = config.url;
|
||||
server.config.headers = config.headers || {};
|
||||
}
|
||||
|
||||
servers.push(server);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for local-scoped MCP servers (project-specific)
|
||||
const currentProjectPath = process.cwd();
|
||||
|
||||
// Check under 'projects' key
|
||||
if (configData.projects && configData.projects[currentProjectPath]) {
|
||||
const projectConfig = configData.projects[currentProjectPath];
|
||||
if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object' && Object.keys(projectConfig.mcpServers).length > 0) {
|
||||
console.log(`🔍 Found local-scoped MCP servers for ${currentProjectPath}:`, Object.keys(projectConfig.mcpServers));
|
||||
for (const [name, config] of Object.entries(projectConfig.mcpServers)) {
|
||||
const server = {
|
||||
id: `local:${name}`, // Prefix with scope for uniqueness
|
||||
name: name, // Keep original name
|
||||
type: 'stdio', // Default type
|
||||
scope: 'local', // Local scope - only for this project
|
||||
projectPath: currentProjectPath,
|
||||
config: {},
|
||||
raw: config // Include raw config for full details
|
||||
};
|
||||
|
||||
// Determine transport type and extract config
|
||||
if (config.command) {
|
||||
server.type = 'stdio';
|
||||
server.config.command = config.command;
|
||||
server.config.args = config.args || [];
|
||||
server.config.env = config.env || {};
|
||||
} else if (config.url) {
|
||||
server.type = config.transport || 'http';
|
||||
server.config.url = config.url;
|
||||
server.config.headers = config.headers || {};
|
||||
}
|
||||
|
||||
servers.push(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📋 Found ${servers.length} MCP servers in config`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
configPath: configPath,
|
||||
servers: servers
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reading Claude config:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to read Claude configuration',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions to parse Claude CLI output
|
||||
function parseClaudeListOutput(output) {
|
||||
// Parse the output from 'claude mcp list' command
|
||||
// Format: "name: command/url" or "name: url (TYPE)"
|
||||
const servers = [];
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip the header line
|
||||
if (line.includes('Checking MCP server health')) continue;
|
||||
|
||||
// Parse lines like "test: test test - ✗ Failed to connect"
|
||||
// or "server-name: command or description - ✓ Connected"
|
||||
if (line.includes(':')) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
|
||||
// Skip empty names
|
||||
if (!name) continue;
|
||||
|
||||
// Extract the rest after the name
|
||||
const rest = line.substring(colonIndex + 1).trim();
|
||||
|
||||
// Try to extract description and status
|
||||
let description = rest;
|
||||
let status = 'unknown';
|
||||
let type = 'stdio'; // default type
|
||||
|
||||
// Check if it has transport type in parentheses like "(SSE)" or "(HTTP)"
|
||||
const typeMatch = rest.match(/\((\w+)\)\s*$/);
|
||||
if (typeMatch) {
|
||||
type = typeMatch[1].toLowerCase();
|
||||
} else if (rest.startsWith('http://') || rest.startsWith('https://')) {
|
||||
// If it's a URL but no explicit type, assume HTTP
|
||||
// Check for status indicators
|
||||
if (rest.includes('✓') || rest.includes('✗')) {
|
||||
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
|
||||
if (statusMatch) {
|
||||
description = statusMatch[1].trim();
|
||||
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
// Try to determine type from description
|
||||
if (description.startsWith('http://') || description.startsWith('https://')) {
|
||||
type = 'http';
|
||||
}
|
||||
|
||||
if (name) {
|
||||
servers.push({
|
||||
name,
|
||||
type,
|
||||
status: 'active'
|
||||
});
|
||||
}
|
||||
servers.push({
|
||||
name,
|
||||
type,
|
||||
status: status || 'active',
|
||||
description
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
src/App.jsx
Executable file → Normal file
@@ -64,6 +64,10 @@ function AppContent() {
|
||||
const saved = localStorage.getItem('autoScrollToBottom');
|
||||
return saved !== null ? JSON.parse(saved) : true;
|
||||
});
|
||||
const [sendByCtrlEnter, setSendByCtrlEnter] = useState(() => {
|
||||
const saved = localStorage.getItem('sendByCtrlEnter');
|
||||
return saved !== null ? JSON.parse(saved) : false;
|
||||
});
|
||||
// Session Protection System: Track sessions with active conversations to prevent
|
||||
// automatic project updates from interrupting ongoing chats. When a user sends
|
||||
// a message, the session is marked as "active" and project updates are paused
|
||||
@@ -586,6 +590,7 @@ function AppContent() {
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -617,6 +622,11 @@ function AppContent() {
|
||||
setAutoScrollToBottom(value);
|
||||
localStorage.setItem('autoScrollToBottom', JSON.stringify(value));
|
||||
}}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
onSendByCtrlEnterChange={(value) => {
|
||||
setSendByCtrlEnter(value);
|
||||
localStorage.setItem('sendByCtrlEnter', JSON.stringify(value));
|
||||
}}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
@@ -625,6 +635,7 @@ function AppContent() {
|
||||
<ToolsSettings
|
||||
isOpen={showToolsSettings}
|
||||
onClose={() => setShowToolsSettings(false)}
|
||||
projects={projects}
|
||||
/>
|
||||
|
||||
{/* Version Upgrade Modal */}
|
||||
|
||||
284
src/components/ChatInterface.jsx
Executable file → Normal file
@@ -26,6 +26,88 @@ import ClaudeStatus from './ClaudeStatus';
|
||||
import { MicButton } from './MicButton.jsx';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
// Safe localStorage utility to handle quota exceeded errors
|
||||
const safeLocalStorage = {
|
||||
setItem: (key, value) => {
|
||||
try {
|
||||
// For chat messages, implement compression and size limits
|
||||
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
// Limit to last 50 messages to prevent storage bloat
|
||||
if (Array.isArray(parsed) && parsed.length > 50) {
|
||||
console.warn(`Truncating chat history for ${key} from ${parsed.length} to 50 messages`);
|
||||
const truncated = parsed.slice(-50);
|
||||
value = JSON.stringify(truncated);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Could not parse chat messages for truncation:', parseError);
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
if (error.name === 'QuotaExceededError') {
|
||||
console.warn('localStorage quota exceeded, clearing old data');
|
||||
// Clear old chat messages to free up space
|
||||
const keys = Object.keys(localStorage);
|
||||
const chatKeys = keys.filter(k => k.startsWith('chat_messages_')).sort();
|
||||
|
||||
// Remove oldest chat data first, keeping only the 3 most recent projects
|
||||
if (chatKeys.length > 3) {
|
||||
chatKeys.slice(0, chatKeys.length - 3).forEach(k => {
|
||||
localStorage.removeItem(k);
|
||||
console.log(`Removed old chat data: ${k}`);
|
||||
});
|
||||
}
|
||||
|
||||
// If still failing, clear draft inputs too
|
||||
const draftKeys = keys.filter(k => k.startsWith('draft_input_'));
|
||||
draftKeys.forEach(k => {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
|
||||
// Try again with reduced data
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch (retryError) {
|
||||
console.error('Failed to save to localStorage even after cleanup:', retryError);
|
||||
// Last resort: Try to save just the last 10 messages
|
||||
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed) && parsed.length > 10) {
|
||||
const minimal = parsed.slice(-10);
|
||||
localStorage.setItem(key, JSON.stringify(minimal));
|
||||
console.warn('Saved only last 10 messages due to quota constraints');
|
||||
}
|
||||
} catch (finalError) {
|
||||
console.error('Final save attempt failed:', finalError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('localStorage error:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
getItem: (key) => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (error) {
|
||||
console.error('localStorage getItem error:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
removeItem: (key) => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('localStorage removeItem error:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Memoized message component to prevent unnecessary re-renders
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
@@ -118,7 +200,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
|
||||
<div className="w-full">
|
||||
|
||||
{message.isToolUse ? (
|
||||
{message.isToolUse && !['Read', 'TodoWrite', 'TodoRead'].includes(message.toolName) ? (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-2 sm:p-3 mb-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -423,41 +505,18 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.file_path) {
|
||||
// Extract filename
|
||||
const filename = input.file_path.split('/').pop();
|
||||
const pathParts = input.file_path.split('/');
|
||||
const directoryPath = pathParts.slice(0, -1).join('/');
|
||||
|
||||
// Simple heuristic to show only relevant path parts
|
||||
// Show the last 2-3 directory parts before the filename
|
||||
const relevantParts = pathParts.slice(-4, -1); // Get up to 3 directories before filename
|
||||
const relativePath = relevantParts.length > 0 ? relevantParts.join('/') + '/' : '';
|
||||
|
||||
return (
|
||||
<details className="mt-2" open={autoExpandTools}>
|
||||
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-1">
|
||||
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span className="text-gray-600 dark:text-gray-400 font-mono text-xs">{relativePath}</span>
|
||||
<span className="font-semibold text-blue-700 dark:text-blue-300 font-mono">{filename}</span>
|
||||
</summary>
|
||||
{showRawParameters && (
|
||||
<div className="mt-3">
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
|
||||
View raw parameters
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-blue-100 dark:bg-blue-800/30 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-blue-900 dark:text-blue-100">
|
||||
{message.toolInput}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
Read{' '}
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(input.file_path)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
||||
>
|
||||
{filename}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -882,6 +941,61 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : message.isToolUse && message.toolName === 'Read' ? (
|
||||
// Simple Read tool indicator
|
||||
(() => {
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.file_path) {
|
||||
const filename = input.file_path.split('/').pop();
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
📖 Read{' '}
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(input.file_path)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
||||
>
|
||||
{filename}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
📖 Read file
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()
|
||||
) : message.isToolUse && message.toolName === 'TodoWrite' ? (
|
||||
// Simple TodoWrite tool indicator with tasks
|
||||
(() => {
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.todos && Array.isArray(input.todos)) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2">
|
||||
<div className="text-sm text-blue-700 dark:text-blue-300 mb-2">
|
||||
📝 Update todo list
|
||||
</div>
|
||||
<TodoList todos={input.todos} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
📝 Update todo list
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()
|
||||
) : message.isToolUse && message.toolName === 'TodoRead' ? (
|
||||
// Simple TodoRead tool indicator
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
📋 Read todo list
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{message.type === 'assistant' ? (
|
||||
@@ -984,16 +1098,16 @@ 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, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom }) {
|
||||
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom, sendByCtrlEnter }) {
|
||||
const [input, setInput] = useState(() => {
|
||||
if (typeof window !== 'undefined' && selectedProject) {
|
||||
return localStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const [chatMessages, setChatMessages] = useState(() => {
|
||||
if (typeof window !== 'undefined' && selectedProject) {
|
||||
const saved = localStorage.getItem(`chat_messages_${selectedProject.name}`);
|
||||
const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`);
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
}
|
||||
return [];
|
||||
@@ -1278,16 +1392,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Persist input draft to localStorage
|
||||
useEffect(() => {
|
||||
if (selectedProject && input !== '') {
|
||||
localStorage.setItem(`draft_input_${selectedProject.name}`, input);
|
||||
safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input);
|
||||
} else if (selectedProject && input === '') {
|
||||
localStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
}
|
||||
}, [input, selectedProject]);
|
||||
|
||||
// Persist chat messages to localStorage
|
||||
useEffect(() => {
|
||||
if (selectedProject && chatMessages.length > 0) {
|
||||
localStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
|
||||
safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
|
||||
}
|
||||
}, [chatMessages, selectedProject]);
|
||||
|
||||
@@ -1295,7 +1409,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
// Always load saved input draft for the project
|
||||
const savedInput = localStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
if (savedInput !== input) {
|
||||
setInput(savedInput);
|
||||
}
|
||||
@@ -1400,19 +1514,45 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
toolResult: null // Will be updated when result comes in
|
||||
}]);
|
||||
} else if (part.type === 'text' && part.text?.trim()) {
|
||||
// Check for usage limit message and format it user-friendly
|
||||
let content = part.text;
|
||||
if (content.includes('Claude AI usage limit reached|')) {
|
||||
const parts = content.split('|');
|
||||
if (parts.length === 2) {
|
||||
const timestamp = parseInt(parts[1]);
|
||||
if (!isNaN(timestamp)) {
|
||||
const resetTime = new Date(timestamp * 1000);
|
||||
content = `Claude AI usage limit reached. The limit will reset on ${resetTime.toLocaleDateString()} at ${resetTime.toLocaleTimeString()}.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add regular text message
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
content: part.text,
|
||||
content: content,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
}
|
||||
} else if (typeof messageData.content === 'string' && messageData.content.trim()) {
|
||||
// Check for usage limit message and format it user-friendly
|
||||
let content = messageData.content;
|
||||
if (content.includes('Claude AI usage limit reached|')) {
|
||||
const parts = content.split('|');
|
||||
if (parts.length === 2) {
|
||||
const timestamp = parseInt(parts[1]);
|
||||
if (!isNaN(timestamp)) {
|
||||
const resetTime = new Date(timestamp * 1000);
|
||||
content = `Claude AI usage limit reached. The limit will reset on ${resetTime.toLocaleDateString()} at ${resetTime.toLocaleTimeString()}.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add regular text message
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
content: messageData.content,
|
||||
content: content,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
@@ -1488,7 +1628,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
// Clear persisted chat messages after successful completion
|
||||
if (selectedProject && latestMessage.exitCode === 0) {
|
||||
localStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
||||
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1745,14 +1885,33 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Handle image files from drag & drop or file picker
|
||||
const handleImageFiles = useCallback((files) => {
|
||||
const validFiles = files.filter(file => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
try {
|
||||
// Validate file object and properties
|
||||
if (!file || typeof file !== 'object') {
|
||||
console.warn('Invalid file object:', file);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file.type || !file.type.startsWith('image/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file.size || file.size > 5 * 1024 * 1024) {
|
||||
// Safely get file name with fallback
|
||||
const fileName = file.name || 'Unknown file';
|
||||
setImageErrors(prev => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(fileName, 'File too large (max 5MB)');
|
||||
return newMap;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error validating file:', error, file);
|
||||
return false;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setImageErrors(prev => new Map(prev).set(file.name, 'File too large (max 5MB)'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
@@ -1808,7 +1967,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
});
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const token = safeLocalStorage.getItem('auth-token');
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
@@ -1871,7 +2030,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Get tools settings from localStorage
|
||||
const getToolsSettings = () => {
|
||||
try {
|
||||
const savedSettings = localStorage.getItem('claude-tools-settings');
|
||||
const savedSettings = safeLocalStorage.getItem('claude-tools-settings');
|
||||
if (savedSettings) {
|
||||
return JSON.parse(savedSettings);
|
||||
}
|
||||
@@ -1917,7 +2076,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
// Clear the saved draft since message was sent
|
||||
if (selectedProject) {
|
||||
localStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1966,14 +2125,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
// Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line
|
||||
if (e.key === 'Enter') {
|
||||
// If we're in composition, don't send message
|
||||
if (e.nativeEvent.isComposing) {
|
||||
return; // Let IME handle the Enter key
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
// Ctrl+Enter or Cmd+Enter: Send message
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
} else if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
// Plain Enter: Also send message (keeping original behavior)
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
// Plain Enter: Send message only if not in IME composition
|
||||
if (!sendByCtrlEnter) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}
|
||||
// Shift+Enter: Allow default behavior (new line)
|
||||
}
|
||||
@@ -2404,12 +2570,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
</div>
|
||||
{/* Hint text */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
|
||||
Press Enter to send • Shift+Enter for new line • Tab to change modes • @ to reference files
|
||||
{sendByCtrlEnter
|
||||
? "Ctrl+Enter to send (IME safe) • Shift+Enter for new line • Tab to change modes • @ to reference files"
|
||||
: "Press Enter to send • Shift+Enter for new line • Tab to change modes • @ to reference files"}
|
||||
</div>
|
||||
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${
|
||||
isInputFocused ? 'opacity-100' : 'opacity-0'
|
||||
}`}>
|
||||
Enter to send • Tab for modes • @ for files
|
||||
{sendByCtrlEnter
|
||||
? "Ctrl+Enter to send (IME safe) • Tab for modes • @ for files"
|
||||
: "Enter to send • Tab for modes • @ for files"}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
0
src/components/ClaudeLogo.jsx
Executable file → Normal file
0
src/components/ClaudeStatus.jsx
Executable file → Normal file
0
src/components/CodeEditor.jsx
Executable file → Normal file
0
src/components/DarkModeToggle.jsx
Executable file → Normal file
73
src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
// Log the error details
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// You can also log the error to an error reporting service here
|
||||
this.setState({
|
||||
error: error,
|
||||
errorInfo: errorInfo
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Fallback UI
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="ml-3 text-sm font-medium text-red-800">
|
||||
Something went wrong
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-sm text-red-700">
|
||||
<p className="mb-2">An error occurred while loading the chat interface.</p>
|
||||
{this.props.showDetails && this.state.error && (
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
|
||||
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
|
||||
{this.state.error.toString()}
|
||||
{this.state.errorInfo && this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
this.setState({ hasError: false, error: null, errorInfo: null });
|
||||
if (this.props.onRetry) this.props.onRetry();
|
||||
}}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
0
src/components/FileTree.jsx
Executable file → Normal file
197
src/components/GitPanel.jsx
Executable file → Normal file
@@ -28,6 +28,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isCommitAreaCollapsed, setIsCommitAreaCollapsed] = useState(isMobile); // Collapsed by default on mobile
|
||||
const [confirmAction, setConfirmAction] = useState(null); // { type: 'discard|commit|pull|push', file?: string, message?: string }
|
||||
const textareaRef = useRef(null);
|
||||
@@ -266,6 +267,34 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
branch: currentBranch
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Refresh status after successful publish
|
||||
fetchGitStatus();
|
||||
fetchRemoteStatus();
|
||||
} else {
|
||||
console.error('Publish failed:', data.error);
|
||||
// TODO: Show user-friendly error message
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error publishing branch:', error);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const discardChanges = async (filePath) => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/discard', {
|
||||
@@ -294,6 +323,34 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUntrackedFile = async (filePath) => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/delete-untracked', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
file: filePath
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Remove from selected files and refresh status
|
||||
setSelectedFiles(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(filePath);
|
||||
return newSet;
|
||||
});
|
||||
fetchGitStatus();
|
||||
} else {
|
||||
console.error('Delete failed:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting untracked file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAndExecute = async () => {
|
||||
if (!confirmAction) return;
|
||||
|
||||
@@ -305,6 +362,9 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
case 'discard':
|
||||
await discardChanges(file);
|
||||
break;
|
||||
case 'delete':
|
||||
await deleteUntrackedFile(file);
|
||||
break;
|
||||
case 'commit':
|
||||
await handleCommit();
|
||||
break;
|
||||
@@ -314,6 +374,9 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
case 'push':
|
||||
await handlePush();
|
||||
break;
|
||||
case 'publish':
|
||||
await handlePublish();
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing ${type}:`, error);
|
||||
@@ -578,6 +641,23 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
{isMobile && <span>Discard</span>}
|
||||
</button>
|
||||
)}
|
||||
{status === 'U' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmAction({
|
||||
type: 'delete',
|
||||
file: filePath,
|
||||
message: `Delete untracked file "${filePath}"? This action cannot be undone.`
|
||||
});
|
||||
}}
|
||||
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600 dark:text-red-400 font-medium flex items-center gap-1`}
|
||||
title="Delete untracked file"
|
||||
>
|
||||
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} />
|
||||
{isMobile && <span>Delete</span>}
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${
|
||||
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' :
|
||||
@@ -716,51 +796,72 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}>
|
||||
{/* Remote action buttons - smart logic based on ahead/behind status */}
|
||||
{remoteStatus?.hasRemote && !remoteStatus?.isUpToDate && (
|
||||
{remoteStatus?.hasRemote && (
|
||||
<>
|
||||
{/* Pull button - show when behind (primary action) */}
|
||||
{remoteStatus.behind > 0 && (
|
||||
{/* Publish button - show when branch doesn't exist on remote */}
|
||||
{!remoteStatus?.hasUpstream && (
|
||||
<button
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'pull',
|
||||
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
|
||||
type: 'publish',
|
||||
message: `Publish branch "${currentBranch}" to ${remoteStatus.remoteName}?`
|
||||
})}
|
||||
disabled={isPulling}
|
||||
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
|
||||
disabled={isPublishing}
|
||||
className="px-2 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Publish branch "${currentBranch}" to ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span>
|
||||
<Upload className={`w-3 h-3 ${isPublishing ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPublishing ? 'Publishing...' : 'Publish'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Push button - show when ahead (primary action when ahead only) */}
|
||||
{remoteStatus.ahead > 0 && (
|
||||
<button
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'push',
|
||||
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
|
||||
})}
|
||||
disabled={isPushing}
|
||||
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fetch button - show when ahead only or when diverged (secondary action) */}
|
||||
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
|
||||
<button
|
||||
onClick={handleFetch}
|
||||
disabled={isFetching}
|
||||
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Fetch from ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
|
||||
</button>
|
||||
{/* Show normal push/pull buttons only if branch has upstream */}
|
||||
{remoteStatus?.hasUpstream && !remoteStatus?.isUpToDate && (
|
||||
<>
|
||||
{/* Pull button - show when behind (primary action) */}
|
||||
{remoteStatus.behind > 0 && (
|
||||
<button
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'pull',
|
||||
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
|
||||
})}
|
||||
disabled={isPulling}
|
||||
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Push button - show when ahead (primary action when ahead only) */}
|
||||
{remoteStatus.ahead > 0 && (
|
||||
<button
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'push',
|
||||
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
|
||||
})}
|
||||
disabled={isPushing}
|
||||
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fetch button - show when ahead only or when diverged (secondary action) */}
|
||||
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
|
||||
<button
|
||||
onClick={handleFetch}
|
||||
disabled={isFetching}
|
||||
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Fetch from ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -1120,16 +1221,18 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<div className="p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className={`p-2 rounded-full mr-3 ${
|
||||
confirmAction.type === 'discard' ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900'
|
||||
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900'
|
||||
}`}>
|
||||
<AlertTriangle className={`w-5 h-5 ${
|
||||
confirmAction.type === 'discard' ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
|
||||
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
|
||||
}`} />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{confirmAction.type === 'discard' ? 'Discard Changes' :
|
||||
confirmAction.type === 'delete' ? 'Delete File' :
|
||||
confirmAction.type === 'commit' ? 'Confirm Commit' :
|
||||
confirmAction.type === 'pull' ? 'Confirm Pull' : 'Confirm Push'}
|
||||
confirmAction.type === 'pull' ? 'Confirm Pull' :
|
||||
confirmAction.type === 'publish' ? 'Publish Branch' : 'Confirm Push'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -1147,12 +1250,14 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<button
|
||||
onClick={confirmAndExecute}
|
||||
className={`px-4 py-2 text-sm text-white rounded-md ${
|
||||
confirmAction.type === 'discard'
|
||||
(confirmAction.type === 'discard' || confirmAction.type === 'delete')
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: confirmAction.type === 'commit'
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: confirmAction.type === 'pull'
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: confirmAction.type === 'publish'
|
||||
? 'bg-purple-600 hover:bg-purple-700'
|
||||
: 'bg-orange-600 hover:bg-orange-700'
|
||||
} flex items-center space-x-2`}
|
||||
>
|
||||
@@ -1161,6 +1266,11 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>Discard</span>
|
||||
</>
|
||||
) : confirmAction.type === 'delete' ? (
|
||||
<>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>Delete</span>
|
||||
</>
|
||||
) : confirmAction.type === 'commit' ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
@@ -1171,6 +1281,11 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Pull</span>
|
||||
</>
|
||||
) : confirmAction.type === 'publish' ? (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>Publish</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
|
||||
0
src/components/ImageViewer.jsx
Executable file → Normal file
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
const LoginForm = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -37,7 +37,9 @@ const LoginForm = () => {
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<ClaudeLogo size={64} />
|
||||
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
|
||||
<MessageSquare className="w-8 h-8 text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Welcome Back</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
|
||||
41
src/components/MainContent.jsx
Executable file → Normal file
@@ -17,6 +17,7 @@ import FileTree from './FileTree';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import Shell from './Shell';
|
||||
import GitPanel from './GitPanel';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
|
||||
function MainContent({
|
||||
selectedProject,
|
||||
@@ -39,7 +40,8 @@ function MainContent({
|
||||
onShowSettings, // Show tools settings panel
|
||||
autoExpandTools, // Auto-expand tool accordions
|
||||
showRawParameters, // Show raw parameters in tool accordions
|
||||
autoScrollToBottom // Auto-scroll to bottom when new messages arrive
|
||||
autoScrollToBottom, // Auto-scroll to bottom when new messages arrive
|
||||
sendByCtrlEnter // Send by Ctrl+Enter mode for East Asian language input
|
||||
}) {
|
||||
const [editingFile, setEditingFile] = useState(null);
|
||||
|
||||
@@ -269,23 +271,26 @@ function MainContent({
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
|
||||
<ChatInterface
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
messages={messages}
|
||||
onFileOpen={handleFileOpen}
|
||||
onInputFocusChange={onInputFocusChange}
|
||||
onSessionActive={onSessionActive}
|
||||
onSessionInactive={onSessionInactive}
|
||||
onReplaceTemporarySession={onReplaceTemporarySession}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
onShowSettings={onShowSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
/>
|
||||
<ErrorBoundary showDetails={true}>
|
||||
<ChatInterface
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
messages={messages}
|
||||
onFileOpen={handleFileOpen}
|
||||
onInputFocusChange={onInputFocusChange}
|
||||
onSessionActive={onSessionActive}
|
||||
onSessionInactive={onSessionInactive}
|
||||
onReplaceTemporarySession={onReplaceTemporarySession}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
onShowSettings={onShowSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>
|
||||
<FileTree selectedProject={selectedProject} />
|
||||
|
||||
0
src/components/MicButton.jsx
Executable file → Normal file
0
src/components/MobileNav.jsx
Executable file → Normal file
@@ -2,13 +2,15 @@ import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import SetupForm from './SetupForm';
|
||||
import LoginForm from './LoginForm';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
const LoadingScreen = () => (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<ClaudeLogo size={64} />
|
||||
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
|
||||
<MessageSquare className="w-8 h-8 text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">Claude Code UI</h1>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
|
||||
26
src/components/QuickSettingsPanel.jsx
Executable file → Normal file
@@ -11,7 +11,8 @@ import {
|
||||
Mic,
|
||||
Brain,
|
||||
Sparkles,
|
||||
FileText
|
||||
FileText,
|
||||
Languages
|
||||
} from 'lucide-react';
|
||||
import DarkModeToggle from './DarkModeToggle';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
@@ -25,6 +26,8 @@ const QuickSettingsPanel = ({
|
||||
onShowRawParametersChange,
|
||||
autoScrollToBottom,
|
||||
onAutoScrollChange,
|
||||
sendByCtrlEnter,
|
||||
onSendByCtrlEnterChange,
|
||||
isMobile
|
||||
}) => {
|
||||
const [localIsOpen, setLocalIsOpen] = useState(isOpen);
|
||||
@@ -142,6 +145,27 @@ const QuickSettingsPanel = ({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Input Settings */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Input Settings</h4>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Send by Ctrl+Enter
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sendByCtrlEnter}
|
||||
onChange={(e) => onSendByCtrlEnterChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">
|
||||
When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Whisper Dictation Settings - HIDDEN */}
|
||||
<div className="space-y-2" style={{ display: 'none' }}>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4>
|
||||
|
||||
0
src/components/Shell.jsx
Executable file → Normal file
4
src/components/Sidebar.jsx
Executable file → Normal file
@@ -374,9 +374,7 @@ function Sidebar({
|
||||
|
||||
try {
|
||||
const currentSessionCount = (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
|
||||
const response = await fetch(
|
||||
`/api/projects/${project.name}/sessions?limit=5&offset=${currentSessionCount}`
|
||||
);
|
||||
const response = await api.sessions(project.name, 5, currentSessionCount);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
|
||||
0
src/components/TodoList.jsx
Executable file → Normal file
498
src/components/ToolsSettings.jsx
Executable file → Normal file
@@ -1,12 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Badge } from './ui/badge';
|
||||
import { X, Plus, Settings, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Play, Globe, Terminal, Zap } from 'lucide-react';
|
||||
import { X, Plus, Settings, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen } from 'lucide-react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
function ToolsSettings({ isOpen, onClose }) {
|
||||
function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
const { isDarkMode, toggleDarkMode } = useTheme();
|
||||
const [allowedTools, setAllowedTools] = useState([]);
|
||||
const [disallowedTools, setDisallowedTools] = useState([]);
|
||||
@@ -17,14 +16,14 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
const [saveStatus, setSaveStatus] = useState(null);
|
||||
const [projectSortOrder, setProjectSortOrder] = useState('name');
|
||||
|
||||
// MCP server management state
|
||||
const [mcpServers, setMcpServers] = useState([]);
|
||||
const [showMcpForm, setShowMcpForm] = useState(false);
|
||||
const [editingMcpServer, setEditingMcpServer] = useState(null);
|
||||
const [mcpFormData, setMcpFormData] = useState({
|
||||
name: '',
|
||||
type: 'stdio',
|
||||
scope: 'user', // Always use user scope
|
||||
scope: 'user',
|
||||
projectPath: '', // For local scope
|
||||
config: {
|
||||
command: '',
|
||||
args: [],
|
||||
@@ -32,17 +31,16 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
url: '',
|
||||
headers: {},
|
||||
timeout: 30000
|
||||
}
|
||||
},
|
||||
jsonInput: '', // For JSON import
|
||||
importMode: 'form' // 'form' or 'json'
|
||||
});
|
||||
const [mcpLoading, setMcpLoading] = useState(false);
|
||||
const [mcpTestResults, setMcpTestResults] = useState({});
|
||||
const [mcpConfigTestResult, setMcpConfigTestResult] = useState(null);
|
||||
const [mcpConfigTesting, setMcpConfigTesting] = useState(false);
|
||||
const [mcpConfigTested, setMcpConfigTested] = useState(false);
|
||||
const [mcpServerTools, setMcpServerTools] = useState({});
|
||||
const [mcpToolsLoading, setMcpToolsLoading] = useState({});
|
||||
const [activeTab, setActiveTab] = useState('tools');
|
||||
|
||||
const [jsonValidationError, setJsonValidationError] = useState('');
|
||||
// Common tool patterns
|
||||
const commonTools = [
|
||||
'Bash(git log:*)',
|
||||
@@ -66,7 +64,23 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
|
||||
// First try to get servers using Claude CLI
|
||||
// Try to read directly from config files for complete details
|
||||
const configResponse = await fetch('/api/mcp/config/read', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (configResponse.ok) {
|
||||
const configData = await configResponse.json();
|
||||
if (configData.success && configData.servers) {
|
||||
setMcpServers(configData.servers);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Claude CLI
|
||||
const cliResponse = await fetch('/api/mcp/cli/list', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
@@ -99,7 +113,7 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to direct config reading
|
||||
// Final fallback to direct config reading
|
||||
const response = await fetch('/api/mcp/servers?scope=user', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
@@ -137,6 +151,8 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
body: JSON.stringify({
|
||||
name: serverData.name,
|
||||
type: serverData.type,
|
||||
scope: serverData.scope,
|
||||
projectPath: serverData.projectPath,
|
||||
command: serverData.config?.command,
|
||||
args: serverData.config?.args || [],
|
||||
url: serverData.config?.url,
|
||||
@@ -167,8 +183,8 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
|
||||
// Use Claude CLI to remove the server
|
||||
const response = await fetch(`/api/mcp/cli/remove/${serverId}`, {
|
||||
// Use Claude CLI to remove the server with proper scope
|
||||
const response = await fetch(`/api/mcp/cli/remove/${serverId}?scope=${scope}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
@@ -218,30 +234,6 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
}
|
||||
};
|
||||
|
||||
const testMcpConfiguration = async (formData) => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const response = await fetch('/api/mcp/servers/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.testResult;
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to test configuration');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error testing MCP configuration:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const discoverMcpTools = async (serverId, scope = 'user') => {
|
||||
try {
|
||||
@@ -362,7 +354,8 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
setMcpFormData({
|
||||
name: '',
|
||||
type: 'stdio',
|
||||
scope: 'user', // Always use user scope
|
||||
scope: 'user', // Default to user scope
|
||||
projectPath: '',
|
||||
config: {
|
||||
command: '',
|
||||
args: [],
|
||||
@@ -370,13 +363,13 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
url: '',
|
||||
headers: {},
|
||||
timeout: 30000
|
||||
}
|
||||
},
|
||||
jsonInput: '',
|
||||
importMode: 'form'
|
||||
});
|
||||
setEditingMcpServer(null);
|
||||
setShowMcpForm(false);
|
||||
setMcpConfigTestResult(null);
|
||||
setMcpConfigTested(false);
|
||||
setMcpConfigTesting(false);
|
||||
setJsonValidationError('');
|
||||
};
|
||||
|
||||
const openMcpForm = (server = null) => {
|
||||
@@ -386,7 +379,11 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
name: server.name,
|
||||
type: server.type,
|
||||
scope: server.scope,
|
||||
config: { ...server.config }
|
||||
projectPath: server.projectPath || '',
|
||||
config: { ...server.config },
|
||||
raw: server.raw, // Store raw config for display
|
||||
importMode: 'form', // Always use form mode when editing
|
||||
jsonInput: ''
|
||||
});
|
||||
} else {
|
||||
resetMcpForm();
|
||||
@@ -400,9 +397,42 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
setMcpLoading(true);
|
||||
|
||||
try {
|
||||
await saveMcpServer(mcpFormData);
|
||||
resetMcpForm();
|
||||
setSaveStatus('success');
|
||||
if (mcpFormData.importMode === 'json') {
|
||||
// Use JSON import endpoint
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const response = await fetch('/api/mcp/cli/add-json', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: mcpFormData.name,
|
||||
jsonConfig: mcpFormData.jsonInput,
|
||||
scope: mcpFormData.scope,
|
||||
projectPath: mcpFormData.projectPath
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
await fetchMcpServers(); // Refresh the list
|
||||
resetMcpForm();
|
||||
setSaveStatus('success');
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to add server via JSON');
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to add server');
|
||||
}
|
||||
} else {
|
||||
// Use regular form-based save
|
||||
await saveMcpServer(mcpFormData);
|
||||
resetMcpForm();
|
||||
setSaveStatus('success');
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
setSaveStatus('error');
|
||||
@@ -468,28 +498,8 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
[key]: value
|
||||
}
|
||||
}));
|
||||
// Reset test status when configuration changes
|
||||
setMcpConfigTestResult(null);
|
||||
setMcpConfigTested(false);
|
||||
};
|
||||
|
||||
const handleTestConfiguration = async () => {
|
||||
setMcpConfigTesting(true);
|
||||
try {
|
||||
const result = await testMcpConfiguration(mcpFormData);
|
||||
setMcpConfigTestResult(result);
|
||||
setMcpConfigTested(true);
|
||||
} catch (error) {
|
||||
setMcpConfigTestResult({
|
||||
success: false,
|
||||
message: error.message,
|
||||
details: []
|
||||
});
|
||||
setMcpConfigTested(true);
|
||||
} finally {
|
||||
setMcpConfigTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTransportIcon = (type) => {
|
||||
switch (type) {
|
||||
@@ -846,8 +856,13 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
{server.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{server.scope}
|
||||
{server.scope === 'local' ? '📁 local' : server.scope === 'user' ? '👤 user' : server.scope}
|
||||
</Badge>
|
||||
{server.projectPath && (
|
||||
<Badge variant="outline" className="text-xs bg-purple-50 dark:bg-purple-900/20" title={server.projectPath}>
|
||||
{server.projectPath.split('/').pop()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
@@ -860,6 +875,17 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
{server.config.args && server.config.args.length > 0 && (
|
||||
<div>Args: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.args.join(' ')}</code></div>
|
||||
)}
|
||||
{server.config.env && Object.keys(server.config.env).length > 0 && (
|
||||
<div>Environment: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{Object.entries(server.config.env).map(([k, v]) => `${k}=${v}`).join(', ')}</code></div>
|
||||
)}
|
||||
{server.raw && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">View full config</summary>
|
||||
<pre className="mt-1 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-x-auto">
|
||||
{JSON.stringify(server.raw, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Results */}
|
||||
@@ -952,39 +978,12 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
onClick={() => handleMcpTest(server.id, server.scope)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={mcpTestResults[server.id]?.loading}
|
||||
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
title="Test connection"
|
||||
>
|
||||
{mcpTestResults[server.id]?.loading ? (
|
||||
<div className="w-4 h-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleMcpToolsDiscovery(server.id, server.scope)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={mcpToolsLoading[server.id]}
|
||||
className="text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
|
||||
title="Discover tools"
|
||||
>
|
||||
{mcpToolsLoading[server.id] ? (
|
||||
<div className="w-4 h-4 animate-spin rounded-full border-2 border-purple-600 border-t-transparent" />
|
||||
) : (
|
||||
<Settings className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openMcpForm(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
title="Edit server"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -993,6 +992,7 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="Delete server"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -1022,9 +1022,134 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleMcpSubmit} className="p-4 space-y-4">
|
||||
|
||||
{!editingMcpServer && (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMcpFormData(prev => ({...prev, importMode: 'form'}))}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
mcpFormData.importMode === 'form'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Form Input
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMcpFormData(prev => ({...prev, importMode: 'json'}))}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
mcpFormData.importMode === 'json'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
JSON Import
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show current scope when editing */}
|
||||
{mcpFormData.importMode === 'form' && editingMcpServer && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Scope
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{mcpFormData.scope === 'user' ? <Globe className="w-4 h-4" /> : <FolderOpen className="w-4 h-4" />}
|
||||
<span className="text-sm">
|
||||
{mcpFormData.scope === 'user' ? 'User (Global)' : 'Project (Local)'}
|
||||
</span>
|
||||
{mcpFormData.scope === 'local' && mcpFormData.projectPath && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
- {mcpFormData.projectPath}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Scope cannot be changed when editing an existing server
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scope Selection - Moved to top, disabled when editing */}
|
||||
{mcpFormData.importMode === 'form' && !editingMcpServer && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Scope *
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMcpFormData(prev => ({...prev, scope: 'user', projectPath: ''}))}
|
||||
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
mcpFormData.scope === 'user'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
<span>User (Global)</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMcpFormData(prev => ({...prev, scope: 'local'}))}
|
||||
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
mcpFormData.scope === 'local'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
<span>Project (Local)</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{mcpFormData.scope === 'user'
|
||||
? 'User scope: Available across all projects on your machine'
|
||||
: 'Local scope: Only available in the selected project'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Project Selection for Local Scope */}
|
||||
{mcpFormData.scope === 'local' && !editingMcpServer && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Project *
|
||||
</label>
|
||||
<select
|
||||
value={mcpFormData.projectPath}
|
||||
onChange={(e) => setMcpFormData(prev => ({...prev, projectPath: e.target.value}))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||
required={mcpFormData.scope === 'local'}
|
||||
>
|
||||
<option value="">Select a project...</option>
|
||||
{projects.map(project => (
|
||||
<option key={project.name} value={project.path || project.fullPath}>
|
||||
{project.displayName || project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{mcpFormData.projectPath && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Path: {mcpFormData.projectPath}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className={mcpFormData.importMode === 'json' ? 'md:col-span-2' : ''}>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Server Name *
|
||||
</label>
|
||||
@@ -1032,38 +1157,98 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
value={mcpFormData.name}
|
||||
onChange={(e) => {
|
||||
setMcpFormData(prev => ({...prev, name: e.target.value}));
|
||||
setMcpConfigTestResult(null);
|
||||
setMcpConfigTested(false);
|
||||
}}
|
||||
placeholder="my-server"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Transport Type *
|
||||
</label>
|
||||
<select
|
||||
value={mcpFormData.type}
|
||||
onChange={(e) => {
|
||||
setMcpFormData(prev => ({...prev, type: e.target.value}));
|
||||
setMcpConfigTestResult(null);
|
||||
setMcpConfigTested(false);
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="stdio">stdio</option>
|
||||
<option value="sse">SSE</option>
|
||||
<option value="http">HTTP</option>
|
||||
</select>
|
||||
</div>
|
||||
{mcpFormData.importMode === 'form' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Transport Type *
|
||||
</label>
|
||||
<select
|
||||
value={mcpFormData.type}
|
||||
onChange={(e) => {
|
||||
setMcpFormData(prev => ({...prev, type: e.target.value}));
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="stdio">stdio</option>
|
||||
<option value="sse">SSE</option>
|
||||
<option value="http">HTTP</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scope is fixed to user - no selection needed */}
|
||||
|
||||
{/* Transport-specific Config */}
|
||||
{mcpFormData.type === 'stdio' && (
|
||||
{/* Show raw configuration details when editing */}
|
||||
{editingMcpServer && mcpFormData.raw && mcpFormData.importMode === 'form' && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-foreground mb-2">
|
||||
Configuration Details (from {editingMcpServer.scope === 'global' ? '~/.claude.json' : 'project config'})
|
||||
</h4>
|
||||
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto">
|
||||
{JSON.stringify(mcpFormData.raw, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JSON Import Mode */}
|
||||
{mcpFormData.importMode === 'json' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
JSON Configuration *
|
||||
</label>
|
||||
<textarea
|
||||
value={mcpFormData.jsonInput}
|
||||
onChange={(e) => {
|
||||
setMcpFormData(prev => ({...prev, jsonInput: e.target.value}));
|
||||
// Validate JSON as user types
|
||||
try {
|
||||
if (e.target.value.trim()) {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
// Basic validation
|
||||
if (!parsed.type) {
|
||||
setJsonValidationError('Missing required field: type');
|
||||
} else if (parsed.type === 'stdio' && !parsed.command) {
|
||||
setJsonValidationError('stdio type requires a command field');
|
||||
} else if ((parsed.type === 'http' || parsed.type === 'sse') && !parsed.url) {
|
||||
setJsonValidationError(`${parsed.type} type requires a url field`);
|
||||
} else {
|
||||
setJsonValidationError('');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (e.target.value.trim()) {
|
||||
setJsonValidationError('Invalid JSON format');
|
||||
} else {
|
||||
setJsonValidationError('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`w-full px-3 py-2 border ${jsonValidationError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'} bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 font-mono text-sm`}
|
||||
rows="8"
|
||||
placeholder={'{\n "type": "stdio",\n "command": "/path/to/server",\n "args": ["--api-key", "abc123"],\n "env": {\n "CACHE_DIR": "/tmp"\n }\n}'}
|
||||
required
|
||||
/>
|
||||
{jsonValidationError && (
|
||||
<p className="text-xs text-red-500 mt-1">{jsonValidationError}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Paste your MCP server configuration in JSON format. Example formats:
|
||||
<br />• stdio: {`{"type":"stdio","command":"npx","args":["@upstash/context7-mcp"]}`}
|
||||
<br />• http/sse: {`{"type":"http","url":"https://api.example.com/mcp"}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transport-specific Config - Only show in form mode */}
|
||||
{mcpFormData.importMode === 'form' && mcpFormData.type === 'stdio' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
@@ -1092,7 +1277,7 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(mcpFormData.type === 'sse' || mcpFormData.type === 'http') && (
|
||||
{mcpFormData.importMode === 'form' && (mcpFormData.type === 'sse' || mcpFormData.type === 'http') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
URL *
|
||||
@@ -1107,8 +1292,9 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div>
|
||||
{/* Environment Variables - Only show in form mode */}
|
||||
{mcpFormData.importMode === 'form' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Environment Variables (KEY=value, one per line)
|
||||
</label>
|
||||
@@ -1129,8 +1315,9 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
placeholder="API_KEY=your-key DEBUG=true"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(mcpFormData.type === 'sse' || mcpFormData.type === 'http') && (
|
||||
{mcpFormData.importMode === 'form' && (mcpFormData.type === 'sse' || mcpFormData.type === 'http') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Headers (KEY=value, one per line)
|
||||
@@ -1154,67 +1341,6 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Configuration Section */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium text-foreground">Configuration Test</h4>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleTestConfiguration}
|
||||
disabled={mcpConfigTesting || !mcpFormData.name.trim()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-blue-600 border-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20"
|
||||
>
|
||||
{mcpConfigTesting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent mr-2" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Test Configuration
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
You can test your configuration to verify it's working correctly.
|
||||
</p>
|
||||
|
||||
{mcpConfigTestResult && (
|
||||
<div className={`p-3 rounded-lg text-sm ${
|
||||
mcpConfigTestResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800'
|
||||
}`}>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
{mcpConfigTestResult.success ? (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
{mcpConfigTestResult.message}
|
||||
</div>
|
||||
{mcpConfigTestResult.details && mcpConfigTestResult.details.length > 0 && (
|
||||
<ul className="mt-2 space-y-1 text-xs">
|
||||
{mcpConfigTestResult.details.map((detail, i) => (
|
||||
<li key={i} className="flex items-start gap-1">
|
||||
<span className="text-gray-400 mt-0.5">•</span>
|
||||
<span>{detail}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={resetMcpForm}>
|
||||
|
||||
0
src/components/ui/badge.jsx
Executable file → Normal file
0
src/components/ui/button.jsx
Executable file → Normal file
0
src/components/ui/input.jsx
Executable file → Normal file
0
src/components/ui/scroll-area.jsx
Executable file → Normal file
0
src/contexts/ThemeContext.jsx
Executable file → Normal file
12
src/index.css
Executable file → Normal file
@@ -559,16 +559,6 @@
|
||||
transform: inherit !important;
|
||||
}
|
||||
|
||||
/* Preserve backgrounds for containers and modals */
|
||||
.fixed:hover,
|
||||
.modal:hover,
|
||||
.bg-white:hover,
|
||||
.bg-gray-800:hover,
|
||||
.bg-gray-900:hover,
|
||||
[class*="bg-"]:hover {
|
||||
background-color: revert !important;
|
||||
}
|
||||
|
||||
/* Force buttons to be immediately clickable */
|
||||
button, [role="button"], .cursor-pointer {
|
||||
cursor: pointer !important;
|
||||
@@ -746,4 +736,4 @@
|
||||
background-color: rgb(31 41 55) !important;
|
||||
color: rgb(243 244 246) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
0
src/main.jsx
Executable file → Normal file
@@ -9,10 +9,14 @@ export default defineConfig(({ command, mode }) => {
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: parseInt(env.VITE_PORT) || 3001,
|
||||
port: parseInt(env.VITE_PORT) || 5173,
|
||||
proxy: {
|
||||
'/api': `http://localhost:${env.PORT || 3002}`,
|
||||
'/api': `http://localhost:${env.PORT || 3001}`,
|
||||
'/ws': {
|
||||
target: `ws://localhost:${env.PORT || 3001}`,
|
||||
ws: true
|
||||
},
|
||||
'/shell': {
|
||||
target: `ws://localhost:${env.PORT || 3002}`,
|
||||
ws: true
|
||||
}
|
||||
|
||||