20 Commits

Author SHA1 Message Date
viper151
ba077fdf62 Update package.json 2025-07-13 17:30:59 +02:00
simos
6170f97216 Fixes to json 2025-07-13 15:30:22 +00:00
simos
6c64ea7552 feat: Ability to add and control user level MCP Servers 2025-07-13 15:25:45 +00:00
viper151
23f5fc357b Update package.json 2025-07-13 16:00:56 +02:00
viper151
2d63b18c69 Update index.js 2025-07-13 15:22:34 +02:00
simos
9f65756e2c Removing some console logs 2025-07-13 12:46:28 +00:00
simos
db8c086f22 Merge branch 'main' of https://github.com/siteboon/claudecodeui 2025-07-13 12:46:10 +00:00
simos
111311eac5 Fixes on Dark Theme 2025-07-13 12:05:32 +00:00
viper151
6934581488 Update README.md 2025-07-13 12:33:03 +02:00
simos
c0f30afb23 Enhanced UX on GitPanel 2025-07-13 09:42:10 +00:00
simos
aa1df3c9c8 Fixes on chat interface file selection 2025-07-13 09:35:16 +00:00
simos
ed920bb73b Introducing push on server 2025-07-13 05:14:01 +00:00
simos
e72be46733 Fixes 2025-07-13 05:11:18 +00:00
simos
a5ddef58df Introducing push on git panel 2025-07-13 05:10:56 +00:00
simos
02cc0257ae UX enhancements on gitpanel and Shell to make them more mobile friendly 2025-07-13 05:06:31 +00:00
simos
24282ababe Merge branch 'main' of https://github.com/siteboon/claudecodeui 2025-07-13 04:37:15 +00:00
simos
71ac848d60 Added animations to git panel 2025-07-12 22:04:08 +00:00
simos
7329f89c96 Added pull and fetch on git panel
Made UX enhancements
2025-07-12 22:02:59 +00:00
simos
00acc57161 Formatting properly exit_plan_mose 2025-07-12 21:43:47 +00:00
viper151
9ac604de41 Update README.md 2025-07-12 22:29:02 +02:00
14 changed files with 2446 additions and 249 deletions

View File

@@ -33,10 +33,11 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
## Features
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code from mobile
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code
- **Integrated Shell Terminal** - Direct access to Claude Code CLI through built-in shell functionality
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
- **Session Management** - Resume conversations, manage multiple sessions, and track history
@@ -72,6 +73,7 @@ cp .env.example .env
npm run dev
```
The application will start at the port you specified in your .env
5. **Open your browser:**
- Development: `http://localhost:3001`
@@ -120,17 +122,21 @@ The UI automatically discovers Claude Code projects from `~/.claude/projects/` a
- **Syntax Highlighting** - Support for multiple programming languages
- **File Operations** - Create, rename, delete files and directories
#### Git Explorer
#### Session Management
- **Session Persistence** - All conversations automatically saved
- **Session Organization** - Group sessions by project and timestamp
- **Session Actions** - Rename, delete, and export conversation history
- **Cross-device Sync** - Access sessions from any device
### Mobile Experience
### Mobile App
- **Responsive Design** - Optimized for all screen sizes
- **Touch-friendly Interface** - Swipe gestures and touch navigation
- **Mobile Navigation** - Bottom tab bar for easy thumb navigation
- **Adaptive Layout** - Collapsible sidebar and smart content prioritization
- **Add shortcut to Home Screen** - Add a shortcut to your home screen and the app will behave like a PWA
## Architecture
@@ -230,8 +236,10 @@ This project is open source and free to use, modify, and distribute under the GP
- **Watch** for updates and new releases
- **Follow** the project for announcements
### Sponsors
- [Siteboon - AI powered website builder](https://siteboon.ai)
---
<div align="center">
<strong>Made with care for the Claude Code community</strong>
<strong>Made with care for the Claude Code community.</strong>
</div>

151
package-lock.json generated
View File

@@ -31,10 +31,12 @@
"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",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"tailwind-merge": "^3.3.1",
@@ -2115,6 +2117,12 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -2125,6 +2133,15 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/attr-accept": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@@ -2366,6 +2383,23 @@
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2677,6 +2711,21 @@
"node": ">= 6"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
@@ -3195,6 +3244,18 @@
"reusify": "^1.0.4"
}
},
"node_modules/file-selector": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
"license": "MIT",
"dependencies": {
"tslib": "^2.7.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -4578,6 +4639,18 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -4588,6 +4661,24 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/multer": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz",
"integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -5049,6 +5140,17 @@
"node": ">=10"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -5171,6 +5273,29 @@
"react": "^18.3.1"
}
},
"node_modules/react-dropzone": {
"version": "14.3.8",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
"license": "MIT",
"dependencies": {
"attr-accept": "^2.2.4",
"file-selector": "^2.1.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -5966,6 +6091,14 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -6407,8 +6540,7 @@
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
@@ -6452,6 +6584,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -6860,6 +6998,15 @@
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-ui",
"version": "1.3.0",
"version": "1.5.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",

View File

@@ -84,6 +84,84 @@ async function spawnClaude(command, options = {}, ws) {
// Add basic flags
args.push('--output-format', 'stream-json', '--verbose');
// Add MCP config flag only if MCP servers are configured
try {
console.log('🔍 Starting MCP config check...');
// Use already imported modules (fs.promises is imported as fs, path, os)
const fsSync = await import('fs'); // Import synchronous fs methods
console.log('✅ Successfully imported fs sync methods');
// Check for MCP config in ~/.claude.json
const claudeConfigPath = path.join(os.homedir(), '.claude.json');
console.log(`🔍 Checking for MCP configs in: ${claudeConfigPath}`);
console.log(` Claude config exists: ${fsSync.existsSync(claudeConfigPath)}`);
let hasMcpServers = false;
// Check Claude config for MCP servers
if (fsSync.existsSync(claudeConfigPath)) {
try {
const claudeConfig = JSON.parse(fsSync.readFileSync(claudeConfigPath, 'utf8'));
// Check global MCP servers
if (claudeConfig.mcpServers && Object.keys(claudeConfig.mcpServers).length > 0) {
console.log(`✅ Found ${Object.keys(claudeConfig.mcpServers).length} global MCP servers`);
hasMcpServers = true;
}
// Check project-specific MCP servers
if (!hasMcpServers && claudeConfig.claudeProjects) {
const currentProjectPath = process.cwd();
const projectConfig = claudeConfig.claudeProjects[currentProjectPath];
if (projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0) {
console.log(`✅ Found ${Object.keys(projectConfig.mcpServers).length} project MCP servers`);
hasMcpServers = true;
}
}
} catch (e) {
console.log(`❌ Failed to parse Claude config:`, e.message);
}
}
console.log(`🔍 hasMcpServers result: ${hasMcpServers}`);
if (hasMcpServers) {
// Use Claude config file if it has MCP servers
let configPath = null;
if (fsSync.existsSync(claudeConfigPath)) {
try {
const claudeConfig = JSON.parse(fsSync.readFileSync(claudeConfigPath, 'utf8'));
// Check if we have any MCP servers (global or project-specific)
const hasGlobalServers = claudeConfig.mcpServers && Object.keys(claudeConfig.mcpServers).length > 0;
const currentProjectPath = process.cwd();
const projectConfig = claudeConfig.claudeProjects && claudeConfig.claudeProjects[currentProjectPath];
const hasProjectServers = projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0;
if (hasGlobalServers || hasProjectServers) {
configPath = claudeConfigPath;
}
} catch (e) {
// No valid config found
}
}
if (configPath) {
console.log(`📡 Adding MCP config: ${configPath}`);
args.push('--mcp-config', configPath);
} else {
console.log('⚠️ MCP servers detected but no valid config file found');
}
}
} catch (error) {
// If there's any error checking for MCP configs, don't add the flag
console.log('❌ MCP config check failed:', error.message);
console.log('📍 Error stack:', error.stack);
console.log('Note: MCP config check failed, proceeding without MCP support');
}
// Add model for new sessions
if (!resume) {
args.push('--model', 'sonnet');

View File

@@ -40,6 +40,7 @@ import { getProjects, getSessions, getSessionMessages, renameProject, deleteSess
import { spawnClaude, abortClaudeSession } from './claude-cli.js';
import gitRoutes from './routes/git.js';
import authRoutes from './routes/auth.js';
import mcpRoutes from './routes/mcp.js';
import { initializeDatabase } from './database/db.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
@@ -130,18 +131,6 @@ async function setupProjectsWatcher() {
}
}
// Get the first non-localhost IP address
function getServerIP() {
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]) {
if (iface.family === 'IPv4' && !iface.internal) {
return iface.address;
}
}
}
return 'localhost';
}
const app = express();
const server = http.createServer(app);
@@ -183,14 +172,15 @@ app.use('/api/auth', authRoutes);
// Git API Routes (protected)
app.use('/api/git', authenticateToken, gitRoutes);
// MCP API Routes (protected)
app.use('/api/mcp', authenticateToken, mcpRoutes);
// Static files served after API routes
app.use(express.static(path.join(__dirname, '../dist')));
// API Routes (protected)
app.get('/api/config', authenticateToken, (req, res) => {
// Always use the server's actual IP and port for WebSocket connections
const serverIP = getServerIP();
const host = `${serverIP}:${PORT}`;
const host = req.headers.host || `${req.hostname}:${PORT}`;
const protocol = req.protocol === 'https' || req.get('x-forwarded-proto') === 'https' ? 'wss' : 'ws';
console.log('Config API called - Returning host:', host, 'Protocol:', protocol);
@@ -428,8 +418,6 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
const files = await getFileTree(actualPath, 3, 0, true);
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
console.log('📄 Found', files.length, 'files/folders, including', hiddenFiles.length, 'hidden files');
console.log('🔍 Hidden files:', hiddenFiles.map(f => f.name));
res.json(files);
} catch (error) {
console.error('❌ File tree error:', error.message);

View File

@@ -11,7 +11,6 @@ let cacheTimestamp = Date.now();
function clearProjectDirectoryCache() {
projectDirectoryCache.clear();
cacheTimestamp = Date.now();
console.log('🗑️ Project directory cache cleared');
}
// Load project configuration file
@@ -73,7 +72,6 @@ async function extractProjectDirectory(projectName) {
return projectDirectoryCache.get(projectName);
}
console.log(`🔍 Extracting project directory for: ${projectName}`);
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const cwdCounts = new Map();
@@ -155,7 +153,6 @@ async function extractProjectDirectory(projectName) {
// Cache the result
projectDirectoryCache.set(projectName, extractedPath);
console.log(`💾 Cached project directory: ${projectName} -> ${extractedPath}`);
return extractedPath;

View File

@@ -420,4 +420,276 @@ function generateSimpleCommitMessage(files, diff) {
}
}
// Get remote status (ahead/behind commits with smart remote detection)
router.get('/remote-status', async (req, res) => {
const { project } = req.query;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
}
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Get current branch
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const branch = currentBranch.trim();
// Check if there's a remote tracking branch (smart detection)
let trackingBranch;
let remoteName;
try {
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
trackingBranch = stdout.trim();
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
} catch (error) {
// No upstream branch configured
return res.json({
hasRemote: false,
branch,
message: 'No remote tracking branch configured'
});
}
// Get ahead/behind counts
const { stdout: countOutput } = await execAsync(
`git rev-list --count --left-right ${trackingBranch}...HEAD`,
{ cwd: projectPath }
);
const [behind, ahead] = countOutput.trim().split('\t').map(Number);
res.json({
hasRemote: true,
branch,
remoteBranch: trackingBranch,
remoteName,
ahead: ahead || 0,
behind: behind || 0,
isUpToDate: ahead === 0 && behind === 0
});
} catch (error) {
console.error('Git remote status error:', error);
res.json({ error: error.message });
}
});
// Fetch from remote (using smart remote detection)
router.post('/fetch', async (req, res) => {
const { project } = req.body;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
}
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const branch = currentBranch.trim();
let remoteName = 'origin'; // fallback
try {
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
remoteName = stdout.trim().split('/')[0]; // Extract remote name
} catch (error) {
// No upstream, try to fetch from origin anyway
console.log('No upstream configured, using origin as fallback');
}
const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath });
res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
} catch (error) {
console.error('Git fetch error:', error);
res.status(500).json({
error: 'Fetch failed',
details: error.message.includes('Could not resolve hostname')
? 'Unable to connect to remote repository. Check your internet connection.'
: error.message.includes('fatal: \'origin\' does not appear to be a git repository')
? 'No remote repository configured. Add a remote with: git remote add origin <url>'
: error.message
});
}
});
// Pull from remote (fetch + merge using smart remote detection)
router.post('/pull', async (req, res) => {
const { project } = req.body;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
}
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const branch = currentBranch.trim();
let remoteName = 'origin'; // fallback
let remoteBranch = branch; // fallback
try {
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
const tracking = stdout.trim();
remoteName = tracking.split('/')[0]; // Extract remote name
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
} catch (error) {
// No upstream, use fallback
console.log('No upstream configured, using origin/branch as fallback');
}
const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath });
res.json({
success: true,
output: stdout || 'Pull completed successfully',
remoteName,
remoteBranch
});
} catch (error) {
console.error('Git pull error:', error);
// Enhanced error handling for common pull scenarios
let errorMessage = 'Pull failed';
let details = error.message;
if (error.message.includes('CONFLICT')) {
errorMessage = 'Merge conflicts detected';
details = 'Pull created merge conflicts. Please resolve conflicts manually in the editor, then commit the changes.';
} else if (error.message.includes('Please commit your changes or stash them')) {
errorMessage = 'Uncommitted changes detected';
details = 'Please commit or stash your local changes before pulling.';
} 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('fatal: \'origin\' does not appear to be a git repository')) {
errorMessage = 'Remote not configured';
details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
} else if (error.message.includes('diverged')) {
errorMessage = 'Branches have diverged';
details = 'Your local branch and remote branch have diverged. Consider fetching first to review changes.';
}
res.status(500).json({
error: errorMessage,
details: details
});
}
});
// Push commits to remote repository
router.post('/push', async (req, res) => {
const { project } = req.body;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
}
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const branch = currentBranch.trim();
let remoteName = 'origin'; // fallback
let remoteBranch = branch; // fallback
try {
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
const tracking = stdout.trim();
remoteName = tracking.split('/')[0]; // Extract remote name
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
} catch (error) {
// No upstream, use fallback
console.log('No upstream configured, using origin/branch as fallback');
}
const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath });
res.json({
success: true,
output: stdout || 'Push completed successfully',
remoteName,
remoteBranch
});
} catch (error) {
console.error('Git push error:', error);
// Enhanced error handling for common push scenarios
let errorMessage = 'Push failed';
let details = error.message;
if (error.message.includes('rejected')) {
errorMessage = 'Push rejected';
details = 'The remote has newer commits. Pull first to merge changes before pushing.';
} else if (error.message.includes('non-fast-forward')) {
errorMessage = 'Non-fast-forward push';
details = 'Your branch is behind the remote. Pull the latest changes first.';
} 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('fatal: \'origin\' does not appear to be a git repository')) {
errorMessage = 'Remote not configured';
details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
} else if (error.message.includes('Permission denied')) {
errorMessage = 'Authentication failed';
details = 'Permission denied. Check your credentials or SSH keys.';
} else if (error.message.includes('no upstream branch')) {
errorMessage = 'No upstream branch';
details = 'No upstream branch configured. Use: git push --set-upstream origin <branch>';
}
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;
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 file status to determine correct discard command
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
if (!statusOutput.trim()) {
return res.status(400).json({ error: 'No changes to discard for this file' });
}
const status = statusOutput.substring(0, 2);
if (status === '??') {
// Untracked file - delete it
await fs.unlink(path.join(projectPath, file));
} else if (status.includes('M') || status.includes('D')) {
// Modified or deleted file - restore from HEAD
await execAsync(`git restore "${file}"`, { cwd: projectPath });
} else if (status.includes('A')) {
// Added file - unstage it
await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath });
}
res.json({ success: true, message: `Changes discarded for ${file}` });
} catch (error) {
console.error('Git discard error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

286
server/routes/mcp.js Normal file
View File

@@ -0,0 +1,286 @@
import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { spawn } from 'child_process';
const router = express.Router();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Claude CLI command routes
// GET /api/mcp/cli/list - List MCP servers using Claude CLI
router.get('/cli/list', async (req, res) => {
try {
console.log('📋 Listing MCP servers using Claude CLI');
const { spawn } = await import('child_process');
const { promisify } = await import('util');
const exec = promisify(spawn);
const process = spawn('claude', ['mcp', 'list', '-s', 'user'], {
stdio: ['pipe', 'pipe', 'pipe']
});
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, servers: parseClaudeListOutput(stdout) });
} else {
console.error('Claude CLI error:', stderr);
res.status(500).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 listing MCP servers via CLI:', error);
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
}
});
// 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;
console.log(' Adding MCP server using Claude CLI:', name);
const { spawn } = await import('child_process');
let cliArgs = ['mcp', 'add'];
if (type === 'http') {
cliArgs.push('--transport', 'http', name, '-s', 'user', 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);
// 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');
// Add environment variables
Object.entries(env).forEach(([key, value]) => {
cliArgs.push('-e', `${key}=${value}`);
});
cliArgs.push(command);
if (args && args.length > 0) {
cliArgs.push(...args);
}
}
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
const process = spawn('claude', cliArgs, {
stdio: ['pipe', 'pipe', 'pipe']
});
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` });
} 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 CLI:', 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;
console.log('🗑️ Removing MCP server using Claude CLI:', name);
const { spawn } = await import('child_process');
const process = spawn('claude', ['mcp', 'remove', '-s', 'user', name], {
stdio: ['pipe', 'pipe', 'pipe']
});
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}" removed successfully` });
} 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 removing MCP server via CLI:', error);
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
}
});
// GET /api/mcp/cli/get/:name - Get MCP server details using Claude CLI
router.get('/cli/get/:name', async (req, res) => {
try {
const { name } = req.params;
console.log('📄 Getting MCP server details using Claude CLI:', name);
const { spawn } = await import('child_process');
const process = spawn('claude', ['mcp', 'get', '-s', 'user', name], {
stdio: ['pipe', 'pipe', 'pipe']
});
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, server: parseClaudeGetOutput(stdout) });
} else {
console.error('Claude CLI error:', stderr);
res.status(404).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 getting MCP server details via CLI:', error);
res.status(500).json({ error: 'Failed to get MCP server details', 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) {
if (line.includes(':')) {
const colonIndex = line.indexOf(':');
const name = line.substring(0, colonIndex).trim();
const rest = line.substring(colonIndex + 1).trim();
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
type = 'http';
}
if (name) {
servers.push({
name,
type,
status: 'active'
});
}
}
}
console.log('🔍 Parsed Claude CLI servers:', servers);
return servers;
}
function parseClaudeGetOutput(output) {
// Parse the output from 'claude mcp get <name>' command
// This is a simple parser - might need adjustment based on actual output format
try {
// Try to extract JSON if present
const jsonMatch = output.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
// Otherwise, parse as text
const server = { raw_output: output };
const lines = output.split('\n');
for (const line of lines) {
if (line.includes('Name:')) {
server.name = line.split(':')[1]?.trim();
} else if (line.includes('Type:')) {
server.type = line.split(':')[1]?.trim();
} else if (line.includes('Command:')) {
server.command = line.split(':')[1]?.trim();
} else if (line.includes('URL:')) {
server.url = line.split(':')[1]?.trim();
}
}
return server;
} catch (error) {
return { raw_output: output, parse_error: error.message };
}
}
export default router;

View File

@@ -465,6 +465,32 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
}
}
// Special handling for exit_plan_mode tool
if (message.toolName === 'exit_plan_mode') {
try {
const input = JSON.parse(message.toolInput);
if (input.plan) {
// Replace escaped newlines with actual newlines
const planContent = input.plan.replace(/\\n/g, '\n');
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-2">
<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>
📋 View implementation plan
</summary>
<div className="mt-3 prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown>{planContent}</ReactMarkdown>
</div>
</details>
);
}
} catch (e) {
// Fall back to regular display
}
}
// Regular tool input display for other tools
return (
<details className="mt-2" open={autoExpandTools}>
@@ -551,6 +577,30 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
}
}
// Special handling for exit_plan_mode tool results
if (message.toolName === 'exit_plan_mode') {
try {
// The content should be JSON with a "plan" field
const parsed = JSON.parse(content);
if (parsed.plan) {
// Replace escaped newlines with actual newlines
const planContent = parsed.plan.replace(/\\n/g, '\n');
return (
<div>
<div className="flex items-center gap-2 mb-3">
<span className="font-medium">Implementation Plan</span>
</div>
<div className="prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown>{planContent}</ReactMarkdown>
</div>
</div>
);
}
} catch (e) {
// Fall through to regular handling
}
}
// Special handling for interactive prompts
if (content.includes('Do you want to proceed?') && message.toolName === 'Bash') {
const lines = content.split('\n');
@@ -977,6 +1027,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1);
const [slashPosition, setSlashPosition] = useState(-1);
const [visibleMessageCount, setVisibleMessageCount] = useState(100);
const [claudeStatus, setClaudeStatus] = useState(null);
@@ -1580,14 +1631,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
return () => clearTimeout(timer);
}, [input]);
// Show only recent messages for better performance (last 100 messages)
// Show only recent messages for better performance
const visibleMessages = useMemo(() => {
const maxMessages = 100;
if (chatMessages.length <= maxMessages) {
if (chatMessages.length <= visibleMessageCount) {
return chatMessages;
}
return chatMessages.slice(-maxMessages);
}, [chatMessages]);
return chatMessages.slice(-visibleMessageCount);
}, [chatMessages, visibleMessageCount]);
// Capture scroll position before render when auto-scroll is disabled
useEffect(() => {
@@ -1687,6 +1737,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, []);
// Load earlier messages by increasing the visible message count
const loadEarlierMessages = useCallback(() => {
setVisibleMessageCount(prevCount => prevCount + 100);
}, []);
// Handle image files from drag & drop or file picker
const handleImageFiles = useCallback((files) => {
const validFiles = files.filter(file => {
@@ -1930,19 +1985,34 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const spaceIndex = textAfterAtQuery.indexOf(' ');
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
const newInput = textBeforeAt + '@' + file.path + textAfterQuery;
const newInput = textBeforeAt + '@' + file.path + ' ' + textAfterQuery;
const newCursorPos = textBeforeAt.length + 1 + file.path.length + 1;
// Immediately ensure focus is maintained
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
textareaRef.current.focus();
}
// Update input and cursor position
setInput(newInput);
setCursorPosition(newCursorPos);
// Hide dropdown
setShowFileDropdown(false);
setAtSymbolPosition(-1);
// Focus back to textarea and set cursor position
// Set cursor position synchronously
if (textareaRef.current) {
// Use requestAnimationFrame for smoother updates
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.focus();
const newCursorPos = textBeforeAt.length + 1 + file.path.length;
setTimeout(() => {
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
setCursorPosition(newCursorPos);
}, 0);
// Ensure focus is maintained
if (!textareaRef.current.matches(':focus')) {
textareaRef.current.focus();
}
}
});
}
};
@@ -2031,10 +2101,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</div>
) : (
<>
{chatMessages.length > 100 && (
{chatMessages.length > visibleMessageCount && (
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
Showing last 100 messages ({chatMessages.length} total)
<button className="ml-1 text-blue-600 hover:text-blue-700 underline">
Showing last {visibleMessageCount} messages ({chatMessages.length} total)
<button
className="ml-1 text-blue-600 hover:text-blue-700 underline"
onClick={loadEarlierMessages}
>
Load earlier messages
</button>
</div>
@@ -2180,6 +2253,37 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</div>
)}
{/* File dropdown - positioned outside dropzone to avoid conflicts */}
{showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-y-auto z-50 backdrop-blur-sm">
{filteredFiles.map((file, index) => (
<div
key={file.path}
className={`px-4 py-3 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-b-0 touch-manipulation ${
index === selectedFileIndex
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
onMouseDown={(e) => {
// Prevent textarea from losing focus on mobile
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
selectFile(file);
}}
>
<div className="font-medium text-sm">{file.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{file.path}
</div>
</div>
))}
</div>
)}
<div {...getRootProps()} className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
<input {...getInputProps()} />
<textarea
@@ -2297,28 +2401,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
/>
</svg>
</button>
{/* File dropdown */}
{showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-y-auto z-50">
{filteredFiles.map((file, index) => (
<div
key={file.path}
className={`px-4 py-2 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-b-0 ${
index === selectedFileIndex
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
onClick={() => selectFile(file)}
>
<div className="font-medium text-sm">{file.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{file.path}
</div>
</div>
))}
</div>
)}
</div>
{/* Hint text */}
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles } from 'lucide-react';
import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles, Download, RotateCcw, Trash2, AlertTriangle, Upload } from 'lucide-react';
import { MicButton } from './MicButton.jsx';
import { authenticatedFetch } from '../utils/api';
@@ -24,6 +24,12 @@ function GitPanel({ selectedProject, isMobile }) {
const [expandedCommits, setExpandedCommits] = useState(new Set());
const [commitDiffs, setCommitDiffs] = useState({});
const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
const [remoteStatus, setRemoteStatus] = useState(null);
const [isFetching, setIsFetching] = useState(false);
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = 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);
const dropdownRef = useRef(null);
@@ -31,6 +37,7 @@ function GitPanel({ selectedProject, isMobile }) {
if (selectedProject) {
fetchGitStatus();
fetchBranches();
fetchRemoteStatus();
if (activeView === 'history') {
fetchRecentCommits();
}
@@ -105,6 +112,24 @@ function GitPanel({ selectedProject, isMobile }) {
}
};
const fetchRemoteStatus = async () => {
if (!selectedProject) return;
try {
const response = await authenticatedFetch(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.name)}`);
const data = await response.json();
if (!data.error) {
setRemoteStatus(data);
} else {
setRemoteStatus(null);
}
} catch (error) {
console.error('Error fetching remote status:', error);
setRemoteStatus(null);
}
};
const switchBranch = async (branchName) => {
try {
const response = await authenticatedFetch('/api/git/checkout', {
@@ -161,6 +186,140 @@ function GitPanel({ selectedProject, isMobile }) {
}
};
const handleFetch = async () => {
setIsFetching(true);
try {
const response = await authenticatedFetch('/api/git/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name
})
});
const data = await response.json();
if (data.success) {
// Refresh status after successful fetch
fetchGitStatus();
fetchRemoteStatus();
} else {
console.error('Fetch failed:', data.error);
}
} catch (error) {
console.error('Error fetching from remote:', error);
} finally {
setIsFetching(false);
}
};
const handlePull = async () => {
setIsPulling(true);
try {
const response = await authenticatedFetch('/api/git/pull', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name
})
});
const data = await response.json();
if (data.success) {
// Refresh status after successful pull
fetchGitStatus();
fetchRemoteStatus();
} else {
console.error('Pull failed:', data.error);
// TODO: Show user-friendly error message
}
} catch (error) {
console.error('Error pulling from remote:', error);
} finally {
setIsPulling(false);
}
};
const handlePush = async () => {
setIsPushing(true);
try {
const response = await authenticatedFetch('/api/git/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name
})
});
const data = await response.json();
if (data.success) {
// Refresh status after successful push
fetchGitStatus();
fetchRemoteStatus();
} else {
console.error('Push failed:', data.error);
// TODO: Show user-friendly error message
}
} catch (error) {
console.error('Error pushing to remote:', error);
} finally {
setIsPushing(false);
}
};
const discardChanges = async (filePath) => {
try {
const response = await authenticatedFetch('/api/git/discard', {
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('Discard failed:', data.error);
}
} catch (error) {
console.error('Error discarding changes:', error);
}
};
const confirmAndExecute = async () => {
if (!confirmAction) return;
const { type, file, message } = confirmAction;
setConfirmAction(null);
try {
switch (type) {
case 'discard':
await discardChanges(file);
break;
case 'commit':
await handleCommit();
break;
case 'pull':
await handlePull();
break;
case 'push':
await handlePush();
break;
}
} catch (error) {
console.error(`Error executing ${type}:`, error);
}
};
const fetchFileDiff = async (filePath) => {
try {
const response = await authenticatedFetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
@@ -292,6 +451,7 @@ function GitPanel({ selectedProject, isMobile }) {
setCommitMessage('');
setSelectedFiles(new Set());
fetchGitStatus();
fetchRemoteStatus();
} else {
console.error('Commit failed:', data.error);
}
@@ -384,22 +544,40 @@ function GitPanel({ selectedProject, isMobile }) {
return (
<div key={filePath} className="border-b border-gray-200 dark:border-gray-700 last:border-0">
<div className="flex items-center px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800">
<div className={`flex items-center hover:bg-gray-50 dark:hover:bg-gray-800 ${isMobile ? 'px-2 py-1.5' : 'px-3 py-2'}`}>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleFileSelected(filePath)}
onClick={(e) => e.stopPropagation()}
className="mr-2 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"
className={`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 ${isMobile ? 'mr-1.5' : 'mr-2'}`}
/>
<div
className="flex items-center flex-1 cursor-pointer"
onClick={() => toggleFileExpanded(filePath)}
>
<div className="mr-2 p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded">
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
<div className={`p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded ${isMobile ? 'mr-1' : 'mr-2'}`}>
<ChevronRight className={`w-3 h-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} />
</div>
<span className="flex-1 text-sm truncate">{filePath}</span>
<span className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'}`}>{filePath}</span>
<div className="flex items-center gap-1">
{(status === 'M' || status === 'D') && (
<button
onClick={(e) => {
e.stopPropagation();
setConfirmAction({
type: 'discard',
file: filePath,
message: `Discard all changes to "${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="Discard changes"
>
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} />
{isMobile && <span>Discard</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' :
@@ -413,10 +591,30 @@ function GitPanel({ selectedProject, isMobile }) {
</span>
</div>
</div>
{isExpanded && diff && (
<div className="bg-gray-50 dark:bg-gray-900">
</div>
<div className={`bg-gray-50 dark:bg-gray-900 transition-all duration-400 ease-in-out overflow-hidden ${
isExpanded && diff
? 'max-h-[600px] opacity-100 translate-y-0'
: 'max-h-0 opacity-0 -translate-y-1'
}`}>
{/* Operation header */}
<div className="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<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' :
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 border-green-200 dark:border-green-800' :
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 border-red-200 dark:border-red-800' :
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
{status}
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{getStatusLabel(status)}
</span>
</div>
{isMobile && (
<div className="flex justify-end p-2 border-b border-gray-200 dark:border-gray-700">
<button
onClick={(e) => {
e.stopPropagation();
@@ -427,13 +625,12 @@ function GitPanel({ selectedProject, isMobile }) {
>
{wrapText ? '↔️ Scroll' : '↩️ Wrap'}
</button>
</div>
)}
</div>
<div className="max-h-96 overflow-y-auto p-2">
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
{diff && diff.split('\n').map((line, index) => renderDiffLine(line, index))}
</div>
</div>
)}
</div>
);
};
@@ -449,14 +646,36 @@ function GitPanel({ selectedProject, isMobile }) {
return (
<div className="h-full flex flex-col bg-white dark:bg-gray-900">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div className={`flex items-center justify-between border-b border-gray-200 dark:border-gray-700 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowBranchDropdown(!showBranchDropdown)}
className="flex items-center space-x-2 px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
className={`flex items-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
>
<GitBranch className="w-4 h-4 text-gray-600 dark:text-gray-400" />
<span className="text-sm font-medium">{currentBranch}</span>
<GitBranch className={`text-gray-600 dark:text-gray-400 ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
<div className="flex items-center gap-1">
<span className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{currentBranch}</span>
{/* Remote status indicators */}
{remoteStatus?.hasRemote && (
<div className="flex items-center gap-1 text-xs">
{remoteStatus.ahead > 0 && (
<span className="text-green-600 dark:text-green-400" title={`${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} ahead`}>
{remoteStatus.ahead}
</span>
)}
{remoteStatus.behind > 0 && (
<span className="text-blue-600 dark:text-blue-400" title={`${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} behind`}>
{remoteStatus.behind}
</span>
)}
{remoteStatus.isUpToDate && (
<span className="text-gray-500 dark:text-gray-400" title="Up to date with remote">
</span>
)}
</div>
)}
</div>
<ChevronDown className={`w-3 h-3 text-gray-500 transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />
</button>
@@ -495,17 +714,70 @@ function GitPanel({ selectedProject, isMobile }) {
)}
</div>
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}>
{/* Remote action buttons - smart logic based on ahead/behind status */}
{remoteStatus?.hasRemote && !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>
)}
</>
)}
<button
onClick={() => {
fetchGitStatus();
fetchBranches();
fetchRemoteStatus();
}}
disabled={isLoading}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
className={`hover:bg-gray-100 dark:hover:bg-gray-800 rounded ${isMobile ? 'p-1' : 'p-1.5'}`}
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
<RefreshCw className={`${isLoading ? 'animate-spin' : ''} ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
</button>
</div>
</div>
{/* Git Repository Not Found Message */}
{gitStatus?.error ? (
@@ -523,8 +795,12 @@ function GitPanel({ selectedProject, isMobile }) {
</div>
) : (
<>
{/* Tab Navigation - Only show when git is available */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
{/* Tab Navigation - Only show when git is available and no files expanded */}
<div className={`flex border-b border-gray-200 dark:border-gray-700 transition-all duration-300 ease-in-out ${
expandedFiles.size === 0
? 'max-h-16 opacity-100 translate-y-0'
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
}`}>
<button
onClick={() => setActiveView('changes')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
@@ -555,9 +831,41 @@ function GitPanel({ selectedProject, isMobile }) {
{/* Changes View */}
{activeView === 'changes' && (
<>
{/* Mobile Commit Toggle Button / Desktop Always Visible - Hide when files expanded */}
<div className={`transition-all duration-300 ease-in-out ${
expandedFiles.size === 0
? 'max-h-96 opacity-100 translate-y-0'
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
}`}>
{isMobile && isCommitAreaCollapsed ? (
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setIsCommitAreaCollapsed(false)}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
<GitCommit className="w-4 h-4" />
<span>Commit {selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''}</span>
<ChevronDown className="w-3 h-3" />
</button>
</div>
) : (
<>
{/* Commit Message Input */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
{/* Mobile collapse button */}
{isMobile && (
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Commit Changes</span>
<button
onClick={() => setIsCommitAreaCollapsed(true)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
>
<ChevronDown className="w-4 h-4 rotate-180" />
</button>
</div>
)}
<div className="relative">
<textarea
ref={textareaRef}
@@ -599,7 +907,10 @@ function GitPanel({ selectedProject, isMobile }) {
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={handleCommit}
onClick={() => setConfirmAction({
type: 'commit',
message: `Commit ${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} with message: "${commitMessage.trim()}"?`
})}
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
>
@@ -610,14 +921,21 @@ function GitPanel({ selectedProject, isMobile }) {
</div>
</>
)}
</div>
</>
)}
{/* File Selection Controls - Only show in changes view and when git is working */}
{/* File Selection Controls - Only show in changes view and when git is working and no files expanded */}
{activeView === 'changes' && gitStatus && !gitStatus.error && (
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<span className="text-xs text-gray-600 dark:text-gray-400">
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} files selected
<div className={`border-b border-gray-200 dark:border-gray-700 flex items-center justify-between transition-all duration-300 ease-in-out ${isMobile ? 'px-3 py-1.5' : 'px-4 py-2'} ${
expandedFiles.size === 0
? 'max-h-16 opacity-100 translate-y-0'
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
}`}>
<span className={`text-gray-600 dark:text-gray-400 ${isMobile ? 'text-xs' : 'text-xs'}`}>
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} {isMobile ? '' : 'files'} selected
</span>
<div className="flex gap-2">
<div className={`flex ${isMobile ? 'gap-1' : 'gap-2'}`}>
<button
onClick={() => {
const allFiles = new Set([
@@ -628,23 +946,23 @@ function GitPanel({ selectedProject, isMobile }) {
]);
setSelectedFiles(allFiles);
}}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 ${isMobile ? 'text-xs' : 'text-xs'}`}
>
Select All
{isMobile ? 'All' : 'Select All'}
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
onClick={() => setSelectedFiles(new Set())}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 ${isMobile ? 'text-xs' : 'text-xs'}`}
>
Deselect All
{isMobile ? 'None' : 'Deselect All'}
</button>
</div>
</div>
)}
{/* Status Legend Toggle */}
{!gitStatus?.error && (
{/* Status Legend Toggle - Hide on mobile by default */}
{!gitStatus?.error && !isMobile && (
<div className="border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowLegend(!showLegend)}
@@ -793,6 +1111,78 @@ function GitPanel({ selectedProject, isMobile }) {
</div>
</div>
)}
{/* Confirmation Modal */}
{confirmAction && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setConfirmAction(null)} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
<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'
}`}>
<AlertTriangle className={`w-5 h-5 ${
confirmAction.type === 'discard' ? '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 === 'commit' ? 'Confirm Commit' :
confirmAction.type === 'pull' ? 'Confirm Pull' : 'Confirm Push'}
</h3>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
{confirmAction.message}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => setConfirmAction(null)}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
>
Cancel
</button>
<button
onClick={confirmAndExecute}
className={`px-4 py-2 text-sm text-white rounded-md ${
confirmAction.type === 'discard'
? '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'
: 'bg-orange-600 hover:bg-orange-700'
} flex items-center space-x-2`}
>
{confirmAction.type === 'discard' ? (
<>
<Trash2 className="w-4 h-4" />
<span>Discard</span>
</>
) : confirmAction.type === 'commit' ? (
<>
<Check className="w-4 h-4" />
<span>Commit</span>
</>
) : confirmAction.type === 'pull' ? (
<>
<Download className="w-4 h-4" />
<span>Pull</span>
</>
) : (
<>
<Upload className="w-4 h-4" />
<span>Push</span>
</>
)}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -5,6 +5,27 @@ import { ClipboardAddon } from '@xterm/addon-clipboard';
import { WebglAddon } from '@xterm/addon-webgl';
import 'xterm/css/xterm.css';
// CSS to remove xterm focus outline
const xtermStyles = `
.xterm .xterm-screen {
outline: none !important;
}
.xterm:focus .xterm-screen {
outline: none !important;
}
.xterm-screen:focus {
outline: none !important;
}
`;
// Inject styles
if (typeof document !== 'undefined') {
const styleSheet = document.createElement('style');
styleSheet.type = 'text/css';
styleSheet.innerText = xtermStyles;
document.head.appendChild(styleSheet);
}
// Global store for shell sessions to persist across tab switches
const shellSessions = new Map();
@@ -138,6 +159,14 @@ function Shell({ selectedProject, selectedSession, isActive }) {
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
// Send terminal size to backend after reattaching
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
cols: terminal.current.cols,
rows: terminal.current.rows
}));
}
}
}, 100);
@@ -226,6 +255,13 @@ function Shell({ selectedProject, selectedSession, isActive }) {
terminal.current.open(terminalRef.current);
// Wait for terminal to be fully rendered, then fit
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
}
}, 50);
// Add keyboard shortcuts for copy/paste
terminal.current.attachCustomKeyEventHandler((event) => {
// Ctrl+C or Cmd+C for copy (when text is selected)
@@ -252,10 +288,18 @@ function Shell({ selectedProject, selectedSession, isActive }) {
return true;
});
// Ensure terminal takes full space
// Ensure terminal takes full space and notify backend of size
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
// Send terminal size to backend after fitting
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
cols: terminal.current.cols,
rows: terminal.current.rows
}));
}
}
}, 100);
@@ -276,6 +320,14 @@ function Shell({ selectedProject, selectedSession, isActive }) {
if (fitAddon.current && terminal.current) {
setTimeout(() => {
fitAddon.current.fit();
// Send updated terminal size to backend after resize
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
cols: terminal.current.cols,
rows: terminal.current.rows
}));
}
}, 50);
}
});
@@ -309,10 +361,18 @@ function Shell({ selectedProject, selectedSession, isActive }) {
useEffect(() => {
if (!isActive || !isInitialized) return;
// Fit terminal when tab becomes active
// Fit terminal when tab becomes active and notify backend
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
// Send terminal size to backend after tab activation
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
cols: terminal.current.cols,
rows: terminal.current.rows
}));
}
}
}, 100);
}, [isActive, isInitialized]);
@@ -363,16 +423,38 @@ function Shell({ selectedProject, selectedSession, isActive }) {
setIsConnected(true);
setIsConnecting(false);
// Send initial setup with project path and session info
// Wait for terminal to be ready, then fit and send dimensions
setTimeout(() => {
if (fitAddon.current && terminal.current) {
// Force a fit to ensure proper dimensions
fitAddon.current.fit();
// Wait a bit more for fit to complete, then send dimensions
setTimeout(() => {
const initPayload = {
type: 'init',
projectPath: selectedProject.fullPath || selectedProject.path,
sessionId: selectedSession?.id,
hasSession: !!selectedSession
hasSession: !!selectedSession,
cols: terminal.current.cols,
rows: terminal.current.rows
};
ws.current.send(JSON.stringify(initPayload));
// Also send resize message immediately after init
setTimeout(() => {
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
cols: terminal.current.cols,
rows: terminal.current.rows
}));
}
}, 100);
}, 50);
}
}, 200);
};
ws.current.onmessage = (event) => {
@@ -442,7 +524,7 @@ function Shell({ selectedProject, selectedSession, isActive }) {
}
return (
<div className="h-full flex flex-col bg-gray-900">
<div className="h-full flex flex-col bg-gray-900 w-full">
{/* Header */}
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between">
@@ -494,7 +576,7 @@ function Shell({ selectedProject, selectedSession, isActive }) {
{/* Terminal */}
<div className="flex-1 p-2 overflow-hidden relative">
<div ref={terminalRef} className="h-full w-full" />
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
{/* Loading state */}
{!isInitialized && (

View File

@@ -3,7 +3,7 @@ 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 } from 'lucide-react';
import { X, Plus, Settings, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Play, Globe, Terminal, Zap } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
function ToolsSettings({ isOpen, onClose }) {
@@ -17,6 +17,32 @@ 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
config: {
command: '',
args: [],
env: {},
url: '',
headers: {},
timeout: 30000
}
});
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');
// Common tool patterns
const commonTools = [
'Bash(git log:*)',
@@ -35,13 +61,219 @@ function ToolsSettings({ isOpen, onClose }) {
'WebSearch'
];
// MCP API functions
const fetchMcpServers = async () => {
try {
const token = localStorage.getItem('auth-token');
// First try to get servers using Claude CLI
const cliResponse = await fetch('/api/mcp/cli/list', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (cliResponse.ok) {
const cliData = await cliResponse.json();
if (cliData.success && cliData.servers) {
// Convert CLI format to our format
const servers = cliData.servers.map(server => ({
id: server.name,
name: server.name,
type: server.type,
scope: 'user',
config: {
command: server.command || '',
args: server.args || [],
env: server.env || {},
url: server.url || '',
headers: server.headers || {},
timeout: 30000
},
created: new Date().toISOString(),
updated: new Date().toISOString()
}));
setMcpServers(servers);
return;
}
}
// Fallback to direct config reading
const response = await fetch('/api/mcp/servers?scope=user', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
setMcpServers(data.servers || []);
} else {
console.error('Failed to fetch MCP servers');
}
} catch (error) {
console.error('Error fetching MCP servers:', error);
}
};
const saveMcpServer = async (serverData) => {
try {
const token = localStorage.getItem('auth-token');
if (editingMcpServer) {
// For editing, remove old server and add new one
await deleteMcpServer(editingMcpServer.id, 'user');
}
// Use Claude CLI to add the server
const response = await fetch('/api/mcp/cli/add', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: serverData.name,
type: serverData.type,
command: serverData.config?.command,
args: serverData.config?.args || [],
url: serverData.config?.url,
headers: serverData.config?.headers || {},
env: serverData.config?.env || {}
})
});
if (response.ok) {
const result = await response.json();
if (result.success) {
await fetchMcpServers(); // Refresh the list
return true;
} else {
throw new Error(result.error || 'Failed to save server via Claude CLI');
}
} else {
const error = await response.json();
throw new Error(error.error || 'Failed to save server');
}
} catch (error) {
console.error('Error saving MCP server:', error);
throw error;
}
};
const deleteMcpServer = async (serverId, scope = 'user') => {
try {
const token = localStorage.getItem('auth-token');
// Use Claude CLI to remove the server
const response = await fetch(`/api/mcp/cli/remove/${serverId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const result = await response.json();
if (result.success) {
await fetchMcpServers(); // Refresh the list
return true;
} else {
throw new Error(result.error || 'Failed to delete server via Claude CLI');
}
} else {
const error = await response.json();
throw new Error(error.error || 'Failed to delete server');
}
} catch (error) {
console.error('Error deleting MCP server:', error);
throw error;
}
};
const testMcpServer = async (serverId, scope = 'user') => {
try {
const token = localStorage.getItem('auth-token');
const response = await fetch(`/api/mcp/servers/${serverId}/test?scope=${scope}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
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 server');
}
} catch (error) {
console.error('Error testing MCP server:', error);
throw error;
}
};
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 {
const token = localStorage.getItem('auth-token');
const response = await fetch(`/api/mcp/servers/${serverId}/tools?scope=${scope}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
return data.toolsResult;
} else {
const error = await response.json();
throw new Error(error.error || 'Failed to discover tools');
}
} catch (error) {
console.error('Error discovering MCP tools:', error);
throw error;
}
};
useEffect(() => {
if (isOpen) {
loadSettings();
}
}, [isOpen]);
const loadSettings = () => {
const loadSettings = async () => {
try {
// Load from localStorage
@@ -60,6 +292,9 @@ function ToolsSettings({ isOpen, onClose }) {
setSkipPermissions(false);
setProjectSortOrder('name');
}
// Load MCP servers from API
await fetchMcpServers();
} catch (error) {
console.error('Error loading tool settings:', error);
// Set defaults on error
@@ -122,6 +357,149 @@ function ToolsSettings({ isOpen, onClose }) {
setDisallowedTools(disallowedTools.filter(t => t !== tool));
};
// MCP form handling functions
const resetMcpForm = () => {
setMcpFormData({
name: '',
type: 'stdio',
scope: 'user', // Always use user scope
config: {
command: '',
args: [],
env: {},
url: '',
headers: {},
timeout: 30000
}
});
setEditingMcpServer(null);
setShowMcpForm(false);
setMcpConfigTestResult(null);
setMcpConfigTested(false);
setMcpConfigTesting(false);
};
const openMcpForm = (server = null) => {
if (server) {
setEditingMcpServer(server);
setMcpFormData({
name: server.name,
type: server.type,
scope: server.scope,
config: { ...server.config }
});
} else {
resetMcpForm();
}
setShowMcpForm(true);
};
const handleMcpSubmit = async (e) => {
e.preventDefault();
setMcpLoading(true);
try {
await saveMcpServer(mcpFormData);
resetMcpForm();
setSaveStatus('success');
} catch (error) {
alert(`Error: ${error.message}`);
setSaveStatus('error');
} finally {
setMcpLoading(false);
}
};
const handleMcpDelete = async (serverId, scope) => {
if (confirm('Are you sure you want to delete this MCP server?')) {
try {
await deleteMcpServer(serverId, scope);
setSaveStatus('success');
} catch (error) {
alert(`Error: ${error.message}`);
setSaveStatus('error');
}
}
};
const handleMcpTest = async (serverId, scope) => {
try {
setMcpTestResults({ ...mcpTestResults, [serverId]: { loading: true } });
const result = await testMcpServer(serverId, scope);
setMcpTestResults({ ...mcpTestResults, [serverId]: result });
} catch (error) {
setMcpTestResults({
...mcpTestResults,
[serverId]: {
success: false,
message: error.message,
details: []
}
});
}
};
const handleMcpToolsDiscovery = async (serverId, scope) => {
try {
setMcpToolsLoading({ ...mcpToolsLoading, [serverId]: true });
const result = await discoverMcpTools(serverId, scope);
setMcpServerTools({ ...mcpServerTools, [serverId]: result });
} catch (error) {
setMcpServerTools({
...mcpServerTools,
[serverId]: {
success: false,
tools: [],
resources: [],
prompts: []
}
});
} finally {
setMcpToolsLoading({ ...mcpToolsLoading, [serverId]: false });
}
};
const updateMcpConfig = (key, value) => {
setMcpFormData(prev => ({
...prev,
config: {
...prev.config,
[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) {
case 'stdio': return <Terminal className="w-4 h-4" />;
case 'sse': return <Zap className="w-4 h-4" />;
case 'http': return <Globe className="w-4 h-4" />;
default: return <Server className="w-4 h-4" />;
}
};
if (!isOpen) return null;
return (
@@ -131,7 +509,7 @@ function ToolsSettings({ isOpen, onClose }) {
<div className="flex items-center gap-3">
<Settings className="w-5 h-5 md:w-6 md:h-6 text-blue-600" />
<h2 className="text-lg md:text-xl font-semibold text-foreground">
Tools Settings
Settings
</h2>
</div>
<Button
@@ -145,16 +523,41 @@ function ToolsSettings({ isOpen, onClose }) {
</div>
<div className="flex-1 overflow-y-auto">
{/* Tab Navigation */}
<div className="border-b border-border">
<div className="flex px-4 md:px-6">
<button
onClick={() => setActiveTab('tools')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'tools'
? 'border-blue-600 text-blue-600 dark:text-blue-400'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
Tools
</button>
<button
onClick={() => setActiveTab('appearance')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'appearance'
? 'border-blue-600 text-blue-600 dark:text-blue-400'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
Appearance
</button>
</div>
</div>
<div className="p-4 md:p-6 space-y-6 md:space-y-8 pb-safe-area-inset-bottom">
{/* Appearance Tab */}
{activeTab === 'appearance' && (
<div className="space-y-6 md:space-y-8">
{activeTab === 'appearance' && (
<div className="space-y-6 md:space-y-8">
{/* Theme Settings */}
<div className="space-y-4">
<div className="flex items-center gap-3">
{isDarkMode ? <Moon className="w-5 h-5 text-blue-500" /> : <Sun className="w-5 h-5 text-yellow-500" />}
<h3 className="text-lg font-medium text-foreground">
Appearance
</h3>
</div>
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
@@ -186,9 +589,12 @@ function ToolsSettings({ isOpen, onClose }) {
</span>
</button>
</div>
</div>
</div>
{/* Project Sorting - Moved under Appearance */}
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
{/* Project Sorting */}
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
@@ -210,6 +616,14 @@ function ToolsSettings({ isOpen, onClose }) {
</div>
</div>
</div>
)}
</div>
)}
{/* Tools Tab */}
{activeTab === 'tools' && (
<div className="space-y-6 md:space-y-8">
{/* Skip Permissions */}
<div className="space-y-4">
@@ -393,6 +807,433 @@ function ToolsSettings({ isOpen, onClose }) {
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> - Block all rm commands (dangerous)</li>
</ul>
</div>
{/* MCP Server Management */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="w-5 h-5 text-purple-500" />
<h3 className="text-lg font-medium text-foreground">
MCP Servers
</h3>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Model Context Protocol servers provide additional tools and data sources to Claude
</p>
</div>
<div className="flex justify-between items-center">
<Button
onClick={() => openMcpForm()}
className="bg-purple-600 hover:bg-purple-700 text-white"
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
Add MCP Server
</Button>
</div>
{/* MCP Servers List */}
<div className="space-y-2">
{mcpServers.map(server => (
<div key={server.id} className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{getTransportIcon(server.type)}
<span className="font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">
{server.type}
</Badge>
<Badge variant="outline" className="text-xs">
{server.scope}
</Badge>
</div>
<div className="text-sm text-muted-foreground space-y-1">
{server.type === 'stdio' && server.config.command && (
<div>Command: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
)}
{(server.type === 'sse' || server.type === 'http') && server.config.url && (
<div>URL: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.url}</code></div>
)}
{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>
)}
</div>
{/* Test Results */}
{mcpTestResults[server.id] && (
<div className={`mt-2 p-2 rounded text-xs ${
mcpTestResults[server.id].success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
<div className="font-medium">{mcpTestResults[server.id].message}</div>
{mcpTestResults[server.id].details && mcpTestResults[server.id].details.length > 0 && (
<ul className="mt-1 space-y-0.5">
{mcpTestResults[server.id].details.map((detail, i) => (
<li key={i}> {detail}</li>
))}
</ul>
)}
</div>
)}
{/* Tools Discovery Results */}
{mcpServerTools[server.id] && (
<div className="mt-2 p-2 rounded text-xs bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800">
<div className="font-medium mb-2">Available Tools & Resources</div>
{mcpServerTools[server.id].tools && mcpServerTools[server.id].tools.length > 0 && (
<div className="mb-2">
<div className="font-medium text-xs mb-1">Tools ({mcpServerTools[server.id].tools.length}):</div>
<ul className="space-y-0.5">
{mcpServerTools[server.id].tools.map((tool, i) => (
<li key={i} className="flex items-start gap-1">
<span className="text-blue-400 mt-0.5"></span>
<div>
<code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{tool.name}</code>
{tool.description && tool.description !== 'No description provided' && (
<span className="ml-1 text-xs opacity-75">- {tool.description}</span>
)}
</div>
</li>
))}
</ul>
</div>
)}
{mcpServerTools[server.id].resources && mcpServerTools[server.id].resources.length > 0 && (
<div className="mb-2">
<div className="font-medium text-xs mb-1">Resources ({mcpServerTools[server.id].resources.length}):</div>
<ul className="space-y-0.5">
{mcpServerTools[server.id].resources.map((resource, i) => (
<li key={i} className="flex items-start gap-1">
<span className="text-blue-400 mt-0.5"></span>
<div>
<code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{resource.name}</code>
{resource.description && resource.description !== 'No description provided' && (
<span className="ml-1 text-xs opacity-75">- {resource.description}</span>
)}
</div>
</li>
))}
</ul>
</div>
)}
{mcpServerTools[server.id].prompts && mcpServerTools[server.id].prompts.length > 0 && (
<div>
<div className="font-medium text-xs mb-1">Prompts ({mcpServerTools[server.id].prompts.length}):</div>
<ul className="space-y-0.5">
{mcpServerTools[server.id].prompts.map((prompt, i) => (
<li key={i} className="flex items-start gap-1">
<span className="text-blue-400 mt-0.5"></span>
<div>
<code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{prompt.name}</code>
{prompt.description && prompt.description !== 'No description provided' && (
<span className="ml-1 text-xs opacity-75">- {prompt.description}</span>
)}
</div>
</li>
))}
</ul>
</div>
)}
{(!mcpServerTools[server.id].tools || mcpServerTools[server.id].tools.length === 0) &&
(!mcpServerTools[server.id].resources || mcpServerTools[server.id].resources.length === 0) &&
(!mcpServerTools[server.id].prompts || mcpServerTools[server.id].prompts.length === 0) && (
<div className="text-xs opacity-75">No tools, resources, or prompts discovered</div>
)}
</div>
)}
</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"
>
<Edit3 className="w-4 h-4" />
</Button>
<Button
onClick={() => handleMcpDelete(server.id, server.scope)}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
))}
{mcpServers.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No MCP servers configured
</div>
)}
</div>
</div>
{/* MCP Server Form Modal */}
{showMcpForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[110] p-4">
<div className="bg-background border border-border rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="text-lg font-medium text-foreground">
{editingMcpServer ? 'Edit MCP Server' : 'Add MCP Server'}
</h3>
<Button variant="ghost" size="sm" onClick={resetMcpForm}>
<X className="w-4 h-4" />
</Button>
</div>
<form onSubmit={handleMcpSubmit} className="p-4 space-y-4">
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Server Name *
</label>
<Input
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>
</div>
{/* Scope is fixed to user - no selection needed */}
{/* Transport-specific Config */}
{mcpFormData.type === 'stdio' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Command *
</label>
<Input
value={mcpFormData.config.command}
onChange={(e) => updateMcpConfig('command', e.target.value)}
placeholder="/path/to/mcp-server"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Arguments (one per line)
</label>
<textarea
value={Array.isArray(mcpFormData.config.args) ? mcpFormData.config.args.join('\n') : ''}
onChange={(e) => updateMcpConfig('args', e.target.value.split('\n').filter(arg => arg.trim()))}
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"
rows="3"
placeholder="--api-key&#10;abc123"
/>
</div>
</div>
)}
{(mcpFormData.type === 'sse' || mcpFormData.type === 'http') && (
<div>
<label className="block text-sm font-medium text-foreground mb-2">
URL *
</label>
<Input
value={mcpFormData.config.url}
onChange={(e) => updateMcpConfig('url', e.target.value)}
placeholder="https://api.example.com/mcp"
type="url"
required
/>
</div>
)}
{/* Environment Variables */}
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Environment Variables (KEY=value, one per line)
</label>
<textarea
value={Object.entries(mcpFormData.config.env || {}).map(([k, v]) => `${k}=${v}`).join('\n')}
onChange={(e) => {
const env = {};
e.target.value.split('\n').forEach(line => {
const [key, ...valueParts] = line.split('=');
if (key && key.trim()) {
env[key.trim()] = valueParts.join('=').trim();
}
});
updateMcpConfig('env', env);
}}
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"
rows="3"
placeholder="API_KEY=your-key&#10;DEBUG=true"
/>
</div>
{(mcpFormData.type === 'sse' || mcpFormData.type === 'http') && (
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Headers (KEY=value, one per line)
</label>
<textarea
value={Object.entries(mcpFormData.config.headers || {}).map(([k, v]) => `${k}=${v}`).join('\n')}
onChange={(e) => {
const headers = {};
e.target.value.split('\n').forEach(line => {
const [key, ...valueParts] = line.split('=');
if (key && key.trim()) {
headers[key.trim()] = valueParts.join('=').trim();
}
});
updateMcpConfig('headers', headers);
}}
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"
rows="3"
placeholder="Authorization=Bearer token&#10;X-API-Key=your-key"
/>
</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}>
Cancel
</Button>
<Button
type="submit"
disabled={mcpLoading}
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-50"
>
{mcpLoading ? 'Saving...' : (editingMcpServer ? 'Update Server' : 'Add Server')}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
)}
</div>
</div>

View File

@@ -32,9 +32,31 @@ export const ThemeProvider = ({ children }) => {
if (isDarkMode) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
// Update iOS status bar style and theme color for dark mode
const statusBarMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
if (statusBarMeta) {
statusBarMeta.setAttribute('content', 'black-translucent');
}
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute('content', '#0c1117'); // Dark background color (hsl(222.2 84% 4.9%))
}
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
// Update iOS status bar style and theme color for light mode
const statusBarMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
if (statusBarMeta) {
statusBarMeta.setAttribute('content', 'default');
}
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute('content', '#ffffff'); // Light background color
}
}
}, [isDarkMode]);

View File

@@ -64,7 +64,7 @@
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--ring: 217.2 91.2% 59.8%;
}
}
@@ -99,8 +99,12 @@
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Color transitions for theme switching */
* {
/* Color transitions for theme switching - exclude interactive elements */
body, div, section, article, aside, header, footer, nav, main,
h1, h2, h3, h4, h5, h6, p, span, blockquote,
ul, ol, li, dl, dt, dd,
table, thead, tbody, tfoot, tr, td, th,
form, fieldset, legend, label {
transition: background-color 200ms ease-in-out,
border-color 200ms ease-in-out,
color 200ms ease-in-out;